Harden Pulse Assistant action governance

This commit is contained in:
rcourtman 2026-04-23 23:01:51 +01:00
parent d57987d48d
commit f8fd641978
29 changed files with 380 additions and 62 deletions

View file

@ -86,12 +86,19 @@ runtime cost control, and shared AI transport surfaces.
platform-native reads or writes must extend the shared Assistant tool
contracts, and read-only or augmentation-only platforms must stay explicit
there instead of drifting into provider-local tools.
8. Keep self-hosted Patrol quickstart messaging aligned with backend runtime
8. Keep Pulse Assistant action governance canonical in the shared tool
registry. Tool prompts and approval surfaces must derive read, mixed, write,
and approval-policy claims from `internal/ai/tools/registry.go` and
`internal/ai/tools/executor.go` instead of maintaining hand-written
prompt-only tool lists, and frontend approval cards must surface backend
approval risk/description without hiding a pending approval when skip or
deny fails.
9. Keep self-hosted Patrol quickstart messaging aligned with backend runtime
truth: the governed quickstart contract is Patrol-only first-run
acceleration on activated or trial-backed installs with server-authoritative
run inventory, not a general hosted chat entitlement or a replacement for
BYOK once Patrol leaves the quickstart path.
9. Keep discovery-analysis prompt bounds and response budgets aligned across
10. Keep discovery-analysis prompt bounds and response budgets aligned across
`internal/ai/service.go` and the shared service-discovery prompt builders:
the runtime must reserve enough output tokens for structured discovery JSON,
and discovery prompts must cap fact/path/port fan-out explicitly instead of

View file

@ -287,6 +287,11 @@ regression protection.
second resource scan, alert fetch, storage read, or recovery read just to
clarify first-viewport copy.
32. Keep infrastructure summary consumers on the compact dashboard overview rather than reopening the all-resources hook. `frontend-modern/src/hooks/useDashboardTrends.ts`, `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`, and adjacent dashboard summary consumers may derive chart identity and storage presence from the overview payload they were already given, but they must not call `useResources()` or mount a second unfiltered unified-resource fetch path inside the dashboard hot path. That rule also applies to globally mounted helpers such as `frontend-modern/src/components/AI/Chat/index.tsx`: closed assistant surfaces must read the live websocket snapshot or existing unified-resource cache rather than forcing the dashboard to pay for `all-resources` just because the shell component is mounted. When that assistant shell changes presentation, `frontend-modern/src/utils/aiChatPresentation.ts` must remain the canonical owner for launcher, drawer, session-menu, and empty-state copy so hot-path consumers do not grow one-off inline strings or extra state branches alongside the mounted shell. Blocking shared dialogs must also suppress closed assistant affordances through the shared dialog runtime instead of leaving the mounted shell clickable behind another overlay.
Approval presentation inside that mounted assistant shell must stay
state-local to the existing drawer/session state and backend approval
endpoints. Deny/skip failure handling may preserve the pending approval
card, but it must not add polling, resource hydration, or mounted-shell work
to recover UI state.
That same mounted-shell hot path must protect usable width on constrained
viewports. When the shared assistant drawer opens inside
`frontend-modern/src/components/AI/Chat/index.tsx`, it may not shrink the

View file

@ -7,6 +7,11 @@ interface ApprovalCardProps {
onSkip: () => void;
}
const riskLabel = (risk?: string) => {
const normalized = risk?.trim();
return normalized ? normalized.toUpperCase() : 'REVIEW';
};
export const ApprovalCard: Component<ApprovalCardProps> = (props) => {
return (
<div class="rounded-md border border-amber-300 dark:border-amber-700 overflow-hidden shadow-sm">
@ -23,6 +28,9 @@ export const ApprovalCard: Component<ApprovalCardProps> = (props) => {
</svg>
</div>
<span class="font-semibold">Approval Required</span>
<span class="px-1.5 py-0.5 bg-amber-200 dark:bg-amber-800 rounded text-[10px] font-bold uppercase tracking-wider">
{riskLabel(props.approval.risk)}
</span>
<Show when={props.approval.runOnHost}>
<span class="px-1.5 py-0.5 bg-amber-200 dark:bg-amber-800 rounded text-[10px] font-bold uppercase tracking-wider">
Agent
@ -37,6 +45,31 @@ export const ApprovalCard: Component<ApprovalCardProps> = (props) => {
{/* Command */}
<div class="px-3 py-3 bg-amber-50 dark:bg-amber-900">
<Show when={props.approval.description}>
<p class="mb-3 text-xs leading-relaxed text-amber-900 dark:text-amber-100">
{props.approval.description}
</p>
</Show>
<div class="mb-3 grid grid-cols-1 sm:grid-cols-3 gap-2 text-[11px]">
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">Tool</div>
<div class="text-base-content break-all">{props.approval.toolName}</div>
</div>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">Target</div>
<div class="text-base-content break-all">
{props.approval.targetHost || 'Pulse runtime'}
</div>
</div>
<div>
<div class="uppercase font-semibold text-amber-700 dark:text-amber-300">Execution</div>
<div class="text-base-content">
{props.approval.runOnHost ? 'Agent routed' : 'Pulse API'}
</div>
</div>
</div>
<div class="mb-3 p-2 bg-surface rounded border border-amber-200 dark:border-amber-700">
<code class="text-xs font-mono text-base-content break-all">
{props.approval.command}

View file

@ -438,7 +438,7 @@ describe('AIChat', () => {
it('opens control menu on click', () => {
renderChat();
fireEvent.click(screen.getByTitle('Control mode'));
expect(screen.getByText('Control mode for this chat')).toBeInTheDocument();
expect(screen.getByText('Default control mode')).toBeInTheDocument();
expect(screen.getByText('No commands or control actions')).toBeInTheDocument();
expect(screen.getByText('Ask before running commands')).toBeInTheDocument();
expect(screen.getByText('Executes without approval (Pro)')).toBeInTheDocument();

View file

@ -196,4 +196,26 @@ describe('ApprovalCard', () => {
expect(screen.getByText(longCommand)).toBeInTheDocument();
});
it('renders governed action context when provided', () => {
render(() => (
<ApprovalCard
approval={makeApproval({
risk: 'high',
description: 'Restart the web service after applying the new config.',
targetHost: 'web1',
toolName: 'pulse_control',
})}
onApprove={vi.fn()}
onSkip={vi.fn()}
/>
));
expect(screen.getByText('HIGH')).toBeInTheDocument();
expect(
screen.getByText('Restart the web service after applying the new config.'),
).toBeInTheDocument();
expect(screen.getByText('pulse_control')).toBeInTheDocument();
expect(screen.getByText('web1')).toBeInTheDocument();
});
});

View file

@ -551,6 +551,8 @@ describe('useChat', () => {
tool_name: 'run_command',
run_on_host: true,
target_host: 'web1',
risk: 'high',
description: 'Restart web service',
approval_id: 'appr-5',
},
});
@ -563,6 +565,8 @@ describe('useChat', () => {
toolName: 'run_command',
runOnHost: true,
targetHost: 'web1',
risk: 'high',
description: 'Restart web service',
isExecuting: false,
approvalId: 'appr-5',
});
@ -1128,7 +1132,7 @@ describe('useChat', () => {
dispose();
});
it('re-initiates stream when idle after answering (reconnection path)', async () => {
it('does not start a blank follow-up stream when idle after answering', async () => {
let fireEvent!: TestStreamDispatch;
let chatCallCount = 0;
mockChat.mockImplementation(
@ -1156,12 +1160,7 @@ describe('useChat', () => {
await chat.answerQuestion(msgId, 'q-50', [{ id: 'q1', value: 'yes' }]);
// Should have called chat again with empty prompt for reconnection
expect(chatCallCount).toBeGreaterThan(chatCallsBefore);
// The reconnection call should use empty prompt and same session
const reconnectCall = mockChat.mock.calls[mockChat.mock.calls.length - 1];
expect(reconnectCall[0]).toBe('');
expect(reconnectCall[1]).toBe('s');
expect(chatCallCount).toBe(chatCallsBefore);
dispose();
});

View file

@ -7,6 +7,7 @@ import type {
ChatMessage,
ToolExecution,
StreamDisplayEvent,
PendingApproval,
PendingQuestion,
PendingTool,
} from '../types';
@ -230,7 +231,9 @@ export function useChat(options: UseChatOptions = {}) {
// Find the matching pending tool (prefer tool ID, then fall back to name).
const resolvedPendingIndex = data.id
? pendingTools.findIndex((t) => t.id === data.id)
: pendingTools.findIndex((t) => normalizeChatToolName(t.name) === normalizedEndName);
: pendingTools.findIndex(
(t) => normalizeChatToolName(t.name) === normalizedEndName,
);
const updatedPending =
resolvedPendingIndex >= 0
? [
@ -320,10 +323,12 @@ export function useChat(options: UseChatOptions = {}) {
tool_name: string;
run_on_host: boolean;
target_host?: string;
risk?: string;
description?: string;
approval_id?: string;
};
const approval = {
const approval: PendingApproval = {
command: data.command,
toolId: data.tool_id,
toolName: data.tool_name,
@ -332,6 +337,12 @@ export function useChat(options: UseChatOptions = {}) {
isExecuting: false,
approvalId: data.approval_id,
};
if (typeof data.risk === 'string') {
approval.risk = data.risk;
}
if (typeof data.description === 'string') {
approval.description = data.description;
}
// Add to streamEvents for chronological display
const updated = addStreamEvent(msg, { type: 'approval', approval });
@ -679,44 +690,6 @@ export function useChat(options: UseChatOptions = {}) {
// Remove the question card - it's been handled
updateQuestion(messageId, questionId, { removed: true });
// After answering, check if the stream is still active.
// If it closed (e.g. on question), we force a re-connection to receive continuation events.
if (!isLoading()) {
logger.debug('[useChat] Stream closed, re-initiating to catch continuation', {
questionId,
messageId,
});
const currentSessionId = sessionId();
if (currentSessionId) {
setIsLoading(true);
abortControllerRef = new AbortController();
// Set the message back to streaming state to show the AI is working
setMessages((prev) =>
prev.map((m) => (m.id === messageId ? { ...m, isStreaming: true } : m)),
);
AIChatAPI.chat(
'', // Empty prompt - just resume listening for completion
currentSessionId,
model() || undefined,
(event) => {
processEvent(messageId, event);
},
abortControllerRef.signal,
)
.catch((err) => {
if (err instanceof Error && err.name === 'AbortError') return;
logger.error('[useChat] Re-connection failed:', err);
})
.finally(() => {
setIsLoading(false);
abortControllerRef = null;
});
}
}
logger.debug('[useChat] Question answered, waiting for AI to continue', {
questionId,
});

View file

@ -818,8 +818,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
chat.updateApproval(messageId, toolId, { removed: true });
} catch (error) {
logger.error('[AIChat] Skip/deny failed:', error);
// Still remove from UI even if API fails
chat.updateApproval(messageId, toolId, { removed: true });
notificationStore.error('Failed to skip approval');
}
};
@ -929,7 +928,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
<Show when={showControlMenu()}>
<div class="absolute right-0 mt-2 w-60 rounded-md border border-border bg-surface shadow-sm z-50 overflow-hidden">
<div class="px-3 py-2 text-[11px] text-muted border-b border-border">
Control mode for this chat
Default control mode
</div>
<button
class={`w-full text-left px-3 py-2.5 text-xs hover:bg-surface-hover transition-colors ${controlLevel() === 'read_only' ? getAIChatControlLevelPresentation('read_only').selectedClassName : ''}`}

View file

@ -20,6 +20,8 @@ export interface PendingApproval {
toolName: string;
runOnHost: boolean;
targetHost?: string;
risk?: string;
description?: string;
isExecuting?: boolean;
approvalId?: string; // ID of the approval record for API calls
}

View file

@ -360,7 +360,12 @@ func classifyToolByName(toolName string, args map[string]interface{}) ToolKind {
return ToolKindRead
case "pulse_kubernetes":
return ToolKindRead
switch actionLower {
case "scale", "restart", "delete_pod", "exec":
return ToolKindWrite
default:
return ToolKindRead
}
case "pulse_knowledge":
// knowledge operations: remember is write, recall is read

View file

@ -238,6 +238,11 @@ func TestClassifyToolCall(t *testing.T) {
{"pulse_docker control", "pulse_docker", map[string]interface{}{"action": "control"}, ToolKindWrite},
{"pulse_docker update", "pulse_docker", map[string]interface{}{"action": "update"}, ToolKindWrite},
// Kubernetes - depends on action
{"pulse_kubernetes pods", "pulse_kubernetes", map[string]interface{}{"action": "pods"}, ToolKindRead},
{"pulse_kubernetes scale", "pulse_kubernetes", map[string]interface{}{"action": "scale"}, ToolKindWrite},
{"pulse_kubernetes exec", "pulse_kubernetes", map[string]interface{}{"action": "exec"}, ToolKindWrite},
// File edit - depends on action
{"pulse_file_edit read", "pulse_file_edit", map[string]interface{}{"action": "read"}, ToolKindRead},
{"pulse_file_edit write", "pulse_file_edit", map[string]interface{}{"action": "write"}, ToolKindWrite},

View file

@ -1429,13 +1429,7 @@ func (s *Service) applyChatContextSettings() {
func (s *Service) buildSystemPrompt() string {
return `You are Pulse AI, a knowledgeable infrastructure assistant. You pair-program with the user on their homelab and infrastructure tasks.
## CAPABILITIES
- pulse_query: Find resources (VMs, containers, hosts) and their locations
- pulse_discovery: Get service details, config paths, ports, bind mounts
- pulse_control: Run commands on hosts and control only resources that explicitly support shared Pulse actions
- pulse_docker: Manage Docker containers
- pulse_file_edit: Read and edit configuration files
- pulse_question: Ask the user for missing information using a structured prompt (interactive only)
` + s.buildToolGovernancePromptSection() + `
## INFRASTRUCTURE TOPOLOGY
- Resources are organized hierarchically: nodes VMs/containers Docker containers
@ -1449,7 +1443,9 @@ func (s *Service) buildSystemPrompt() string {
- Use pulse_discovery to find bind mount mappings
## TOOL SELECTION
- pulse_control and pulse_docker are WRITE tools they change infrastructure state.
- Tool action modes and approval policies are generated from Pulse's tool registry. Treat that manifest as the source of truth for whether a tool is read-only, mixed, or write-capable.
- pulse_control and pulse_file_edit are WRITE tools they change infrastructure state.
- pulse_docker, pulse_kubernetes, pulse_alerts, and pulse_knowledge are MIXED tools their read subactions are safe, but their write or decision-recording subactions require the governed path described in the manifest.
- Not every VM or container supports control. Some API-backed platforms are read-only even when the resource type is "vm" or "system-container".
- ONLY use write tools when the user explicitly asks you to perform an action.
- For status checks or monitoring, use pulse_query or pulse_read instead.
@ -1475,6 +1471,99 @@ You are like a colleague doing pair programming on infrastructure tasks. Tool ca
- If told to call pulse_query or pulse_read first, you MUST do that before retrying the blocked action.`
}
func (s *Service) buildToolGovernancePromptSection() string {
manifest := []tools.ToolGovernanceDescriptor(nil)
if s != nil && s.executor != nil {
manifest = s.executor.ListToolGovernance()
}
if len(manifest) == 0 {
manifest = fallbackAssistantToolGovernance()
}
var b strings.Builder
b.WriteString("## AVAILABLE TOOL GOVERNANCE\n")
b.WriteString("This manifest is generated from Pulse's governed tool registry. Use only tools that are actually offered by the provider for the current turn.\n")
for _, tool := range manifest {
mode := tool.ActionMode
if mode == "" {
mode = tools.ToolActionRead
}
policy := strings.TrimSpace(tool.ApprovalPolicy)
if policy == "" {
policy = "no approval required"
}
summary := strings.TrimSpace(tool.Summary)
if summary == "" {
summary = firstPromptLine(tool.Description)
}
if summary != "" {
b.WriteString(fmt.Sprintf("- %s: mode=%s; approval=%s; %s\n", tool.Name, mode, policy, summary))
} else {
b.WriteString(fmt.Sprintf("- %s: mode=%s; approval=%s\n", tool.Name, mode, policy))
}
}
b.WriteString("- pulse_question: mode=interactive; approval=user answer required; asks the user for missing information using a structured prompt in interactive chat only.\n")
return strings.TrimRight(b.String(), "\n")
}
func fallbackAssistantToolGovernance() []tools.ToolGovernanceDescriptor {
return []tools.ToolGovernanceDescriptor{
{
Name: "pulse_query",
ActionMode: tools.ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Resolves canonical infrastructure identity, topology, config, and health without changing state.",
},
{
Name: "pulse_read",
ActionMode: tools.ToolActionRead,
ApprovalPolicy: "no approval required; write-like commands are rejected",
Summary: "Runs read-only infrastructure inspection such as logs, file reads, tails, and safe exec.",
},
{
Name: "pulse_discovery",
ActionMode: tools.ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Reads discovered service details, config paths, ports, and bind mounts.",
},
{
Name: "pulse_control",
ActionMode: tools.ToolActionWrite,
ApprovalPolicy: "hidden in read-only mode; approval required in controlled mode",
Summary: "Runs shared Pulse control actions and can control only resources that explicitly support shared Pulse actions.",
},
{
Name: "pulse_file_edit",
ActionMode: tools.ToolActionWrite,
ApprovalPolicy: "hidden in read-only mode; approval required in controlled mode",
Summary: "Changes files through the governed file-edit path.",
},
{
Name: "pulse_docker",
ActionMode: tools.ToolActionMixed,
ApprovalPolicy: "read/list actions are safe; control and update subactions require approval in controlled mode",
Summary: "Lists Docker state and performs governed Docker control/update subactions.",
},
{
Name: "pulse_kubernetes",
ActionMode: tools.ToolActionMixed,
ApprovalPolicy: "read/list/log actions are safe; scale, restart, delete, and exec subactions require approval in controlled mode",
Summary: "Reads Kubernetes topology and runs governed workload-control subactions.",
},
}
}
func firstPromptLine(description string) string {
description = strings.TrimSpace(description)
if description == "" {
return ""
}
if idx := strings.Index(description, "\n"); idx >= 0 {
description = strings.TrimSpace(description[:idx])
}
return strings.Join(strings.Fields(description), " ")
}
var recentContextPronounPattern = regexp.MustCompile(`(?i)\b(it|its|that|those|this|them|previous|earlier|last|same|former|latter)\b`)
var recentContextNounPattern = regexp.MustCompile(`(?i)\b(the (service|container|vm|node|host|docker|instance|one))\b`)

View file

@ -161,6 +161,12 @@ func TestBuildSystemPrompt_DoesNotClaimGenericVMControl(t *testing.T) {
if !strings.Contains(prompt, "control only resources that explicitly support shared Pulse actions") {
t.Fatalf("expected system prompt to describe capability-bound pulse_control usage, got %q", prompt)
}
if !strings.Contains(prompt, "## AVAILABLE TOOL GOVERNANCE") {
t.Fatalf("expected system prompt to include generated tool governance section, got %q", prompt)
}
if !strings.Contains(prompt, "pulse_kubernetes") {
t.Fatalf("expected system prompt to include the governed Kubernetes tool contract, got %q", prompt)
}
}
func TestFilterToolsForPrompt_RecoveryOnlyKeepsStorage(t *testing.T) {

View file

@ -934,6 +934,22 @@ func (e *PulseToolExecutor) ListTools() []Tool {
return available
}
// ListToolGovernance returns the governed manifest for currently available tools.
func (e *PulseToolExecutor) ListToolGovernance() []ToolGovernanceDescriptor {
tools := e.registry.ListToolGovernance(e.controlLevel)
if len(tools) == 0 {
return tools
}
available := make([]ToolGovernanceDescriptor, 0, len(tools))
for _, tool := range tools {
if e.isToolAvailable(tool.Name) {
available = append(available, tool)
}
}
return available
}
func (e *PulseToolExecutor) isToolAvailable(name string) bool {
switch name {
// Check tool availability based on primary requirements

View file

@ -197,6 +197,12 @@ func TestToolRegistry_ListTools(t *testing.T) {
require.Len(t, full, 2)
assert.Equal(t, "read", full[0].Name)
assert.Equal(t, "control", full[1].Name)
governance := registry.ListToolGovernance(ControlLevelControlled)
require.Len(t, governance, 2)
assert.Equal(t, ToolActionRead, governance[0].ActionMode)
assert.Equal(t, ToolActionWrite, governance[1].ActionMode)
assert.Equal(t, "hidden in read-only mode; approval required in controlled mode", governance[1].ApprovalPolicy)
}
func containsTool(tools []Tool, name string) bool {

View file

@ -6,6 +6,33 @@ import (
"sync"
)
// ToolActionMode describes the state-changing capability of a registered tool.
type ToolActionMode string
const (
ToolActionRead ToolActionMode = "read"
ToolActionMixed ToolActionMode = "mixed"
ToolActionWrite ToolActionMode = "write"
)
// ToolGovernance records the operator-facing governance contract for a tool.
type ToolGovernance struct {
ActionMode ToolActionMode
ApprovalPolicy string
Summary string
}
// ToolGovernanceDescriptor is the read-only manifest used by Assistant prompts
// and action-governance surfaces.
type ToolGovernanceDescriptor struct {
Name string
Description string
RequireControl bool
ActionMode ToolActionMode
ApprovalPolicy string
Summary string
}
// ToolHandler is a function that handles tool execution
type ToolHandler func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error)
@ -14,6 +41,7 @@ type RegisteredTool struct {
Definition Tool
Handler ToolHandler
RequireControl bool // If true, only available when control level is not read_only
Governance ToolGovernance
}
// ToolRegistry manages tool registration and execution
@ -60,6 +88,54 @@ func (r *ToolRegistry) ListTools(controlLevel ControlLevel) []Tool {
return result
}
// ListToolGovernance returns the governed tool manifest available at a control level.
func (r *ToolRegistry) ListToolGovernance(controlLevel ControlLevel) []ToolGovernanceDescriptor {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ToolGovernanceDescriptor, 0, len(r.tools))
for _, name := range r.order {
tool := r.tools[name]
if tool.RequireControl && (controlLevel == ControlLevelReadOnly || controlLevel == "") {
continue
}
governance := normalizeToolGovernance(tool)
result = append(result, ToolGovernanceDescriptor{
Name: tool.Definition.Name,
Description: tool.Definition.Description,
RequireControl: tool.RequireControl,
ActionMode: governance.ActionMode,
ApprovalPolicy: governance.ApprovalPolicy,
Summary: governance.Summary,
})
}
return result
}
func normalizeToolGovernance(tool RegisteredTool) ToolGovernance {
governance := tool.Governance
if governance.ActionMode == "" {
if tool.RequireControl {
governance.ActionMode = ToolActionWrite
} else {
governance.ActionMode = ToolActionRead
}
}
if governance.ApprovalPolicy == "" {
if governance.ActionMode == ToolActionRead {
governance.ApprovalPolicy = "no approval required"
} else if tool.RequireControl {
governance.ApprovalPolicy = "hidden in read-only mode; approval required in controlled mode"
} else {
governance.ApprovalPolicy = "write subactions require the tool's governed approval path"
}
}
if governance.Summary == "" {
governance.Summary = tool.Definition.Description
}
return governance
}
// Execute runs a tool by name
func (r *ToolRegistry) Execute(ctx context.Context, e *PulseToolExecutor, name string, args map[string]interface{}) (CallToolResult, error) {
r.mu.RLock()

View file

@ -91,6 +91,11 @@ Examples:
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeAlerts(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionMixed,
ApprovalPolicy: "read/list actions are safe; finding resolution and dismissal require the alerts governance path",
Summary: "Reviews alert and finding state and records governed finding decisions.",
},
})
}

View file

@ -67,6 +67,11 @@ func (e *PulseToolExecutor) registerControlTools() {
return exec.executeControl(ctx, args)
},
RequireControl: true,
Governance: ToolGovernance{
ActionMode: ToolActionWrite,
ApprovalPolicy: "hidden in read-only mode; approval required in controlled mode",
Summary: "Runs shared Pulse control actions and state-changing commands only against resources that advertise supported actions.",
},
})
}

View file

@ -56,6 +56,11 @@ func (e *PulseToolExecutor) registerDiscoveryTools() {
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeDiscovery(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Reads discovered service paths, ports, and bind mounts for known resources.",
},
})
}

View file

@ -67,6 +67,11 @@ func (e *PulseToolExecutor) registerDockerTools() {
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeDocker(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionMixed,
ApprovalPolicy: "read/list actions are safe; control and update subactions require approval in controlled mode",
Summary: "Lists Docker state and performs governed Docker control/update subactions.",
},
})
}

View file

@ -87,6 +87,11 @@ Examples:
return exec.executeFileEdit(ctx, args)
},
RequireControl: true,
Governance: ToolGovernance{
ActionMode: ToolActionWrite,
ApprovalPolicy: "hidden in read-only mode; approval required in controlled mode",
Summary: "Reads or changes files through the governed file-edit path; use pulse_read for read-only file inspection.",
},
})
}

View file

@ -136,6 +136,11 @@ Examples:
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeKnowledge(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionMixed,
ApprovalPolicy: "recall and analysis are safe; remember records operator-visible knowledge",
Summary: "Reads operational memory and records governed knowledge notes when requested.",
},
})
}

View file

@ -86,6 +86,11 @@ func (e *PulseToolExecutor) registerKubernetesTools() {
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeKubernetes(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionMixed,
ApprovalPolicy: "read/list/log actions are safe; scale, restart, delete, and exec subactions require approval in controlled mode",
Summary: "Reads Kubernetes topology and runs governed workload-control subactions.",
},
})
}

View file

@ -88,6 +88,11 @@ Examples:
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeMetrics(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Reads performance, sensor, baseline, pattern, and disk-health data without changing state.",
},
})
}

View file

@ -71,6 +71,11 @@ Returns: {"ok": true, "finding_id": "...", "is_new": true/false} on success.`,
},
},
Handler: handlePatrolReportFinding,
Governance: ToolGovernance{
ActionMode: ToolActionWrite,
ApprovalPolicy: "patrol-only; records a governed finding after evidence collection",
Summary: "Creates a structured patrol finding during autonomous investigation.",
},
})
// patrol_resolve_finding — LLM calls this to resolve an active finding
@ -97,6 +102,11 @@ Returns: {"ok": true, "resolved": true} on success.`,
},
},
Handler: handlePatrolResolveFinding,
Governance: ToolGovernance{
ActionMode: ToolActionWrite,
ApprovalPolicy: "patrol-only; resolves a finding after verification",
Summary: "Marks a patrol finding resolved after current evidence supports closure.",
},
})
// patrol_get_findings — LLM calls this to check existing active findings
@ -122,6 +132,11 @@ Returns a list of active findings with their IDs, severity, resource, and title.
},
},
Handler: handlePatrolGetFindings,
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Reads active patrol findings for deduplication and investigation context.",
},
})
}

View file

@ -30,6 +30,11 @@ func (e *PulseToolExecutor) registerPMGTools() {
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executePMG(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Reads Proxmox Mail Gateway status, queue, spam, and mail statistics.",
},
})
}

View file

@ -2104,6 +2104,11 @@ func (e *PulseToolExecutor) registerQueryTools() {
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeQuery(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Resolves canonical infrastructure identity, topology, config, and health without changing state.",
},
})
}

View file

@ -77,6 +77,11 @@ func (e *PulseToolExecutor) registerReadTools() {
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeRead(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: "no approval required; write-like commands are rejected",
Summary: "Runs read-only infrastructure inspection such as logs, file reads, tails, and safe exec.",
},
// Note: RequireControl is NOT set - this is a read-only tool
// It's available at all control levels including read_only
})

View file

@ -95,6 +95,11 @@ func (e *PulseToolExecutor) registerStorageTools() {
Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return exec.executeStorage(ctx, args)
},
Governance: ToolGovernance{
ActionMode: ToolActionRead,
ApprovalPolicy: "no approval required",
Summary: "Reads storage, backup, recovery, Ceph, RAID, and disk-health state without changing infrastructure.",
},
})
}