Replace the hard-coded tool classifier with registry-owned invocation descriptors

Every registered Pulse tool now carries a canonical invocation
descriptor (internal/agentcapabilities/invocation.go): static or
discriminator-based, classifying each invocation with a workflow kind
plus a mutation target (none / pulse_state / infrastructure). Mixed
descriptors must exactly cover their schema enum and registration
panics otherwise, so an unclassifiable tool cannot exist. Missing,
malformed, unknown, or fabricated discriminator values classify
fail-closed as infrastructure writes.

Provider projection and runtime enforcement consume the same
descriptor under one InvocationPolicy (control level plus the
request-local, non-serializable deny_infrastructure_mutations
restriction, isolated across executor clones): ListTools and
ListToolGovernance remove forbidden enum values, drop empty tools, and
recompute the offered action mode, while ToolRegistry.Execute blocks
forbidden invocations before the handler runs. This closes the mixed
tool control-level bypass, most seriously Docker action:update, which
previously fell through to direct execution at read-only, and fixes
the Kubernetes misclassification: the retired switch read the action
argument while the schema discriminator is type, so type:scale
classified as read.

pulse_file_edit is now write-only (append/write); file inspection
routes through pulse_read action=file, whose exec path keeps its
structural read-only execution-intent enforcement. ClassifyToolCall
consults the descriptor table first and retains only genuinely
non-registry compatibility cases. The deny restriction is deliberately
separate from autonomous mode, which only suppresses interactive
questions and grants no mutation authority.

Proofs: descriptor validation and fail-closed classification unit
tests, plus the invocation-policy regression suite (scale classifies
write and never invokes at read-only or under deny; Docker update
queues nothing at read-only; autonomous plus deny cannot mutate;
fabricated enum values fail at runtime; filtered projection and
runtime enforcement agree; executor clones keep request policies
isolated). Contracts and registry ownership updated for the new
shared invocation descriptor boundary.

Slice 3a of the typed-lifecycle ratchet; the patrol_investigation
execution profile and patrol_propose_action tool build on this
substrate next.
This commit is contained in:
rcourtman 2026-07-10 13:31:11 +01:00
parent 99dad2b511
commit 67c2534c08
24 changed files with 956 additions and 236 deletions

View file

@ -272,34 +272,35 @@ call when building tool-result turns.
13. `internal/agentcapabilities/events.go` shared with `api-contracts`: the Pulse Intelligence event vocabulary is both the canonical API SSE event contract and the AI runtime adapter notification contract for Assistant and external-agent surfaces.
14. `internal/agentcapabilities/governance_prompt.go` shared with `api-contracts`: the Pulse Intelligence surface-affordance-resolved model-facing operating-instruction, tool-governance prompt, reusable provider-tool governance description, Assistant-native offered-tool filtering, and Assistant-native interactive question-tool governance projections are both the Assistant system-prompt governance section and the shared API/agent vocabulary for action mode, approval posture, MCP affordance advertisement, and non-registry interaction-tool boundaries.
15. `internal/agentcapabilities/http.go` shared with `api-contracts`: the Pulse Intelligence agent HTTP substrate is both the API capabilities invocation contract and the shared AI runtime adapter execution primitive for MCP and reference agent clients.
16. `internal/agentcapabilities/manifest.go` shared with `api-contracts`: the canonical Pulse Intelligence agent capabilities manifest declaration, including capability display titles, manifest-owned finding lifecycle schemas, manifest-owned governed action schemas and routes, manifest-owned external-adapter surface tool contracts, and manifest-owned structured output schemas, is both the API discovery payload source and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
17. `internal/agentcapabilities/markdown.go` shared with `api-contracts`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
18. `internal/agentcapabilities/mcp.go` shared with `api-contracts`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
19. `internal/agentcapabilities/mcp_adapter.go` shared with `api-contracts`: the Pulse MCP adapter setup contract defaults and normalization are both the canonical API manifest setup projection and the AI runtime onboarding contract for Assistant-compatible external-agent surfaces.
20. `internal/agentcapabilities/projection.go` shared with `api-contracts`: the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases.
21. `internal/agentcapabilities/provider_tool_artifacts.go` shared with `api-contracts`: the provider tool-call artifact detector and streaming tool-name prefix splitter are both the Assistant stream-sanitization boundary and the shared external-adapter leak guard for provider-native tool-call markup that escaped the structured channel.
22. `internal/agentcapabilities/schema.go` shared with `api-contracts`: the agent capability input schema contract is both the canonical API manifest schema envelope and the AI runtime structured tool-schema, governance-aware provider-projection with neutral behavior hints and Pulse governance metadata, offered-tool governance extraction for Assistant prompt policy, manifest-affordance-gated Assistant provider-surface composition, manifest raw-schema to Assistant provider-schema projection for capability tools, legacy native Assistant utility provider aliases and schemas, provider-call normalization, provider-result context projection, Assistant-native interaction provider-tool declaration, and live Assistant execution-normalization contract for Pulse Assistant and MCP-facing agent tools.
23. `internal/agentcapabilities/scopes.go` shared with `api-contracts`: the manifest-derived required-scope summary is both the canonical API/agent token guidance contract and the AI runtime adapter startup/onboarding contract for Assistant-compatible external-agent surfaces.
24. `internal/agentcapabilities/sse.go` shared with `api-contracts`: the Pulse Intelligence SSE subscription transport and record parser are both the canonical API event-stream consumption contract and the AI runtime adapter push bridge contract for MCP and reference agent clients.
25. `internal/agentcapabilities/surface_contract.go` shared with `api-contracts`: the Pulse Intelligence operator-surface affordance contract, shared surface-affordance, surface-tool identity, Assistant surface tool filtering, normalized external surface tool resolver, surface lookup, affordance labels, and manifest-published external-adapter surface tool allowlist projection are both the canonical API manifest surface model and the AI runtime prompt and onboarding guardrail for Assistant and MCP-facing surfaces.
26. `internal/agentcapabilities/text_tool_invocation.go` shared with `api-contracts`: the Pulse Intelligence text tool invocation parser, internal approval argument, and current_resource handle vocabulary are both the Assistant approved-action execution projection and the shared tool-call params bridge for governed Pulse Intelligence tool calls, with MCP tools/call compatibility staying at the adapter edge.
27. `internal/agentcapabilities/tool_call.go` shared with `api-contracts`: the Pulse Intelligence shared tool-call params, normalization, validation, direct registry preparation, registry-entrypoint failure result helpers, and provider/registry tool-call safety classification are both the native Assistant execution/FSM contract and the canonical API/agent tools/call compatibility contract for governed Pulse Intelligence tool calls.
28. `internal/agentcapabilities/tool_execution.go` shared with `api-contracts`: the Pulse Intelligence neutral capability tool HTTP execution helper and direct tool execution output/error mapper are both the Assistant-native direct execution contract and the canonical API/agent request/response execution contract, with MCP adapters consuming the neutral helpers only after the shared MCP manifest-surface execution bridge has applied the published surface tool contract.
29. `internal/agentcapabilities/tool_marker.go` shared with `api-contracts`: the Pulse Intelligence Assistant tool marker vocabulary and approval/policy marker parser are both the Assistant structured tool-result compatibility contract and the canonical API/agent branching contract for governed tool outcomes.
30. `internal/agentcapabilities/tool_names.go` shared with `api-contracts`: the Pulse Intelligence registry tool-name vocabulary is both the native Assistant execution/display contract and the canonical API/agent tool identity contract for MCP-facing external-agent adapters.
31. `internal/agentcapabilities/tool_response.go` shared with `api-contracts`: the shared tool response envelope, tool error-code vocabulary, and tool-result error-code and verification evidence parsers are both the Assistant structured tool-result contract and the canonical API/agent branching contract for Pulse Intelligence tool failures, recovery tracking, and write self-verification.
32. `internal/agentcapabilities/tool_result.go` shared with `api-contracts`: the Pulse Intelligence shared tool-result content/result envelope, structuredContent projection, result constructors, HTTP response-to-result mapping, text projection, and result interpretation helpers are both the Assistant registry result contract and the canonical API/agent result projection contract for governed tool outcomes.
33. `internal/agentcapabilities/types.go` shared with `api-contracts`: the agent capabilities manifest wire type, manifest-owned external-adapter surface tool contract field, capability display title and structured output schema fields, approval-policy vocabulary, capability governance normalization, and tool-governance descriptor shape are both the canonical API payload contract and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
34. `internal/agentcapabilities/workflow_prompt.go` shared with `api-contracts`: the Pulse Intelligence workflow prompt catalogue, manifest-owned `workflowPrompts` projection, MCP prompt title projection, presentation kind hints, shared resource-context and finding argument vocabulary, Patrol issue-handling capability gating, argument validation, and manifest-gated shared prompt rendering rules are both the AI runtime starter contract for Assistant-compatible surfaces and the canonical API/agent prompt projection contract for MCP-facing clients.
35. `internal/api/ai_handler.go` shared with `api-contracts`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
36. `internal/api/ai_handlers.go` shared with `api-contracts`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
37. `internal/api/ai_intelligence_handlers.go` shared with `api-contracts`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
38. `pkg/aicontracts/action_broker.go` shared with `api-contracts`: the public typed action-proposal broker contract is both an AI runtime proposal boundary (the only sanctioned Patrol route to an infrastructure mutation) and a canonical API dependency contract over the shared action lifecycle service.
39. `pkg/aicontracts/fix_execution.go` shared with `api-contracts`: the public approved-fix execution contract is both an AI runtime approved-action boundary and a canonical API dependency contract for Patrol and enterprise auto-fix binders.
40. `pkg/aicontracts/investigation.go` shared with `api-contracts`: the public Patrol investigation record and finding contract is both an AI runtime handoff boundary and a canonical API payload contract for Patrol, Assistant, unified findings, persistence, and audit surfaces.
41. `pkg/aicontracts/orchestrator_deps.go` shared with `api-contracts`: the public investigation orchestrator dependency contract is both an AI runtime handoff boundary and a canonical API payload contract for Assistant and Patrol tool-call history.
42. `pkg/extensions/ai_autofix.go` shared with `api-contracts`: the enterprise auto-fix extension dependency seam is both an AI runtime approved-action boundary and a canonical API extension contract over Assistant and Patrol execution dependencies.
43. `scripts/generate-pulse-intelligence-docs.go` shared with `api-contracts`: the Pulse Intelligence manifest docs generator is both an AI runtime docs/onboarding projection and a canonical API contract projection over the agent capabilities manifest and Pulse MCP surface tool contract.
16. `internal/agentcapabilities/invocation.go` shared with `api-contracts`: the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, workflow kind plus mutation target, fail-closed classification) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement.
17. `internal/agentcapabilities/manifest.go` shared with `api-contracts`: the canonical Pulse Intelligence agent capabilities manifest declaration, including capability display titles, manifest-owned finding lifecycle schemas, manifest-owned governed action schemas and routes, manifest-owned external-adapter surface tool contracts, and manifest-owned structured output schemas, is both the API discovery payload source and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
18. `internal/agentcapabilities/markdown.go` shared with `api-contracts`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
19. `internal/agentcapabilities/mcp.go` shared with `api-contracts`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
20. `internal/agentcapabilities/mcp_adapter.go` shared with `api-contracts`: the Pulse MCP adapter setup contract defaults and normalization are both the canonical API manifest setup projection and the AI runtime onboarding contract for Assistant-compatible external-agent surfaces.
21. `internal/agentcapabilities/projection.go` shared with `api-contracts`: the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases.
22. `internal/agentcapabilities/provider_tool_artifacts.go` shared with `api-contracts`: the provider tool-call artifact detector and streaming tool-name prefix splitter are both the Assistant stream-sanitization boundary and the shared external-adapter leak guard for provider-native tool-call markup that escaped the structured channel.
23. `internal/agentcapabilities/schema.go` shared with `api-contracts`: the agent capability input schema contract is both the canonical API manifest schema envelope and the AI runtime structured tool-schema, governance-aware provider-projection with neutral behavior hints and Pulse governance metadata, offered-tool governance extraction for Assistant prompt policy, manifest-affordance-gated Assistant provider-surface composition, manifest raw-schema to Assistant provider-schema projection for capability tools, legacy native Assistant utility provider aliases and schemas, provider-call normalization, provider-result context projection, Assistant-native interaction provider-tool declaration, and live Assistant execution-normalization contract for Pulse Assistant and MCP-facing agent tools.
24. `internal/agentcapabilities/scopes.go` shared with `api-contracts`: the manifest-derived required-scope summary is both the canonical API/agent token guidance contract and the AI runtime adapter startup/onboarding contract for Assistant-compatible external-agent surfaces.
25. `internal/agentcapabilities/sse.go` shared with `api-contracts`: the Pulse Intelligence SSE subscription transport and record parser are both the canonical API event-stream consumption contract and the AI runtime adapter push bridge contract for MCP and reference agent clients.
26. `internal/agentcapabilities/surface_contract.go` shared with `api-contracts`: the Pulse Intelligence operator-surface affordance contract, shared surface-affordance, surface-tool identity, Assistant surface tool filtering, normalized external surface tool resolver, surface lookup, affordance labels, and manifest-published external-adapter surface tool allowlist projection are both the canonical API manifest surface model and the AI runtime prompt and onboarding guardrail for Assistant and MCP-facing surfaces.
27. `internal/agentcapabilities/text_tool_invocation.go` shared with `api-contracts`: the Pulse Intelligence text tool invocation parser, internal approval argument, and current_resource handle vocabulary are both the Assistant approved-action execution projection and the shared tool-call params bridge for governed Pulse Intelligence tool calls, with MCP tools/call compatibility staying at the adapter edge.
28. `internal/agentcapabilities/tool_call.go` shared with `api-contracts`: the Pulse Intelligence shared tool-call params, normalization, validation, direct registry preparation, registry-entrypoint failure result helpers, and provider/registry tool-call safety classification are both the native Assistant execution/FSM contract and the canonical API/agent tools/call compatibility contract for governed Pulse Intelligence tool calls.
29. `internal/agentcapabilities/tool_execution.go` shared with `api-contracts`: the Pulse Intelligence neutral capability tool HTTP execution helper and direct tool execution output/error mapper are both the Assistant-native direct execution contract and the canonical API/agent request/response execution contract, with MCP adapters consuming the neutral helpers only after the shared MCP manifest-surface execution bridge has applied the published surface tool contract.
30. `internal/agentcapabilities/tool_marker.go` shared with `api-contracts`: the Pulse Intelligence Assistant tool marker vocabulary and approval/policy marker parser are both the Assistant structured tool-result compatibility contract and the canonical API/agent branching contract for governed tool outcomes.
31. `internal/agentcapabilities/tool_names.go` shared with `api-contracts`: the Pulse Intelligence registry tool-name vocabulary is both the native Assistant execution/display contract and the canonical API/agent tool identity contract for MCP-facing external-agent adapters.
32. `internal/agentcapabilities/tool_response.go` shared with `api-contracts`: the shared tool response envelope, tool error-code vocabulary, and tool-result error-code and verification evidence parsers are both the Assistant structured tool-result contract and the canonical API/agent branching contract for Pulse Intelligence tool failures, recovery tracking, and write self-verification.
33. `internal/agentcapabilities/tool_result.go` shared with `api-contracts`: the Pulse Intelligence shared tool-result content/result envelope, structuredContent projection, result constructors, HTTP response-to-result mapping, text projection, and result interpretation helpers are both the Assistant registry result contract and the canonical API/agent result projection contract for governed tool outcomes.
34. `internal/agentcapabilities/types.go` shared with `api-contracts`: the agent capabilities manifest wire type, manifest-owned external-adapter surface tool contract field, capability display title and structured output schema fields, approval-policy vocabulary, capability governance normalization, and tool-governance descriptor shape are both the canonical API payload contract and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
35. `internal/agentcapabilities/workflow_prompt.go` shared with `api-contracts`: the Pulse Intelligence workflow prompt catalogue, manifest-owned `workflowPrompts` projection, MCP prompt title projection, presentation kind hints, shared resource-context and finding argument vocabulary, Patrol issue-handling capability gating, argument validation, and manifest-gated shared prompt rendering rules are both the AI runtime starter contract for Assistant-compatible surfaces and the canonical API/agent prompt projection contract for MCP-facing clients.
36. `internal/api/ai_handler.go` shared with `api-contracts`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
37. `internal/api/ai_handlers.go` shared with `api-contracts`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
38. `internal/api/ai_intelligence_handlers.go` shared with `api-contracts`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
39. `pkg/aicontracts/action_broker.go` shared with `api-contracts`: the public typed action-proposal broker contract is both an AI runtime proposal boundary (the only sanctioned Patrol route to an infrastructure mutation) and a canonical API dependency contract over the shared action lifecycle service.
40. `pkg/aicontracts/fix_execution.go` shared with `api-contracts`: the public approved-fix execution contract is both an AI runtime approved-action boundary and a canonical API dependency contract for Patrol and enterprise auto-fix binders.
41. `pkg/aicontracts/investigation.go` shared with `api-contracts`: the public Patrol investigation record and finding contract is both an AI runtime handoff boundary and a canonical API payload contract for Patrol, Assistant, unified findings, persistence, and audit surfaces.
42. `pkg/aicontracts/orchestrator_deps.go` shared with `api-contracts`: the public investigation orchestrator dependency contract is both an AI runtime handoff boundary and a canonical API payload contract for Assistant and Patrol tool-call history.
43. `pkg/extensions/ai_autofix.go` shared with `api-contracts`: the enterprise auto-fix extension dependency seam is both an AI runtime approved-action boundary and a canonical API extension contract over Assistant and Patrol execution dependencies.
44. `scripts/generate-pulse-intelligence-docs.go` shared with `api-contracts`: the Pulse Intelligence manifest docs generator is both an AI runtime docs/onboarding projection and a canonical API contract projection over the agent capabilities manifest and Pulse MCP surface tool contract.
The shared agent-capabilities manifest also owns the runtime surface contract:
the manifest wire type and generated frontend projection must name Pulse
@ -4579,6 +4580,39 @@ duplicated into investigation stores. Proofs:
`TestActionProposalWireShapeIsTypedAndCommandFree`, and
`TestActionReferenceIsAdditiveOnInvestigationShapes` in
`pkg/aicontracts/contracts_test.go`.
Tool-call safety classification is registry-owned through the canonical
invocation descriptors in `internal/agentcapabilities/invocation.go`:
every registered Pulse tool has a static or discriminator-based
descriptor whose classification carries both a workflow kind
(read/resolve/write) and a mutation target (`none`, `pulse_state`, or
`infrastructure`). A mixed tool's descriptor must exactly cover its
schema enum for the declared discriminator (Kubernetes discriminates on
`type`, not `action`); `ToolRegistry.Register` panics on a missing
descriptor or on missing/extra cases, so an unclassifiable tool is not
registerable. Missing, malformed, or unknown discriminator values
classify fail-closed as write/infrastructure. Provider projection
(`ToolRegistry.ListTools`/`ListToolGovernance`) and runtime enforcement
(`ToolRegistry.Execute`, before the handler runs) consume the same
descriptor under one `InvocationPolicy` (control level plus the
request-local `deny_infrastructure_mutations` restriction on the
executor, which clones per session and never serializes): projection
removes forbidden enum values, drops tools with no permitted
invocation, and recomputes the offered governance action mode, so the
offered schema and the enforcement boundary can never disagree.
Control-level blocks keep returning the operator guidance message;
policy blocks return the shared invocation-blocked result.
Handler-level checks (the file-edit read-only guard and pulse_read's
structural execution-intent classifier) remain defense in depth. The
deny restriction is deliberately separate from autonomous mode:
suppressing interactive questions grants no mutation authority.
`pulse_file_edit` is write-only (append/write); file inspection routes
through `pulse_read` action="file". The retired hard-coded classifier
misread Kubernetes's discriminator and classified `type:"scale"` as
read, and mixed tools such as Docker bypassed tool-level control
gating entirely (an `action:"update"` reached direct execution at
read-only). Proofs: `internal/agentcapabilities/invocation_test.go`
and the invocation-policy regression suite in
`internal/ai/tools/invocation_policy_test.go`.
The same ownership includes the Pulse query tool schema under
`internal/ai/tools/`: topology-query input names must stay canonical inside
the AI runtime itself, so new tool arguments such as `max_proxmox_nodes`

View file

@ -1180,26 +1180,27 @@ payload shape change when the portal presents compact client rows.
39. `internal/agentcapabilities/events.go` shared with `ai-runtime`: the Pulse Intelligence event vocabulary is both the canonical API SSE event contract and the AI runtime adapter notification contract for Assistant and external-agent surfaces.
40. `internal/agentcapabilities/governance_prompt.go` shared with `ai-runtime`: the Pulse Intelligence surface-affordance-resolved model-facing operating-instruction, tool-governance prompt, reusable provider-tool governance description, Assistant-native offered-tool filtering, and Assistant-native interactive question-tool governance projections are both the Assistant system-prompt governance section and the shared API/agent vocabulary for action mode, approval posture, MCP affordance advertisement, and non-registry interaction-tool boundaries.
41. `internal/agentcapabilities/http.go` shared with `ai-runtime`: the Pulse Intelligence agent HTTP substrate is both the API capabilities invocation contract and the shared AI runtime adapter execution primitive for MCP and reference agent clients.
42. `internal/agentcapabilities/manifest.go` shared with `ai-runtime`: the canonical Pulse Intelligence agent capabilities manifest declaration, including capability display titles, manifest-owned finding lifecycle schemas, manifest-owned governed action schemas and routes, manifest-owned external-adapter surface tool contracts, and manifest-owned structured output schemas, is both the API discovery payload source and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
43. `internal/agentcapabilities/markdown.go` shared with `ai-runtime`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
44. `internal/agentcapabilities/mcp.go` shared with `ai-runtime`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
45. `internal/agentcapabilities/mcp_adapter.go` shared with `ai-runtime`: the Pulse MCP adapter setup contract defaults and normalization are both the canonical API manifest setup projection and the AI runtime onboarding contract for Assistant-compatible external-agent surfaces.
46. `internal/agentcapabilities/projection.go` shared with `ai-runtime`: the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases.
47. `internal/agentcapabilities/provider_tool_artifacts.go` shared with `ai-runtime`: the provider tool-call artifact detector and streaming tool-name prefix splitter are both the Assistant stream-sanitization boundary and the shared external-adapter leak guard for provider-native tool-call markup that escaped the structured channel.
48. `internal/agentcapabilities/schema.go` shared with `ai-runtime`: the agent capability input schema contract is both the canonical API manifest schema envelope and the AI runtime structured tool-schema, governance-aware provider-projection with neutral behavior hints and Pulse governance metadata, offered-tool governance extraction for Assistant prompt policy, manifest-affordance-gated Assistant provider-surface composition, manifest raw-schema to Assistant provider-schema projection for capability tools, legacy native Assistant utility provider aliases and schemas, provider-call normalization, provider-result context projection, Assistant-native interaction provider-tool declaration, and live Assistant execution-normalization contract for Pulse Assistant and MCP-facing agent tools.
49. `internal/agentcapabilities/scopes.go` shared with `ai-runtime`: the manifest-derived required-scope summary is both the canonical API/agent token guidance contract and the AI runtime adapter startup/onboarding contract for Assistant-compatible external-agent surfaces.
50. `internal/agentcapabilities/sse.go` shared with `ai-runtime`: the Pulse Intelligence SSE subscription transport and record parser are both the canonical API event-stream consumption contract and the AI runtime adapter push bridge contract for MCP and reference agent clients.
51. `internal/agentcapabilities/surface_contract.go` shared with `ai-runtime`: the Pulse Intelligence operator-surface affordance contract, shared surface-affordance, surface-tool identity, Assistant surface tool filtering, normalized external surface tool resolver, surface lookup, affordance labels, and manifest-published external-adapter surface tool allowlist projection are both the canonical API manifest surface model and the AI runtime prompt and onboarding guardrail for Assistant and MCP-facing surfaces.
52. `internal/agentcapabilities/text_tool_invocation.go` shared with `ai-runtime`: the Pulse Intelligence text tool invocation parser, internal approval argument, and current_resource handle vocabulary are both the Assistant approved-action execution projection and the shared tool-call params bridge for governed Pulse Intelligence tool calls, with MCP tools/call compatibility staying at the adapter edge.
53. `internal/agentcapabilities/tool_call.go` shared with `ai-runtime`: the Pulse Intelligence shared tool-call params, normalization, validation, direct registry preparation, registry-entrypoint failure result helpers, and provider/registry tool-call safety classification are both the native Assistant execution/FSM contract and the canonical API/agent tools/call compatibility contract for governed Pulse Intelligence tool calls.
54. `internal/agentcapabilities/tool_execution.go` shared with `ai-runtime`: the Pulse Intelligence neutral capability tool HTTP execution helper and direct tool execution output/error mapper are both the Assistant-native direct execution contract and the canonical API/agent request/response execution contract, with MCP adapters consuming the neutral helpers only after the shared MCP manifest-surface execution bridge has applied the published surface tool contract.
55. `internal/agentcapabilities/tool_marker.go` shared with `ai-runtime`: the Pulse Intelligence Assistant tool marker vocabulary and approval/policy marker parser are both the Assistant structured tool-result compatibility contract and the canonical API/agent branching contract for governed tool outcomes.
56. `internal/agentcapabilities/tool_names.go` shared with `ai-runtime`: the Pulse Intelligence registry tool-name vocabulary is both the native Assistant execution/display contract and the canonical API/agent tool identity contract for MCP-facing external-agent adapters.
57. `internal/agentcapabilities/tool_response.go` shared with `ai-runtime`: the shared tool response envelope, tool error-code vocabulary, and tool-result error-code and verification evidence parsers are both the Assistant structured tool-result contract and the canonical API/agent branching contract for Pulse Intelligence tool failures, recovery tracking, and write self-verification.
58. `internal/agentcapabilities/tool_result.go` shared with `ai-runtime`: the Pulse Intelligence shared tool-result content/result envelope, structuredContent projection, result constructors, HTTP response-to-result mapping, text projection, and result interpretation helpers are both the Assistant registry result contract and the canonical API/agent result projection contract for governed tool outcomes.
59. `internal/agentcapabilities/types.go` shared with `ai-runtime`: the agent capabilities manifest wire type, manifest-owned external-adapter surface tool contract field, capability display title and structured output schema fields, approval-policy vocabulary, capability governance normalization, and tool-governance descriptor shape are both the canonical API payload contract and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
60. `internal/agentcapabilities/workflow_prompt.go` shared with `ai-runtime`: the Pulse Intelligence workflow prompt catalogue, manifest-owned `workflowPrompts` projection, MCP prompt title projection, presentation kind hints, shared resource-context and finding argument vocabulary, Patrol issue-handling capability gating, argument validation, and manifest-gated shared prompt rendering rules are both the AI runtime starter contract for Assistant-compatible surfaces and the canonical API/agent prompt projection contract for MCP-facing clients.
61. `internal/api/access_control_handlers.go` shared with `organization-settings`: RBAC role and user-assignment handlers are both an organization settings control surface and a canonical API payload contract boundary.
42. `internal/agentcapabilities/invocation.go` shared with `ai-runtime`: the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, workflow kind plus mutation target, fail-closed classification) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement.
43. `internal/agentcapabilities/manifest.go` shared with `ai-runtime`: the canonical Pulse Intelligence agent capabilities manifest declaration, including capability display titles, manifest-owned finding lifecycle schemas, manifest-owned governed action schemas and routes, manifest-owned external-adapter surface tool contracts, and manifest-owned structured output schemas, is both the API discovery payload source and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
44. `internal/agentcapabilities/markdown.go` shared with `ai-runtime`: the Pulse Intelligence manifest Markdown projection, including manifest-owned capability titles, surface-filtered Pulse MCP tool/error inventories, and prompt labels, is both the canonical API/agent documentation projection and the AI runtime onboarding projection for Assistant-compatible external-agent surfaces.
45. `internal/agentcapabilities/mcp.go` shared with `ai-runtime`: the Pulse Intelligence MCP protocol version, JSON-RPC, method dispatch, method payload, surface-tool-contract-gated initialize operating-instruction and capability advertisement payload, manifest surface-filtered tools/list and tools/call execution bridge, manifest surface-gated resources/list and resources/read bridge, manifest-owned and surface-affordance-gated workflow prompt projection, protocol wire aliases, resource and prompt handler gates, and notification projection collectively define the external-agent adapter wire contract over the shared Pulse Intelligence tool core; MCP initialize, tools/call execution, resource list/read projection, and prompt list/get projection must enter through manifest-owned surface and workflow-prompt contracts so raw capability slices cannot bypass the published external-adapter contract.
46. `internal/agentcapabilities/mcp_adapter.go` shared with `ai-runtime`: the Pulse MCP adapter setup contract defaults and normalization are both the canonical API manifest setup projection and the AI runtime onboarding contract for Assistant-compatible external-agent surfaces.
47. `internal/agentcapabilities/projection.go` shared with `ai-runtime`: the agent capability external-tool projection helper, normalized manifest-owned surface tool contract resolution and tools-affordance gating, manifest-owned resource-context route and argument vocabulary, operator-state capability and route vocabulary, finding workflow capability and lifecycle argument vocabulary including resolution and dismissal notes, governed action capability, route, and argument vocabulary, manifest-owned tool title and outputSchema projection, structured Pulse capability _meta, and shared tool behavior hints are both the canonical API manifest projection contract and the AI runtime adapter projection for Pulse Assistant and MCP-facing agent tools, with MCP annotation and metadata wire names confined to adapter-edge aliases.
48. `internal/agentcapabilities/provider_tool_artifacts.go` shared with `ai-runtime`: the provider tool-call artifact detector and streaming tool-name prefix splitter are both the Assistant stream-sanitization boundary and the shared external-adapter leak guard for provider-native tool-call markup that escaped the structured channel.
49. `internal/agentcapabilities/schema.go` shared with `ai-runtime`: the agent capability input schema contract is both the canonical API manifest schema envelope and the AI runtime structured tool-schema, governance-aware provider-projection with neutral behavior hints and Pulse governance metadata, offered-tool governance extraction for Assistant prompt policy, manifest-affordance-gated Assistant provider-surface composition, manifest raw-schema to Assistant provider-schema projection for capability tools, legacy native Assistant utility provider aliases and schemas, provider-call normalization, provider-result context projection, Assistant-native interaction provider-tool declaration, and live Assistant execution-normalization contract for Pulse Assistant and MCP-facing agent tools.
50. `internal/agentcapabilities/scopes.go` shared with `ai-runtime`: the manifest-derived required-scope summary is both the canonical API/agent token guidance contract and the AI runtime adapter startup/onboarding contract for Assistant-compatible external-agent surfaces.
51. `internal/agentcapabilities/sse.go` shared with `ai-runtime`: the Pulse Intelligence SSE subscription transport and record parser are both the canonical API event-stream consumption contract and the AI runtime adapter push bridge contract for MCP and reference agent clients.
52. `internal/agentcapabilities/surface_contract.go` shared with `ai-runtime`: the Pulse Intelligence operator-surface affordance contract, shared surface-affordance, surface-tool identity, Assistant surface tool filtering, normalized external surface tool resolver, surface lookup, affordance labels, and manifest-published external-adapter surface tool allowlist projection are both the canonical API manifest surface model and the AI runtime prompt and onboarding guardrail for Assistant and MCP-facing surfaces.
53. `internal/agentcapabilities/text_tool_invocation.go` shared with `ai-runtime`: the Pulse Intelligence text tool invocation parser, internal approval argument, and current_resource handle vocabulary are both the Assistant approved-action execution projection and the shared tool-call params bridge for governed Pulse Intelligence tool calls, with MCP tools/call compatibility staying at the adapter edge.
54. `internal/agentcapabilities/tool_call.go` shared with `ai-runtime`: the Pulse Intelligence shared tool-call params, normalization, validation, direct registry preparation, registry-entrypoint failure result helpers, and provider/registry tool-call safety classification are both the native Assistant execution/FSM contract and the canonical API/agent tools/call compatibility contract for governed Pulse Intelligence tool calls.
55. `internal/agentcapabilities/tool_execution.go` shared with `ai-runtime`: the Pulse Intelligence neutral capability tool HTTP execution helper and direct tool execution output/error mapper are both the Assistant-native direct execution contract and the canonical API/agent request/response execution contract, with MCP adapters consuming the neutral helpers only after the shared MCP manifest-surface execution bridge has applied the published surface tool contract.
56. `internal/agentcapabilities/tool_marker.go` shared with `ai-runtime`: the Pulse Intelligence Assistant tool marker vocabulary and approval/policy marker parser are both the Assistant structured tool-result compatibility contract and the canonical API/agent branching contract for governed tool outcomes.
57. `internal/agentcapabilities/tool_names.go` shared with `ai-runtime`: the Pulse Intelligence registry tool-name vocabulary is both the native Assistant execution/display contract and the canonical API/agent tool identity contract for MCP-facing external-agent adapters.
58. `internal/agentcapabilities/tool_response.go` shared with `ai-runtime`: the shared tool response envelope, tool error-code vocabulary, and tool-result error-code and verification evidence parsers are both the Assistant structured tool-result contract and the canonical API/agent branching contract for Pulse Intelligence tool failures, recovery tracking, and write self-verification.
59. `internal/agentcapabilities/tool_result.go` shared with `ai-runtime`: the Pulse Intelligence shared tool-result content/result envelope, structuredContent projection, result constructors, HTTP response-to-result mapping, text projection, and result interpretation helpers are both the Assistant registry result contract and the canonical API/agent result projection contract for governed tool outcomes.
60. `internal/agentcapabilities/types.go` shared with `ai-runtime`: the agent capabilities manifest wire type, manifest-owned external-adapter surface tool contract field, capability display title and structured output schema fields, approval-policy vocabulary, capability governance normalization, and tool-governance descriptor shape are both the canonical API payload contract and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools.
61. `internal/agentcapabilities/workflow_prompt.go` shared with `ai-runtime`: the Pulse Intelligence workflow prompt catalogue, manifest-owned `workflowPrompts` projection, MCP prompt title projection, presentation kind hints, shared resource-context and finding argument vocabulary, Patrol issue-handling capability gating, argument validation, and manifest-gated shared prompt rendering rules are both the AI runtime starter contract for Assistant-compatible surfaces and the canonical API/agent prompt projection contract for MCP-facing clients.
62. `internal/api/access_control_handlers.go` shared with `organization-settings`: RBAC role and user-assignment handlers are both an organization settings control surface and a canonical API payload contract boundary.
The `/api/resources` type filter also treats URL-encoded comma separators
(`%2C`) the same as literal comma-separated type lists, because frontend
consumers build these requests through standard URL query encoders.
@ -1222,7 +1223,7 @@ payload shape change when the portal presents compact client rows.
controls. That sequence is presentation guidance for the existing setup
payload phases; it does not create a second node setup API model or allow
page-local payload ownership.
62. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
63. `internal/api/agent_install_command_shared.go` shared with `agent-lifecycle`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
Frontend and backend Unix install command builders must stay on the same
token-file and preflight transport contract: tokens are passed to the
installer as ephemeral files, and host install snippets must verify the
@ -1238,7 +1239,7 @@ payload shape change when the portal presents compact client rows.
hosted mode, only for an existing tenant/org, and only into that tenant
runtime's token store with the org boundary, command shape, token metadata,
and already-loaded tenant-monitor refresh behavior preserved.
63. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
64. `internal/api/ai_handler.go` shared with `ai-runtime`: Pulse Assistant handlers are both an AI runtime control surface and a canonical API payload contract boundary.
Assistant session list payloads may expose only the safe
`handoff_summary` projection needed by the browser to mark and restore a
scoped handoff. The payload must not expose provider-bound model context,
@ -1434,7 +1435,7 @@ payload shape change when the portal presents compact client rows.
sequence must exercise the local prompt-send state by emitting `session`,
then pacing the first backend `workflow_state` long enough for browser proof
to verify immediate visible activity without opening a provider request.
64. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
65. `internal/api/ai_handlers.go` shared with `ai-runtime`: AI settings and remediation handlers are both an AI runtime control surface and a canonical API payload contract boundary.
The AI settings payload on `/api/settings/ai` carries no cloud-context-privacy
field: cloud context behavior is a fixed posture (real context to cloud, with
credentials and local-only resources always withheld), not a settings-payload
@ -1467,8 +1468,8 @@ payload shape change when the portal presents compact client rows.
evidence. The frontend API client, settings shell, and Assistant drawer
must treat this payload as the canonical provider health contract rather
than parsing free-form provider error strings.
65. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
66. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
66. `internal/api/ai_intelligence_handlers.go` shared with `ai-runtime`: AI intelligence handlers are both an AI runtime control surface and a canonical API payload contract boundary.
67. `internal/api/config_setup_handlers.go` shared with `agent-lifecycle`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
That same shared boundary also owns reachable-host selection truth for canonical Proxmox registration: runtime callers may propose ordered `candidateHosts`, but the API contract must persist and echo the first candidate Pulse can actually reach instead of freezing the caller's rejected first preference into the stored node endpoint.
That same canonical payload contract also owns strict-TLS truth for that selected host: `/api/auto-register` may only persist `VerifySSL=true` when Pulse actually captured a certificate fingerprint for the selected candidate, and it must not pretend public-CA verification is safe after every candidate fingerprint probe failed.
For PVE cluster sources, that same contract must distinguish primary
@ -1501,9 +1502,9 @@ payload shape change when the portal presents compact client rows.
`VM.GuestAgent.FileRead` on PVE 9+ and reserve `VM.Monitor` for the legacy
PVE 8 fallback, so API-generated scripts, runtime setup, installer setup,
and browser manual guidance stay on one privilege contract.
67. `internal/api/enterprise_extension_rbac_admin.go` shared with `organization-settings`: RBAC admin extension endpoints are both an organization settings control surface and a canonical API payload contract boundary.
68. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
69. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
68. `internal/api/enterprise_extension_rbac_admin.go` shared with `organization-settings`: RBAC admin extension endpoints are both an organization settings control surface and a canonical API payload contract boundary.
69. `internal/api/licensing_bridge.go` shared with `cloud-paid`: commercial licensing bridge handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
70. `internal/api/licensing_handlers.go` shared with `cloud-paid`: commercial licensing handlers carry both API payload contract and cloud-paid entitlement boundary ownership.
That same shared licensing boundary also owns authenticated
install-version and runtime-build attribution: `internal/api/router.go`
must hand the canonical process `serverVersion` and normalized runtime
@ -1523,20 +1524,20 @@ payload shape change when the portal presents compact client rows.
destination, but the browser/API contract must not reintroduce
Pulse-Pro-as-page-name copy in callback titles, actions, or retry
guidance.
70. `internal/api/licensing_legacy_retry.go` shared with `cloud-paid`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership.
71. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary.
72. `internal/api/org_handlers.go` shared with `organization-settings`: organization management handlers are both an organization settings control surface and a canonical API payload contract boundary.
73. `internal/api/org_lifecycle_handlers.go` shared with `organization-settings`: organization lifecycle handlers are both an organization settings control surface and a canonical API payload contract boundary.
74. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
75. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
71. `internal/api/licensing_legacy_retry.go` shared with `cloud-paid`: the background legacy-exchange retry loop carries both API payload contract and cloud-paid entitlement boundary ownership.
72. `internal/api/notifications.go` shared with `notifications`: notification handlers are both a notification delivery control surface and a canonical API payload contract boundary.
73. `internal/api/org_handlers.go` shared with `organization-settings`: organization management handlers are both an organization settings control surface and a canonical API payload contract boundary.
74. `internal/api/org_lifecycle_handlers.go` shared with `organization-settings`: organization lifecycle handlers are both an organization settings control surface and a canonical API payload contract boundary.
75. `internal/api/payments_webhook_handlers.go` shared with `cloud-paid`: commercial payment webhook handlers carry both API payload contract and cloud-paid billing boundary ownership.
76. `internal/api/public_signup_handlers.go` shared with `cloud-paid`: hosted signup handlers carry both API payload contract and cloud-paid hosted provisioning boundary ownership.
That same shared boundary also owns public hosted-signup response privacy:
syntactically valid `/api/public/signup` requests must return one generic
`202 Accepted` Pulse Account message whether provisioning/email side effects
ran or were suppressed by the owner-email limiter, while invalid bodies and
true server failures remain explicit.
76. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface.
77. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
78. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
77. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface.
78. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
79. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary.
That same shared security/API boundary owns CSRF replacement-token
concurrency. When parallel browser mutations arrive with stale or missing
CSRF tokens for the same session, `internal/api/csrf_store.go` may retain
@ -1561,7 +1562,7 @@ payload shape change when the portal presents compact client rows.
details require an admin/session boundary or an API token carrying
`settings:read` (`detailLevel=privileged`). Bootstrap token paths, LXC IDs,
and Docker container names are not part of any security-status tier.
79. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
80. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary.
Token owner identity is reserved for the server-authenticated principal:
shared token-minting helpers must derive `owner_user_id` from the current
session or caller token and reject extension metadata that tries to
@ -1577,22 +1578,22 @@ payload shape change when the portal presents compact client rows.
before minting a `relay:mobile:access` credential. Community installs may
receive the standard license-required response, but direct API calls must
not bypass Relay entitlement by creating mobile runtime tokens.
80. `internal/api/setup_script_render.go` shared with `agent-lifecycle`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection).
81. `internal/api/setup_script_render.go` shared with `agent-lifecycle`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection).
PVE setup-script auto-registration is part of the rendered API contract:
after creating the privilege-separated token and applying ACLs, the script
must smoke-test the exact token id/value against
`${HOST_URL%/}/api2/json/nodes` with `PVEAPIToken` authentication before
it posts the canonical `/api/auto-register` payload. Smoke-check failure is
a manual-completion state, not a successful auto-registration response.
81. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
82. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
83. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
82. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
83. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary.
84. `internal/api/unified_agent.go` shared with `agent-lifecycle`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
Development-mode missing-binary responses must report the build command
for the requested normalized OS/architecture, not a hard-coded Linux
target, so installer preflight failures point operators at the artifact
they actually need.
84. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
85. `pkg/aicontracts/action_broker.go` shared with `ai-runtime`: the public typed action-proposal broker contract is both an AI runtime proposal boundary (the only sanctioned Patrol route to an infrastructure mutation) and a canonical API dependency contract over the shared action lifecycle service.
85. `internal/api/updates.go` shared with `deployment-installability`: update handlers are both a deployment-installability control surface and a canonical API payload contract boundary.
86. `pkg/aicontracts/action_broker.go` shared with `ai-runtime`: the public typed action-proposal broker contract is both an AI runtime proposal boundary (the only sanctioned Patrol route to an infrastructure mutation) and a canonical API dependency contract over the shared action lifecycle service.
The updater registry behind `GET /api/updates/plan` is a plan-provider
seam only (`SupportsApply`, `PrepareUpdate`, `GetDeploymentType`); apply
and rollback semantics ride the manager pipeline behind
@ -1609,11 +1610,11 @@ payload shape change when the portal presents compact client rows.
and answers with the same started-acknowledgement shape as apply;
conflict responses cover pruned or missing backups and Docker
deployments, and not-found covers unknown history entries.
86. `pkg/aicontracts/fix_execution.go` shared with `ai-runtime`: the public approved-fix execution contract is both an AI runtime approved-action boundary and a canonical API dependency contract for Patrol and enterprise auto-fix binders.
87. `pkg/aicontracts/investigation.go` shared with `ai-runtime`: the public Patrol investigation record and finding contract is both an AI runtime handoff boundary and a canonical API payload contract for Patrol, Assistant, unified findings, persistence, and audit surfaces.
88. `pkg/aicontracts/orchestrator_deps.go` shared with `ai-runtime`: the public investigation orchestrator dependency contract is both an AI runtime handoff boundary and a canonical API payload contract for Assistant and Patrol tool-call history.
89. `pkg/extensions/ai_autofix.go` shared with `ai-runtime`: the enterprise auto-fix extension dependency seam is both an AI runtime approved-action boundary and a canonical API extension contract over Assistant and Patrol execution dependencies.
90. `scripts/generate-pulse-intelligence-docs.go` shared with `ai-runtime`: the Pulse Intelligence manifest docs generator is both an AI runtime docs/onboarding projection and a canonical API contract projection over the agent capabilities manifest and Pulse MCP surface tool contract.
87. `pkg/aicontracts/fix_execution.go` shared with `ai-runtime`: the public approved-fix execution contract is both an AI runtime approved-action boundary and a canonical API dependency contract for Patrol and enterprise auto-fix binders.
88. `pkg/aicontracts/investigation.go` shared with `ai-runtime`: the public Patrol investigation record and finding contract is both an AI runtime handoff boundary and a canonical API payload contract for Patrol, Assistant, unified findings, persistence, and audit surfaces.
89. `pkg/aicontracts/orchestrator_deps.go` shared with `ai-runtime`: the public investigation orchestrator dependency contract is both an AI runtime handoff boundary and a canonical API payload contract for Assistant and Patrol tool-call history.
90. `pkg/extensions/ai_autofix.go` shared with `ai-runtime`: the enterprise auto-fix extension dependency seam is both an AI runtime approved-action boundary and a canonical API extension contract over Assistant and Patrol execution dependencies.
91. `scripts/generate-pulse-intelligence-docs.go` shared with `ai-runtime`: the Pulse Intelligence manifest docs generator is both an AI runtime docs/onboarding projection and a canonical API contract projection over the agent capabilities manifest and Pulse MCP surface tool contract.
Update-plan responses own the structured readiness verdict for server
updater capability, rollback support, agent continuity, v5 agent migration
transport security, and agent reporting token scope. That verdict is part

View file

@ -538,6 +538,14 @@
"api-contracts"
]
},
{
"path": "internal/agentcapabilities/invocation.go",
"rationale": "the canonical registry-owned invocation descriptors (per-tool discriminator, enum-exact case coverage, workflow kind plus mutation target, fail-closed classification) are both the native Assistant/FSM safety-classification contract and the canonical API/agent governed-invocation policy contract consumed by provider projection and registry runtime enforcement",
"subsystems": [
"ai-runtime",
"api-contracts"
]
},
{
"path": "internal/agentcapabilities/manifest.go",
"rationale": "the canonical Pulse Intelligence agent capabilities manifest declaration, including capability display titles, manifest-owned finding lifecycle schemas, manifest-owned governed action schemas and routes, manifest-owned external-adapter surface tool contracts, and manifest-owned structured output schemas, is both the API discovery payload source and the AI runtime projection contract for Pulse Assistant and MCP-facing agent tools",
@ -1626,6 +1634,7 @@
"exact_files": [
"cmd/pulse-mcp/main_test.go",
"internal/agentcapabilities/action_target_test.go",
"internal/agentcapabilities/invocation_test.go",
"internal/agentcapabilities/manifest_test.go",
"internal/agentcapabilities/mcp_test.go",
"internal/agentcapabilities/schema_test.go",
@ -2116,6 +2125,7 @@
"exact_files": [
"cmd/pulse-mcp/main_test.go",
"internal/agentcapabilities/action_target_test.go",
"internal/agentcapabilities/invocation_test.go",
"internal/agentcapabilities/manifest_test.go",
"internal/agentcapabilities/mcp_test.go",
"internal/agentcapabilities/schema_test.go",

View file

@ -0,0 +1,224 @@
package agentcapabilities
import (
"fmt"
"sort"
"strings"
)
// MutationTarget names what an individual tool invocation can change.
// Workflow kind (read/write/resolve) drives FSM transitions; mutation
// target drives safety policy: control-level gating and request-scoped
// mutation-deny policies key on it, never on workflow kind alone.
type MutationTarget string
const (
// MutationNone: the invocation changes nothing durable.
MutationNone MutationTarget = "none"
// MutationPulseState: the invocation changes Pulse's own records
// (findings, alerts, knowledge) but no customer infrastructure.
MutationPulseState MutationTarget = "pulse_state"
// MutationInfrastructure: the invocation can change customer
// infrastructure. Blocked at read-only control level and under
// deny-infrastructure-mutations request policy, before any handler.
MutationInfrastructure MutationTarget = "infrastructure"
)
// InvocationClass is the classification of one concrete tool invocation.
type InvocationClass struct {
Kind ToolCallKind
Mutation MutationTarget
}
// FailClosedInvocationClass is what missing, malformed, or unknown
// invocations classify as: a newly introduced or fabricated subaction can
// never bypass governed-mutation checks by being unclassified.
func FailClosedInvocationClass() InvocationClass {
return InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure}
}
// InvocationDescriptor is the registry-owned classification contract for
// one tool. A tool is either static (every invocation has one class) or
// discriminator-based (the named argument selects the subaction, and Cases
// must exactly cover the schema enum for that argument; registration
// asserts the coverage).
type InvocationDescriptor struct {
// Discriminator is the argument key whose value selects the
// subaction. Empty for static tools.
Discriminator string
// Static is the classification for every invocation of a static tool.
Static *InvocationClass
// Cases maps each declared discriminator enum value to its class.
Cases map[string]InvocationClass
}
// Classify resolves the invocation class for a concrete argument map.
// Missing, malformed, or unknown discriminator values fail closed.
func (d InvocationDescriptor) Classify(args map[string]interface{}) InvocationClass {
if d.Static != nil {
return *d.Static
}
if d.Discriminator == "" || len(d.Cases) == 0 {
return FailClosedInvocationClass()
}
raw, ok := args[d.Discriminator]
if !ok {
return FailClosedInvocationClass()
}
value, ok := raw.(string)
if !ok {
return FailClosedInvocationClass()
}
class, ok := d.Cases[strings.ToLower(strings.TrimSpace(value))]
if !ok {
return FailClosedInvocationClass()
}
return class
}
// Validate checks the descriptor's own shape and, for discriminator-based
// descriptors, that its cases exactly cover the given schema enum values.
// Registration fails on missing or extra cases so the classification
// contract can never drift from the offered schema.
func (d InvocationDescriptor) Validate(toolName string, enumValues []string) error {
if d.Static != nil {
if d.Discriminator != "" || len(d.Cases) != 0 {
return fmt.Errorf("tool %q invocation descriptor must be static or discriminator-based, not both", toolName)
}
return nil
}
if d.Discriminator == "" {
return fmt.Errorf("tool %q invocation descriptor declares neither static class nor discriminator", toolName)
}
if len(enumValues) == 0 {
return fmt.Errorf("tool %q discriminator %q has no schema enum to cover", toolName, d.Discriminator)
}
want := map[string]bool{}
for _, v := range enumValues {
want[strings.ToLower(strings.TrimSpace(v))] = true
}
var missing, extra []string
for v := range want {
if _, ok := d.Cases[v]; !ok {
missing = append(missing, v)
}
}
for v := range d.Cases {
if !want[v] {
extra = append(extra, v)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 || len(extra) > 0 {
return fmt.Errorf("tool %q invocation descriptor does not exactly cover schema enum for %q (missing=%v extra=%v)", toolName, d.Discriminator, missing, extra)
}
return nil
}
func staticClass(kind ToolCallKind, mutation MutationTarget) InvocationDescriptor {
class := InvocationClass{Kind: kind, Mutation: mutation}
return InvocationDescriptor{Static: &class}
}
// registryInvocationDescriptors is the canonical classification table for
// every Pulse registry tool. Workflow kinds intentionally match the
// historical shared classifier so FSM transitions do not change; mutation
// targets are the safety-policy layer on top.
//
// Kubernetes's discriminator is `type`, not `action` - the historical
// hard-coded classifier read `action` and therefore classified every
// Kubernetes invocation (including scale/restart/delete_pod/exec) as read.
var registryInvocationDescriptors = map[string]InvocationDescriptor{
PulseQueryToolName: staticClass(ToolCallKindResolve, MutationNone),
PulseMetricsToolName: staticClass(ToolCallKindRead, MutationNone),
PulseStorageToolName: staticClass(ToolCallKindRead, MutationNone),
PulsePMGToolName: staticClass(ToolCallKindRead, MutationNone),
PulseSummarizeToolName: staticClass(ToolCallKindRead, MutationNone),
// pulse_read's exec subaction dispatches only structurally read-only
// commands: the handler's execution-intent classifier rejects
// WriteOrUnknown commands before dispatch.
PulseReadToolName: staticClass(ToolCallKindRead, MutationNone),
PulseControlToolName: staticClass(ToolCallKindWrite, MutationInfrastructure),
// pulse_file_edit is write-only: file inspection routes through
// pulse_read, so this tool never advertises a read subaction.
PulseFileEditToolName: staticClass(ToolCallKindWrite, MutationInfrastructure),
PulseDiscoveryToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"get": {Kind: ToolCallKindResolve, Mutation: MutationNone},
"list": {Kind: ToolCallKindResolve, Mutation: MutationNone},
// run collects evidence into the discovery cache only; it
// does not mutate customer infrastructure.
"run": {Kind: ToolCallKindResolve, Mutation: MutationNone},
},
},
PulseAlertsToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"list": {Kind: ToolCallKindRead, Mutation: MutationNone},
"findings": {Kind: ToolCallKindRead, Mutation: MutationNone},
"resolved": {Kind: ToolCallKindRead, Mutation: MutationNone},
"resolve": {Kind: ToolCallKindWrite, Mutation: MutationPulseState},
"dismiss": {Kind: ToolCallKindWrite, Mutation: MutationPulseState},
},
},
PulseKubernetesToolName: {
Discriminator: "type",
Cases: map[string]InvocationClass{
"clusters": {Kind: ToolCallKindRead, Mutation: MutationNone},
"nodes": {Kind: ToolCallKindRead, Mutation: MutationNone},
"pods": {Kind: ToolCallKindRead, Mutation: MutationNone},
"deployments": {Kind: ToolCallKindRead, Mutation: MutationNone},
"logs": {Kind: ToolCallKindRead, Mutation: MutationNone},
"scale": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
"restart": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
"delete_pod": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
"exec": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
},
},
PulseDockerToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"updates": {Kind: ToolCallKindRead, Mutation: MutationNone},
"services": {Kind: ToolCallKindRead, Mutation: MutationNone},
"tasks": {Kind: ToolCallKindRead, Mutation: MutationNone},
"swarm": {Kind: ToolCallKindRead, Mutation: MutationNone},
// check_updates queues a read-only scan command on the
// agent; it changes nothing on the container estate.
"check_updates": {Kind: ToolCallKindWrite, Mutation: MutationNone},
"control": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
"update": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
},
},
PulseKnowledgeToolName: {
Discriminator: "action",
Cases: map[string]InvocationClass{
"recall": {Kind: ToolCallKindRead, Mutation: MutationNone},
"incidents": {Kind: ToolCallKindRead, Mutation: MutationNone},
"correlate": {Kind: ToolCallKindRead, Mutation: MutationNone},
"remember": {Kind: ToolCallKindWrite, Mutation: MutationPulseState},
},
},
PatrolGetFindingsToolName: staticClass(ToolCallKindRead, MutationNone),
PatrolReportFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
PatrolResolveFindingToolName: staticClass(ToolCallKindWrite, MutationPulseState),
}
// InvocationDescriptorFor returns the canonical invocation descriptor for
// a registry tool name.
func InvocationDescriptorFor(toolName string) (InvocationDescriptor, bool) {
d, ok := registryInvocationDescriptors[strings.TrimSpace(toolName)]
return d, ok
}
// ClassifyRegisteredInvocation classifies a concrete invocation of a
// registry tool. Unknown tool names fail closed: a tool without a
// descriptor cannot be assumed safe.
func ClassifyRegisteredInvocation(toolName string, args map[string]interface{}) InvocationClass {
descriptor, ok := InvocationDescriptorFor(toolName)
if !ok {
return FailClosedInvocationClass()
}
return descriptor.Classify(args)
}

View file

@ -0,0 +1,93 @@
package agentcapabilities
import (
"testing"
)
func TestInvocationDescriptorClassifyFailsClosed(t *testing.T) {
descriptor, ok := InvocationDescriptorFor(PulseKubernetesToolName)
if !ok {
t.Fatal("kubernetes descriptor missing")
}
cases := []struct {
name string
args map[string]interface{}
}{
{name: "missing discriminator", args: nil},
{name: "malformed discriminator", args: map[string]interface{}{"type": 42}},
{name: "unknown value", args: map[string]interface{}{"type": "drain_node"}},
{name: "wrong discriminator key", args: map[string]interface{}{"action": "pods"}},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
class := descriptor.Classify(tt.args)
if class != FailClosedInvocationClass() {
t.Fatalf("Classify(%#v) = %#v, want fail-closed write/infrastructure", tt.args, class)
}
})
}
if class := ClassifyRegisteredInvocation("no_such_tool", nil); class != FailClosedInvocationClass() {
t.Fatalf("unknown tool classified %#v, want fail-closed", class)
}
}
func TestInvocationDescriptorValidateRequiresExactEnumCoverage(t *testing.T) {
descriptor := InvocationDescriptor{
Discriminator: "action",
Cases: map[string]InvocationClass{
"list": {Kind: ToolCallKindRead, Mutation: MutationNone},
"restart": {Kind: ToolCallKindWrite, Mutation: MutationInfrastructure},
},
}
if err := descriptor.Validate("demo", []string{"list", "restart"}); err != nil {
t.Fatalf("exact coverage should validate: %v", err)
}
if err := descriptor.Validate("demo", []string{"list", "restart", "delete"}); err == nil {
t.Fatal("missing case must fail validation")
}
if err := descriptor.Validate("demo", []string{"list"}); err == nil {
t.Fatal("extra case must fail validation")
}
if err := descriptor.Validate("demo", nil); err == nil {
t.Fatal("discriminator without schema enum must fail validation")
}
static := InvocationDescriptor{Static: &InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone}}
if err := static.Validate("demo", nil); err != nil {
t.Fatalf("static descriptor should validate without enum: %v", err)
}
both := static
both.Discriminator = "action"
if err := both.Validate("demo", nil); err == nil {
t.Fatal("static plus discriminator must fail validation")
}
neither := InvocationDescriptor{}
if err := neither.Validate("demo", nil); err == nil {
t.Fatal("empty descriptor must fail validation")
}
}
func TestCanonicalDescriptorsPinSafetyCriticalClassifications(t *testing.T) {
assertClass := func(tool string, args map[string]interface{}, want InvocationClass) {
t.Helper()
if got := ClassifyRegisteredInvocation(tool, args); got != want {
t.Fatalf("%s %v = %#v, want %#v", tool, args, got, want)
}
}
assertClass(PulseKubernetesToolName, map[string]interface{}{"type": "scale"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
assertClass(PulseDockerToolName, map[string]interface{}{"action": "update"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
assertClass(PulseDockerToolName, map[string]interface{}{"action": "check_updates"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationNone})
assertClass(PulseAlertsToolName, map[string]interface{}{"action": "resolve"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationPulseState})
assertClass(PulseControlToolName, nil,
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
assertClass(PulseFileEditToolName, map[string]interface{}{"action": "write"},
InvocationClass{Kind: ToolCallKindWrite, Mutation: MutationInfrastructure})
assertClass(PulseReadToolName, map[string]interface{}{"action": "exec"},
InvocationClass{Kind: ToolCallKindRead, Mutation: MutationNone})
}

View file

@ -89,9 +89,18 @@ func PrepareToolRegistryExecution(name string, args map[string]any) (ToolCallPar
}
// ClassifyToolCall classifies a provider/registry tool call for safety gates
// and workflow state transitions. Unknown tools default to write so newly
// introduced tools cannot bypass governed-action checks accidentally.
// and workflow state transitions. Registry tools classify through their
// canonical invocation descriptors (the same table the tool registry
// enforces at execution time), so FSM classification and runtime policy
// can never disagree. The switch below covers only genuinely non-registry
// names: chat-native tools, MCP/native adapter names, and legacy assistant
// aliases. Unknown tools default to write so newly introduced tools cannot
// bypass governed-action checks accidentally.
func ClassifyToolCall(toolName string, args map[string]interface{}) ToolCallKind {
if descriptor, ok := InvocationDescriptorFor(toolName); ok {
return descriptor.Classify(args).Kind
}
action, _ := args["action"].(string)
actionLower := strings.ToLower(action)
operation, _ := args["operation"].(string)
@ -101,60 +110,9 @@ func ClassifyToolCall(toolName string, args map[string]interface{}) ToolCallKind
case PulseQuestionToolName:
return ToolCallKindUserInput
case PulseQueryToolName, PulseDiscoveryToolName:
return ToolCallKindResolve
case PulseMetricsToolName, PulseStorageToolName, PulsePMGToolName, PulseSummarizeToolName:
case LegacyAssistantFetchURLToolName:
return ToolCallKindRead
case PulseAlertsToolName:
switch actionLower {
case "resolve", "dismiss":
return ToolCallKindWrite
default:
return ToolCallKindRead
}
case PulseKubernetesToolName:
switch actionLower {
case "scale", "restart", "delete_pod", "exec":
return ToolCallKindWrite
default:
return ToolCallKindRead
}
case PulseKnowledgeToolName:
switch actionLower {
case "remember", "note", "save":
return ToolCallKindWrite
default:
return ToolCallKindRead
}
case PulseReadToolName, LegacyAssistantFetchURLToolName:
return ToolCallKindRead
case PulseControlToolName:
return ToolCallKindWrite
case PulseDockerToolName:
switch actionLower {
case "control", "update", "check_updates", "trigger_update":
return ToolCallKindWrite
default:
return ToolCallKindRead
}
case PulseFileEditToolName:
switch actionLower {
case "read":
return ToolCallKindRead
case "write", "append":
return ToolCallKindWrite
default:
return ToolCallKindRead
}
case PulseRunCommandToolName, PulseControlGuestToolName, PulseControlDockerToolName,
LegacyAssistantRunCommandToolName, LegacyAssistantSetResourceURLToolName:
return ToolCallKindWrite
@ -166,11 +124,6 @@ func ClassifyToolCall(toolName string, args map[string]interface{}) ToolCallKind
case PulseGetDockerLogsToolName, PulseGetPerformanceMetricsToolName,
PulseGetTemperaturesToolName, PulseGetBaselinesToolName, PulseGetPatternsToolName:
return ToolCallKindRead
case PatrolGetFindingsToolName:
return ToolCallKindRead
case PatrolReportFindingToolName, PatrolResolveFindingToolName:
return ToolCallKindWrite
}
if toolCallActionIsWrite(actionLower) || toolCallActionIsWrite(operationLower) {
@ -228,3 +181,14 @@ func NewUnknownToolResult(name string) ToolResult {
func NewControlToolsDisabledToolResult() ToolResult {
return NewToolTextResult(ControlToolsDisabledMessage)
}
// NewInvocationBlockedToolResult is the stable shared result for a tool
// invocation the registry's invocation policy refuses before the handler
// runs: an infrastructure-mutating (or unclassifiable, therefore
// fail-closed) invocation under a read-only control level or a
// deny-infrastructure-mutations request policy.
func NewInvocationBlockedToolResult(toolName string, class InvocationClass) ToolResult {
return NewToolTextResultWithIsError(fmt.Sprintf(
"Invocation blocked: this %s call classifies as an infrastructure-mutating action (%s), which the current session policy does not permit. Read-only investigation may gather evidence and propose a typed action instead of mutating directly.",
toolName, class.Mutation), true)
}

View file

@ -101,7 +101,9 @@ func TestClassifyToolCallUsesSharedSafetyClassification(t *testing.T) {
}{
{name: "native question", toolName: PulseQuestionToolName, want: ToolCallKindUserInput},
{name: "query resolves", toolName: "pulse_query", want: ToolCallKindResolve},
{name: "discovery resolves", toolName: "pulse_discovery", want: ToolCallKindResolve},
{name: "discovery get resolves", toolName: "pulse_discovery", args: map[string]interface{}{"action": "get"}, want: ToolCallKindResolve},
{name: "discovery run resolves", toolName: "pulse_discovery", args: map[string]interface{}{"action": "run"}, want: ToolCallKindResolve},
{name: "discovery missing action fails closed", toolName: "pulse_discovery", want: ToolCallKindWrite},
{name: "metrics reads", toolName: "pulse_metrics", want: ToolCallKindRead},
{name: "summarize reads", toolName: PulseSummarizeToolName, want: ToolCallKindRead},
{name: "alert list reads", toolName: "pulse_alerts", args: map[string]interface{}{"action": "list"}, want: ToolCallKindRead},
@ -110,9 +112,15 @@ func TestClassifyToolCallUsesSharedSafetyClassification(t *testing.T) {
{name: "control writes", toolName: "pulse_control", args: map[string]interface{}{"type": "command"}, want: ToolCallKindWrite},
{name: "docker services reads", toolName: "pulse_docker", args: map[string]interface{}{"action": "services"}, want: ToolCallKindRead},
{name: "docker update writes", toolName: "pulse_docker", args: map[string]interface{}{"action": "update"}, want: ToolCallKindWrite},
{name: "kubernetes pods reads", toolName: "pulse_kubernetes", args: map[string]interface{}{"action": "pods"}, want: ToolCallKindRead},
{name: "kubernetes exec writes", toolName: "pulse_kubernetes", args: map[string]interface{}{"action": "exec"}, want: ToolCallKindWrite},
{name: "file read reads", toolName: "pulse_file_edit", args: map[string]interface{}{"action": "read"}, want: ToolCallKindRead},
// Kubernetes's real discriminator is `type`; the retired
// hard-coded classifier read `action` and therefore classified
// scale/restart/delete_pod/exec as read.
{name: "kubernetes pods reads", toolName: "pulse_kubernetes", args: map[string]interface{}{"type": "pods"}, want: ToolCallKindRead},
{name: "kubernetes scale writes", toolName: "pulse_kubernetes", args: map[string]interface{}{"type": "scale"}, want: ToolCallKindWrite},
{name: "kubernetes exec writes", toolName: "pulse_kubernetes", args: map[string]interface{}{"type": "exec"}, want: ToolCallKindWrite},
{name: "kubernetes wrong discriminator fails closed", toolName: "pulse_kubernetes", args: map[string]interface{}{"action": "pods"}, want: ToolCallKindWrite},
// pulse_file_edit is write-only; file reads route via pulse_read.
{name: "file read fails closed", toolName: "pulse_file_edit", args: map[string]interface{}{"action": "read"}, want: ToolCallKindWrite},
{name: "file append writes", toolName: "pulse_file_edit", args: map[string]interface{}{"action": "append"}, want: ToolCallKindWrite},
{name: "knowledge recall reads", toolName: "pulse_knowledge", args: map[string]interface{}{"action": "recall"}, want: ToolCallKindRead},
{name: "knowledge remember writes", toolName: "pulse_knowledge", args: map[string]interface{}{"action": "remember"}, want: ToolCallKindWrite},

View file

@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
)
@ -372,6 +373,7 @@ func TestBuildAutomaticFallbackSummary_UsesToolNamesNotCallIDsOrRawOutput(t *tes
func TestExecuteToolSafely_RecoversPanic(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{
Name: "panic_tool",
InputSchema: tools.InputSchema{
@ -664,6 +666,7 @@ func TestAgenticLoop_DoesNotAutoRecoverStructuredToolCall(t *testing.T) {
recoveryCalls := 0
executor.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{
Name: "fail_tool",
InputSchema: tools.InputSchema{
@ -683,6 +686,7 @@ func TestAgenticLoop_DoesNotAutoRecoverStructuredToolCall(t *testing.T) {
},
})
executor.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{
Name: "recovery_tool",
InputSchema: tools.InputSchema{
@ -766,6 +770,7 @@ func TestAgenticLoop_NormalizesProviderToolCallsThroughSharedProjection(t *testi
executor := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
var capturedArgs map[string]interface{}
executor.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{
Name: "test_tool",
InputSchema: tools.InputSchema{

View file

@ -285,7 +285,9 @@ func TestClassifyToolCall(t *testing.T) {
}{
// Resolve tools
{"pulse_query", "pulse_query", nil, ToolKindResolve},
{"pulse_discovery", "pulse_discovery", nil, ToolKindResolve},
{"pulse_discovery get", "pulse_discovery", map[string]interface{}{"action": "get"}, ToolKindResolve},
// Missing required discriminators fail closed as write.
{"pulse_discovery no action", "pulse_discovery", nil, ToolKindWrite},
{"pulse_search_resources", "pulse_search_resources", nil, ToolKindResolve},
// Interactive user input tools
@ -294,7 +296,8 @@ func TestClassifyToolCall(t *testing.T) {
// Read tools
{"pulse_metrics", "pulse_metrics", nil, ToolKindRead},
{"pulse_storage", "pulse_storage", nil, ToolKindRead},
{"pulse_kubernetes", "pulse_kubernetes", nil, ToolKindRead},
// Kubernetes without its required `type` discriminator fails closed.
{"pulse_kubernetes no type", "pulse_kubernetes", nil, ToolKindWrite},
{"pulse_pmg", "pulse_pmg", nil, ToolKindRead},
{"pulse_alerts list", "pulse_alerts", map[string]interface{}{"action": "list"}, ToolKindRead},
@ -318,13 +321,16 @@ 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},
// Kubernetes - depends on `type`, its real schema discriminator.
// The retired hard-coded classifier read `action` and therefore
// classified scale/exec as read.
{"pulse_kubernetes pods", "pulse_kubernetes", map[string]interface{}{"type": "pods"}, ToolKindRead},
{"pulse_kubernetes scale", "pulse_kubernetes", map[string]interface{}{"type": "scale"}, ToolKindWrite},
{"pulse_kubernetes exec", "pulse_kubernetes", map[string]interface{}{"type": "exec"}, ToolKindWrite},
{"pulse_kubernetes wrong discriminator", "pulse_kubernetes", map[string]interface{}{"action": "pods"}, ToolKindWrite},
// File edit - depends on action
{"pulse_file_edit read", "pulse_file_edit", map[string]interface{}{"action": "read"}, ToolKindRead},
// File edit is write-only; file reads route through pulse_read.
{"pulse_file_edit read fails closed", "pulse_file_edit", map[string]interface{}{"action": "read"}, ToolKindWrite},
{"pulse_file_edit write", "pulse_file_edit", map[string]interface{}{"action": "write"}, ToolKindWrite},
{"pulse_file_edit append", "pulse_file_edit", map[string]interface{}{"action": "append"}, ToolKindWrite},

View file

@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
@ -252,6 +253,7 @@ func TestServiceExecutePatrolStreamReturnsPartialTokenCountsOnError(t *testing.T
cfg: &config.AIConfig{PatrolModel: "mock:model"},
}
executor.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{
Name: "test_tool",
Description: "test tool",

View file

@ -562,6 +562,7 @@ func TestToolsForExecutionMode_PatrolScopeUsesConfigNotPrompt(t *testing.T) {
func TestExecuteCommand_SuccessAndExitCode(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: agentcapabilities.PulseRunCommandToolName},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("Command failed (exit code 7): boom"), nil
@ -585,6 +586,7 @@ func TestExecuteCommand_SuccessAndExitCode(t *testing.T) {
func TestExecuteCommand_ErrorAndApprovalPaths(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: agentcapabilities.PulseRunCommandToolName},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewErrorResult(context.Canceled), nil
@ -599,6 +601,7 @@ func TestExecuteCommand_ErrorAndApprovalPaths(t *testing.T) {
}
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: agentcapabilities.PulseRunCommandToolName},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("APPROVAL_REQUIRED: requires approval"), nil
@ -614,6 +617,7 @@ func TestExecuteCommand_ErrorAndApprovalPaths(t *testing.T) {
func TestExecuteCommandUsesSharedResultTextProjection(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: agentcapabilities.PulseRunCommandToolName},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.CallToolResult{
@ -643,6 +647,7 @@ func TestExecuteCommandUsesSharedResultTextProjection(t *testing.T) {
func TestExecuteAssistantTool_ErrorsAndSuccess(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: "test_tool"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewErrorResult(context.DeadlineExceeded), nil
@ -657,6 +662,7 @@ func TestExecuteAssistantTool_ErrorsAndSuccess(t *testing.T) {
}
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: "test_tool"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("POLICY_BLOCKED: nope"), nil
@ -668,6 +674,7 @@ func TestExecuteAssistantTool_ErrorsAndSuccess(t *testing.T) {
}
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: "test_tool"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("ok"), nil
@ -682,6 +689,7 @@ func TestExecuteAssistantTool_ErrorsAndSuccess(t *testing.T) {
func TestExecuteAssistantToolUsesSharedResultTextProjection(t *testing.T) {
exec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
exec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: "test_tool"},
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.CallToolResult{

View file

@ -399,6 +399,7 @@ func TestExecuteToolCallTreatsSharedApprovalAndPolicyMarkersAsInconclusive(t *te
Name: "patrol_marker_probe",
Description: "test-only marker probe",
},
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Handler: func(context.Context, *tools.PulseToolExecutor, map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult(tt.resultText), nil
},

View file

@ -559,6 +559,14 @@ type PulseToolExecutor struct {
targetID string
isAutonomous bool
orgID string
// denyInfrastructureMutations is the request-local execution
// restriction for non-interactive read-only workloads (e.g. Patrol
// investigations): every infrastructure-mutating invocation is
// blocked by the registry before its handler runs, regardless of
// control level or autonomy. Core-owned and never serialized; it is
// deliberately separate from isAutonomous, which only suppresses
// interactive questions and grants no mutation authority.
denyInfrastructureMutations bool
// Session-scoped resolved context for resource validation
// This is set per-session by the agentic loop before tool execution
@ -681,45 +689,46 @@ func (e *PulseToolExecutor) Clone() *PulseToolExecutor {
}
clone := &PulseToolExecutor{
stateProvider: e.stateProvider,
policy: e.policy,
agentServer: e.agentServer,
metricsHistory: e.metricsHistory,
baselineProvider: e.baselineProvider,
patternProvider: e.patternProvider,
alertProvider: e.alertProvider,
findingsProvider: e.findingsProvider,
backupProvider: e.backupProvider,
replicationProvider: e.replicationProvider,
connectionHealth: e.connectionHealth,
recoveryPointsProvider: e.recoveryPointsProvider,
guestConfigProvider: e.guestConfigProvider,
appContainerConfigProvider: e.appContainerConfigProvider,
diskHealthProvider: e.diskHealthProvider,
updatesProvider: e.updatesProvider,
metadataUpdater: e.metadataUpdater,
findingsManager: e.findingsManager,
agentProfileManager: e.agentProfileManager,
incidentRecorderProvider: e.incidentRecorderProvider,
eventCorrelatorProvider: e.eventCorrelatorProvider,
knowledgeStoreProvider: e.knowledgeStoreProvider,
discoveryProvider: e.discoveryProvider,
unifiedResourceProvider: e.unifiedResourceProvider,
appContainerActionProvider: e.appContainerActionProvider,
appContainerReadProvider: e.appContainerReadProvider,
actionAuditStore: e.actionAuditStore,
readState: e.readState,
controlLevel: e.controlLevel,
protectedGuests: append([]string(nil), e.protectedGuests...),
targetType: e.targetType,
targetID: e.targetID,
isAutonomous: e.isAutonomous,
orgID: e.orgID,
telemetryCallback: e.telemetryCallback,
reportNarrator: e.reportNarrator,
reportFleetNarrator: e.reportFleetNarrator,
reportFindingsProvider: e.reportFindingsProvider,
registry: e.registry,
stateProvider: e.stateProvider,
policy: e.policy,
agentServer: e.agentServer,
metricsHistory: e.metricsHistory,
baselineProvider: e.baselineProvider,
patternProvider: e.patternProvider,
alertProvider: e.alertProvider,
findingsProvider: e.findingsProvider,
backupProvider: e.backupProvider,
replicationProvider: e.replicationProvider,
connectionHealth: e.connectionHealth,
recoveryPointsProvider: e.recoveryPointsProvider,
guestConfigProvider: e.guestConfigProvider,
appContainerConfigProvider: e.appContainerConfigProvider,
diskHealthProvider: e.diskHealthProvider,
updatesProvider: e.updatesProvider,
metadataUpdater: e.metadataUpdater,
findingsManager: e.findingsManager,
agentProfileManager: e.agentProfileManager,
incidentRecorderProvider: e.incidentRecorderProvider,
eventCorrelatorProvider: e.eventCorrelatorProvider,
knowledgeStoreProvider: e.knowledgeStoreProvider,
discoveryProvider: e.discoveryProvider,
unifiedResourceProvider: e.unifiedResourceProvider,
appContainerActionProvider: e.appContainerActionProvider,
appContainerReadProvider: e.appContainerReadProvider,
actionAuditStore: e.actionAuditStore,
readState: e.readState,
controlLevel: e.controlLevel,
protectedGuests: append([]string(nil), e.protectedGuests...),
targetType: e.targetType,
targetID: e.targetID,
isAutonomous: e.isAutonomous,
orgID: e.orgID,
denyInfrastructureMutations: e.denyInfrastructureMutations,
telemetryCallback: e.telemetryCallback,
reportNarrator: e.reportNarrator,
reportFleetNarrator: e.reportFleetNarrator,
reportFindingsProvider: e.reportFindingsProvider,
registry: e.registry,
}
clone.patrolFindingCreator = e.GetPatrolFindingCreator()
return clone
@ -970,9 +979,28 @@ func (e *PulseToolExecutor) GetResolvedContext() ResolvedContextProvider {
return e.resolvedContext
}
// invocationPolicy is the request-scoped safety policy for this executor
// instance: the session control level plus the deny-infrastructure
// restriction. Clones snapshot the policy, so a per-request restriction
// on one clone can never leak into concurrent sessions.
func (e *PulseToolExecutor) invocationPolicy() InvocationPolicy {
return InvocationPolicy{
ControlLevel: e.controlLevel,
DenyInfrastructureMutations: e.denyInfrastructureMutations,
}
}
// SetDenyInfrastructureMutations toggles the request-local restriction
// that blocks every infrastructure-mutating invocation at the registry
// boundary, before any handler runs. Intended for non-interactive
// read-only workloads such as Patrol investigations.
func (e *PulseToolExecutor) SetDenyInfrastructureMutations(deny bool) {
e.denyInfrastructureMutations = deny
}
// ListTools returns the list of available tools
func (e *PulseToolExecutor) ListTools() []Tool {
tools := e.registry.ListTools(e.controlLevel)
tools := e.registry.ListTools(e.invocationPolicy())
if len(tools) == 0 {
return tools
}
@ -988,7 +1016,7 @@ func (e *PulseToolExecutor) ListTools() []Tool {
// ListToolGovernance returns the governed manifest for currently available tools.
func (e *PulseToolExecutor) ListToolGovernance() []ToolGovernanceDescriptor {
tools := e.registry.ListToolGovernance(e.controlLevel)
tools := e.registry.ListToolGovernance(e.invocationPolicy())
if len(tools) == 0 {
return tools
}

View file

@ -190,27 +190,29 @@ func TestPulseToolExecutor_GetReadStatePrefersUnifiedResourceProvider(t *testing
func TestToolRegistry_ListTools(t *testing.T) {
registry := NewToolRegistry()
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "read"},
})
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "control"},
RequireControl: true,
})
readOnly := registry.ListTools(ControlLevelReadOnly)
readOnly := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
require.Len(t, readOnly, 1)
assert.Equal(t, "read", readOnly[0].Name)
unknown := registry.ListTools(ControlLevel("bad"))
unknown := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevel("bad")})
require.Len(t, unknown, 1)
assert.Equal(t, "read", unknown[0].Name)
full := registry.ListTools(ControlLevelControlled)
full := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelControlled})
require.Len(t, full, 2)
assert.Equal(t, "read", full[0].Name)
assert.Equal(t, "control", full[1].Name)
governance := registry.ListToolGovernance(ControlLevelControlled)
governance := registry.ListToolGovernance(InvocationPolicy{ControlLevel: ControlLevelControlled})
require.Len(t, governance, 2)
assert.Equal(t, ToolActionRead, governance[0].ActionMode)
assert.Equal(t, ToolActionWrite, governance[1].ActionMode)
@ -226,6 +228,7 @@ func TestToolRegistry_ListToolsReturnsIndependentDefinitions(t *testing.T) {
"mode": {Type: "string", Enum: enum},
}
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{
Name: "read",
InputSchema: InputSchema{
@ -241,7 +244,7 @@ func TestToolRegistry_ListToolsReturnsIndependentDefinitions(t *testing.T) {
sourceMode.Enum[1] = "source-mutated"
properties["mode"] = sourceMode
first := registry.ListTools(ControlLevelReadOnly)
first := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
require.Len(t, first, 1)
assert.Equal(t, "object", first[0].InputSchema.Type)
assert.Equal(t, "mode", first[0].InputSchema.Required[0])
@ -253,7 +256,7 @@ func TestToolRegistry_ListToolsReturnsIndependentDefinitions(t *testing.T) {
listedMode.Enum[0] = "listed-mutated"
first[0].InputSchema.Properties["mode"] = listedMode
second := registry.ListTools(ControlLevelReadOnly)
second := registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
require.Len(t, second, 1)
assert.Equal(t, "mode", second[0].InputSchema.Required[0])
assert.Equal(t, "summary", second[0].InputSchema.Properties["mode"].Enum[0])
@ -301,6 +304,7 @@ func TestToolGovernanceUsesSharedAgentCapabilityShape(t *testing.T) {
func TestToolRegistry_ExecuteControlToolReadOnlyUsesAssistantAndPatrolGuidance(t *testing.T) {
registry := NewToolRegistry()
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "control"},
RequireControl: true,
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
@ -327,6 +331,7 @@ func TestToolRegistryExecuteNormalizesSharedToolCallParams(t *testing.T) {
var gotArgs map[string]interface{}
var gotArgsLenBeforeMutation int
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "test_tool"},
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
gotArgs = args
@ -359,6 +364,7 @@ func TestToolRegistryExecuteNormalizesSharedToolCallParams(t *testing.T) {
assert.Contains(t, interpreted.Text, "invalid tools/call params: tool name is required")
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "empty_args"},
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
gotArgs = args
@ -377,12 +383,14 @@ func TestToolRegistryExecuteNormalizesSharedToolResult(t *testing.T) {
registry := NewToolRegistry()
handlerContent := []Content{{Type: "text", Text: "ok"}}
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "read"},
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return CallToolResult{Content: handlerContent}, nil
},
})
registry.Register(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "empty"},
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
return CallToolResult{}, nil

View file

@ -22,7 +22,7 @@ const (
// honored.
func CanonicalToolGovernance(controlLevel ControlLevel) []ToolGovernanceDescriptor {
e := NewPulseToolExecutor(ExecutorConfig{})
return e.registry.ListToolGovernance(controlLevel)
return e.registry.ListToolGovernance(InvocationPolicy{ControlLevel: controlLevel})
}
// CanonicalToolGovernanceForSurface returns the registry-owned fallback

View file

@ -8,7 +8,7 @@ import (
func TestCanonicalToolGovernanceMirrorsRegisteredRegistry(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
registryGovernance := exec.registry.ListToolGovernance(ControlLevelControlled)
registryGovernance := exec.registry.ListToolGovernance(InvocationPolicy{ControlLevel: ControlLevelControlled})
canonical := CanonicalToolGovernance(ControlLevelControlled)
if len(canonical) != len(registryGovernance) {
@ -23,7 +23,7 @@ func TestCanonicalToolGovernanceMirrorsRegisteredRegistry(t *testing.T) {
func TestCanonicalToolGovernanceForAssistantSurfaceMirrorsRegisteredRegistry(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
registryGovernance := exec.registry.ListToolGovernance(ControlLevelControlled)
registryGovernance := exec.registry.ListToolGovernance(InvocationPolicy{ControlLevel: ControlLevelControlled})
canonical := CanonicalToolGovernanceForSurface(ControlLevelControlled, ToolGovernanceSurfacePulseAssistant)
filtered := make([]ToolGovernanceDescriptor, 0, len(registryGovernance))

View file

@ -0,0 +1,149 @@
package tools
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
)
// The invocation-policy regression proofs: the registry blocks
// infrastructure-mutating invocations before any handler runs, the offered
// provider schema and the runtime boundary consume the same descriptor, and
// request-local policies never leak across executor clones.
func newInvocationPolicyExecutor(t *testing.T) *PulseToolExecutor {
t.Helper()
return NewPulseToolExecutor(ExecutorConfig{})
}
func executeBlockedText(t *testing.T, exec *PulseToolExecutor, tool string, args map[string]interface{}) string {
t.Helper()
result, err := exec.registry.Execute(context.Background(), exec, tool, args)
require.NoError(t, err)
require.NotEmpty(t, result.Content)
return result.Content[0].Text
}
func TestKubernetesScaleClassifiesWriteAndNeverInvokesUnderReadOnly(t *testing.T) {
class := agentcapabilities.ClassifyRegisteredInvocation("pulse_kubernetes", map[string]interface{}{"type": "scale"})
assert.Equal(t, agentcapabilities.ToolCallKindWrite, class.Kind)
assert.Equal(t, agentcapabilities.MutationInfrastructure, class.Mutation)
exec := newInvocationPolicyExecutor(t)
exec.SetControlLevel(ControlLevelReadOnly)
text := executeBlockedText(t, exec, "pulse_kubernetes", map[string]interface{}{"type": "scale"})
assert.Equal(t, agentcapabilities.ControlToolsDisabledMessage, text)
// Deny-mutations policy blocks even with an autonomous control level.
exec.SetControlLevel(ControlLevelAutonomous)
exec.SetDenyInfrastructureMutations(true)
text = executeBlockedText(t, exec, "pulse_kubernetes", map[string]interface{}{"type": "scale"})
assert.Contains(t, text, "Invocation blocked")
assert.Contains(t, text, "infrastructure")
}
func TestDockerUpdateQueuesNothingAtReadOnly(t *testing.T) {
updates := &mockUpdatesProvider{}
exec := NewPulseToolExecutor(ExecutorConfig{UpdatesProvider: updates})
exec.SetControlLevel(ControlLevelReadOnly)
text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{
"action": "update", "container": "nginx", "host": "tower",
})
assert.Equal(t, agentcapabilities.ControlToolsDisabledMessage, text)
updates.AssertNotCalled(t, "UpdateContainer")
updates.AssertNotCalled(t, "IsUpdateActionsEnabled")
}
func TestAutonomousDenyMutationsCannotMutateDocker(t *testing.T) {
updates := &mockUpdatesProvider{}
exec := NewPulseToolExecutor(ExecutorConfig{UpdatesProvider: updates})
exec.SetControlLevel(ControlLevelAutonomous)
exec.SetAutonomousMode(true)
exec.SetDenyInfrastructureMutations(true)
text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{
"action": "update", "container": "nginx", "host": "tower",
})
assert.Contains(t, text, "Invocation blocked")
updates.AssertNotCalled(t, "UpdateContainer")
// Read subactions remain available under the same policy.
updates.On("GetPendingUpdates", "").Return([]ContainerUpdateInfo{})
result, err := exec.registry.Execute(context.Background(), exec, "pulse_docker", map[string]interface{}{"action": "updates"})
require.NoError(t, err)
require.NotEmpty(t, result.Content)
assert.NotContains(t, result.Content[0].Text, "Invocation blocked")
}
func TestFabricatedEnumValuesFailClosedAtRuntime(t *testing.T) {
exec := newInvocationPolicyExecutor(t)
exec.SetControlLevel(ControlLevelAutonomous)
exec.SetDenyInfrastructureMutations(true)
// trigger_update is not in pulse_docker's schema enum; a fabricated
// call must classify fail-closed as an infrastructure mutation.
text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{"action": "trigger_update"})
assert.Contains(t, text, "Invocation blocked")
// A missing required discriminator fails closed the same way.
text = executeBlockedText(t, exec, "pulse_kubernetes", nil)
assert.Contains(t, text, "Invocation blocked")
}
func TestProjectionAndRuntimeEnforcementAgree(t *testing.T) {
exec := newInvocationPolicyExecutor(t)
exec.SetControlLevel(ControlLevelReadOnly)
policy := exec.invocationPolicy()
for _, tool := range exec.registry.ListTools(policy) {
descriptor, ok := agentcapabilities.InvocationDescriptorFor(tool.Name)
require.True(t, ok, "offered tool %q must have a canonical descriptor", tool.Name)
if descriptor.Static != nil {
assert.True(t, policy.Allows(*descriptor.Static), "offered static tool %q must be executable", tool.Name)
continue
}
property, ok := tool.InputSchema.Properties[descriptor.Discriminator]
require.True(t, ok)
require.NotEmpty(t, property.Enum, "offered mixed tool %q must keep at least one enum value", tool.Name)
for _, value := range property.Enum {
class := descriptor.Classify(map[string]interface{}{descriptor.Discriminator: value})
assert.True(t, policy.Allows(class),
"tool %q offers enum %q that runtime enforcement would block", tool.Name, value)
}
}
// The forbidden Docker and Kubernetes subactions must not be offered
// at read-only, while their read subactions remain.
byName := map[string][]string{}
for _, tool := range exec.registry.ListTools(policy) {
if d, ok := agentcapabilities.InvocationDescriptorFor(tool.Name); ok && d.Discriminator != "" {
byName[tool.Name] = tool.InputSchema.Properties[d.Discriminator].Enum
}
}
assert.NotContains(t, byName["pulse_docker"], "update")
assert.NotContains(t, byName["pulse_docker"], "control")
assert.Contains(t, byName["pulse_docker"], "updates")
assert.NotContains(t, byName["pulse_kubernetes"], "scale")
assert.NotContains(t, byName["pulse_kubernetes"], "exec")
assert.Contains(t, byName["pulse_kubernetes"], "pods")
}
func TestExecutorClonesKeepRequestPoliciesIsolated(t *testing.T) {
original := newInvocationPolicyExecutor(t)
original.SetControlLevel(ControlLevelAutonomous)
clone := original.Clone()
clone.SetDenyInfrastructureMutations(true)
assert.False(t, original.invocationPolicy().DenyInfrastructureMutations,
"restricting a clone must not restrict the original")
assert.True(t, clone.invocationPolicy().DenyInfrastructureMutations)
// And the restriction survives further cloning of the restricted clone.
assert.True(t, clone.Clone().invocationPolicy().DenyInfrastructureMutations)
}

View file

@ -2,6 +2,7 @@ package tools
import (
"context"
"fmt"
"sync"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
@ -61,6 +62,14 @@ type RegisteredTool struct {
Handler ToolHandler
RequireControl bool // If true, only available when control level is not read_only
Governance ToolGovernance
// Invocation optionally overrides the canonical invocation
// descriptor. Canonical Pulse tools must leave it nil (their
// descriptor comes from the shared agentcapabilities table); it
// exists for test doubles and extension tools that are not part of
// the canonical manifest. Register resolves and stores the
// effective descriptor, so execution and projection always consult
// the same classification the registration validated.
Invocation *agentcapabilities.InvocationDescriptor
}
// ToolRegistry manages tool registration and execution
@ -78,57 +87,201 @@ func NewToolRegistry() *ToolRegistry {
}
}
// Register adds a tool to the registry
// InvocationPolicy is the request-scoped safety policy the registry
// enforces on every invocation: the session control level plus an
// optional deny-infrastructure-mutations restriction (e.g. Patrol
// investigations). It is core-owned, never serialized, and consulted by
// both provider projection and runtime execution so the offered schema
// and the enforcement boundary can never disagree.
type InvocationPolicy struct {
ControlLevel ControlLevel
DenyInfrastructureMutations bool
}
// Allows reports whether the policy permits an invocation class.
// Infrastructure mutations require a control level that allows control
// tools and are always blocked under the deny restriction; pulse-state
// and non-mutating invocations are not control-gated here (handlers keep
// their own defense-in-depth checks).
func (p InvocationPolicy) Allows(class agentcapabilities.InvocationClass) bool {
if class.Mutation != agentcapabilities.MutationInfrastructure {
return true
}
if p.DenyInfrastructureMutations {
return false
}
return agentcapabilities.ControlLevelAllowsControlTools(p.ControlLevel)
}
// Register adds a tool to the registry. Every registered tool must have a
// canonical invocation descriptor whose cases exactly cover the schema
// enum of its discriminator; a tool that cannot be classified must not be
// registerable, so this panics on programmer error rather than degrading
// to an unclassified (and therefore ungovernable) tool.
func (r *ToolRegistry) Register(tool RegisteredTool) {
r.mu.Lock()
defer r.mu.Unlock()
tool.Definition = tool.Definition.NormalizeCollections()
name := tool.Definition.Name
descriptor := agentcapabilities.InvocationDescriptor{}
if tool.Invocation != nil {
descriptor = *tool.Invocation
} else {
canonical, ok := agentcapabilities.InvocationDescriptorFor(name)
if !ok {
panic(fmt.Sprintf("tool %q has no canonical invocation descriptor; declare one in agentcapabilities/invocation.go", name))
}
descriptor = canonical
}
if err := descriptor.Validate(name, discriminatorEnum(tool.Definition, descriptor.Discriminator)); err != nil {
panic(err.Error())
}
tool.Invocation = &descriptor
if _, exists := r.tools[name]; !exists {
r.order = append(r.order, name)
}
r.tools[name] = tool
}
// ListTools returns all tools available for the given control level
func (r *ToolRegistry) ListTools(controlLevel ControlLevel) []Tool {
// StaticInvocation builds a static invocation descriptor. Convenience for
// extension and test tool registrations that are not part of the
// canonical descriptor table.
func StaticInvocation(kind agentcapabilities.ToolCallKind, mutation agentcapabilities.MutationTarget) *agentcapabilities.InvocationDescriptor {
class := agentcapabilities.InvocationClass{Kind: kind, Mutation: mutation}
return &agentcapabilities.InvocationDescriptor{Static: &class}
}
// discriminatorEnum returns the schema enum for the descriptor's
// discriminator property, or nil for static descriptors.
func discriminatorEnum(definition Tool, discriminator string) []string {
if discriminator == "" {
return nil
}
property, ok := definition.InputSchema.Properties[discriminator]
if !ok {
return nil
}
return property.Enum
}
// ListTools returns all tools available under the given invocation
// policy. Mixed tools whose discriminator has forbidden subactions are
// offered with those enum values removed; tools with no permitted
// invocation are dropped entirely. The same descriptor drives runtime
// enforcement in Execute, so the offered schema and the enforcement
// boundary always agree.
func (r *ToolRegistry) ListTools(policy InvocationPolicy) []Tool {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]Tool, 0, len(r.tools))
for _, name := range r.order {
tool := r.tools[name]
// Skip control tools if in read-only mode
if tool.RequireControl && !agentcapabilities.ControlLevelAllowsControlTools(controlLevel) {
// Legacy tool-level gate, kept as defense in depth.
if tool.RequireControl && !agentcapabilities.ControlLevelAllowsControlTools(policy.ControlLevel) {
continue
}
result = append(result, tool.Definition.NormalizeCollections())
projected, ok := projectToolForPolicy(tool, policy)
if !ok {
continue
}
result = append(result, projected.Definition.NormalizeCollections())
}
return result
}
// ListToolGovernance returns the governed tool manifest available at a control level.
func (r *ToolRegistry) ListToolGovernance(controlLevel ControlLevel) []ToolGovernanceDescriptor {
// ListToolGovernance returns the governed tool manifest available under
// the given invocation policy, with each tool's action mode recomputed
// from the subactions the policy actually offers.
func (r *ToolRegistry) ListToolGovernance(policy InvocationPolicy) []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 && !agentcapabilities.ControlLevelAllowsControlTools(controlLevel) {
if tool.RequireControl && !agentcapabilities.ControlLevelAllowsControlTools(policy.ControlLevel) {
continue
}
projected, ok := projectToolForPolicy(tool, policy)
if !ok {
continue
}
result = append(result, agentcapabilities.NewToolGovernanceDescriptor(
tool.Definition.Name,
tool.Definition.Description,
tool.RequireControl,
tool.Governance,
projected.Definition.Name,
projected.Definition.Description,
projected.RequireControl,
projected.Governance,
))
}
return result
}
// projectToolForPolicy filters one registered tool against the policy.
// Static tools pass or drop whole; discriminator-based tools are offered
// with forbidden enum values removed and their governance action mode
// recomputed from what remains. Returns false when no invocation of the
// tool is permitted.
func projectToolForPolicy(tool RegisteredTool, policy InvocationPolicy) (RegisteredTool, bool) {
if tool.Invocation == nil {
// Unregisterable in practice (Register resolves and stores the
// descriptor), but fail closed.
return RegisteredTool{}, false
}
descriptor := *tool.Invocation
if descriptor.Static != nil {
if !policy.Allows(*descriptor.Static) {
return RegisteredTool{}, false
}
return tool, true
}
property, ok := tool.Definition.InputSchema.Properties[descriptor.Discriminator]
if !ok {
return RegisteredTool{}, false
}
allowed := make([]string, 0, len(property.Enum))
sawWrite := false
sawRead := false
for _, value := range property.Enum {
class := descriptor.Classify(map[string]interface{}{descriptor.Discriminator: value})
if !policy.Allows(class) {
continue
}
allowed = append(allowed, value)
if class.Kind == agentcapabilities.ToolCallKindWrite {
sawWrite = true
} else {
sawRead = true
}
}
if len(allowed) == 0 {
return RegisteredTool{}, false
}
if len(allowed) == len(property.Enum) {
return tool, true
}
projected := tool
projected.Definition.InputSchema.Properties = make(map[string]PropertySchema, len(tool.Definition.InputSchema.Properties))
for key, value := range tool.Definition.InputSchema.Properties {
projected.Definition.InputSchema.Properties[key] = value
}
property.Enum = allowed
projected.Definition.InputSchema.Properties[descriptor.Discriminator] = property
switch {
case sawWrite && sawRead:
projected.Governance.ActionMode = agentcapabilities.ActionModeMixed
case sawWrite:
projected.Governance.ActionMode = agentcapabilities.ActionModeWrite
default:
projected.Governance.ActionMode = agentcapabilities.ActionModeRead
}
return projected, true
}
// allNames returns the canonical list of registered tool names in
// registration order. Internal helper for KnownToolNames.
func (r *ToolRegistry) allNames() []string {
@ -156,7 +309,24 @@ func (r *ToolRegistry) Execute(ctx context.Context, e *PulseToolExecutor, name s
return agentcapabilities.NewUnknownToolResult(name), nil
}
// Centralized control level check
// Invocation-level policy enforcement, before the handler runs.
// The classification fails closed (missing/unknown discriminators
// count as infrastructure writes), so a fabricated or hidden enum
// value can never reach a handler under a policy that forbids it.
class := agentcapabilities.FailClosedInvocationClass()
if tool.Invocation != nil {
class = tool.Invocation.Classify(args)
}
if class.Mutation == agentcapabilities.MutationInfrastructure {
if e.invocationPolicy().DenyInfrastructureMutations {
return agentcapabilities.NewInvocationBlockedToolResult(name, class), nil
}
if !agentcapabilities.ControlLevelAllowsControlTools(e.controlLevel) {
return agentcapabilities.NewControlToolsDisabledToolResult(), nil
}
}
// Legacy tool-level control check, kept as defense in depth.
if tool.RequireControl {
if !agentcapabilities.ControlLevelAllowsControlTools(e.controlLevel) {
return agentcapabilities.NewControlToolsDisabledToolResult(), nil

View file

@ -36,13 +36,14 @@ func (e *PulseToolExecutor) registerFileTools() {
e.registry.Register(RegisteredTool{
Definition: Tool{
Name: agentcapabilities.PulseFileEditToolName,
Description: `Read and edit files on remote hosts, containers, VMs, and Docker containers safely.
Description: `Edit files on remote hosts, containers, VMs, and Docker containers safely.
Actions:
- read: Read the contents of a file
- append: Append content to the end of a file
- write: Write/overwrite a file with new content (creates if doesn't exist)
To READ a file, use pulse_read with action="file" instead; this tool is write-only.
This tool handles escaping automatically - just provide the content as-is.
Use this instead of shell commands for editing config files (YAML, JSON, etc.)
@ -51,17 +52,15 @@ Routing: target_host can be a node (pve-node), a container name (homepage-docker
Docker container support: Use container to access files INSIDE a Docker container. The target_host specifies where Docker runs.
Examples:
- Read from container: action="read", path="/opt/app/config.yaml", target_host="homepage-docker"
- Write to host: action="write", path="/tmp/test.txt", content="hello", target_host="pve-node"
- Read from Docker: action="read", path="/config/settings.json", target_host="tower", container="jellyfin"
- Write to Docker: action="write", path="/tmp/test.txt", content="hello", target_host="tower", container="nginx"`,
InputSchema: InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
"action": {
Type: "string",
Description: "File action: read, append, or write",
Enum: []string{"read", "append", "write"},
Description: "File action: append or write",
Enum: []string{"append", "write"},
},
"path": {
Type: "string",
@ -128,14 +127,15 @@ func (e *PulseToolExecutor) executeFileEdit(ctx context.Context, args map[string
}
}
// Check control level
if e.controlLevel == ControlLevelReadOnly && action != "read" {
// Check control level (defense in depth; the registry blocks
// infrastructure mutations before this handler runs).
if e.controlLevel == ControlLevelReadOnly {
return NewTextResult("File editing is not available in read-only mode."), nil
}
switch action {
case "read":
return e.executeFileRead(ctx, path, targetHost, dockerContainer)
return NewErrorResult(fmt.Errorf("pulse_file_edit is write-only; read files with pulse_read action=\"file\"")), nil
case "append":
if content == "" {
return NewErrorResult(fmt.Errorf("content is required for append action")), nil
@ -147,7 +147,7 @@ func (e *PulseToolExecutor) executeFileEdit(ctx context.Context, args map[string
}
return e.executeFileWrite(ctx, path, content, targetHost, dockerContainer, args)
default:
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: read, append, write", action)), nil
return NewErrorResult(fmt.Errorf("unknown action: %s. Use: append, write", action)), nil
}
}

View file

@ -14,7 +14,7 @@ import (
func TestFileTools_Registry(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
exec.registerFileTools()
tools := exec.registry.ListTools(ControlLevelControlled)
tools := exec.registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelControlled})
found := false
for _, tool := range tools {

View file

@ -684,7 +684,7 @@ func TestPatrolToolsRegistered(t *testing.T) {
exec := NewPulseToolExecutor(ExecutorConfig{})
// Patrol tools should be registered but availability depends on patrolFindingCreator
tools := exec.registry.ListTools(ControlLevelReadOnly)
tools := exec.registry.ListTools(InvocationPolicy{ControlLevel: ControlLevelReadOnly})
found := map[string]bool{}
var resolveTool Tool
for _, tool := range tools {

View file

@ -44,7 +44,7 @@ func TestSummarizeTool_RegisteredAndDiscoverable(t *testing.T) {
exec, cleanup := newSummarizeTestEnvironment(t)
defer cleanup()
tools := exec.registry.ListTools("")
tools := exec.registry.ListTools(InvocationPolicy{})
var found bool
for _, tool := range tools {
if tool.Name == agentcapabilities.PulseSummarizeToolName {

View file

@ -17403,10 +17403,12 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
`ToolCallKindUserInput`,
`func (k ToolCallKind) String() string`,
`func ClassifyToolCall(toolName string, args map[string]interface{}) ToolCallKind`,
// Registry tools classify through the canonical invocation
// descriptors; the switch keeps only non-registry names.
`if descriptor, ok := InvocationDescriptorFor(toolName); ok {`,
`return descriptor.Classify(args).Kind`,
`case PulseQuestionToolName:`,
`case PulseQueryToolName, PulseDiscoveryToolName:`,
`case PulseControlToolName:`,
`case PulseReadToolName, LegacyAssistantFetchURLToolName:`,
`case LegacyAssistantFetchURLToolName:`,
`LegacyAssistantRunCommandToolName, LegacyAssistantSetResourceURLToolName`,
`return ToolCallKindWrite`,
} {
@ -18548,15 +18550,22 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
`ControlLevelReadOnly ControlLevel = agentcapabilities.ControlLevelReadOnly`,
`ControlLevelControlled ControlLevel = agentcapabilities.ControlLevelControlled`,
`ControlLevelAutonomous ControlLevel = agentcapabilities.ControlLevelAutonomous`,
`!agentcapabilities.ControlLevelAllowsControlTools(controlLevel)`,
`!agentcapabilities.ControlLevelAllowsControlTools(policy.ControlLevel)`,
`!agentcapabilities.ControlLevelAllowsControlTools(e.controlLevel)`,
`agentcapabilities.NewToolGovernanceDescriptor(`,
`tool.Definition = tool.Definition.NormalizeCollections()`,
`result = append(result, tool.Definition.NormalizeCollections())`,
`result = append(result, projected.Definition.NormalizeCollections())`,
`params, invalidResult, ok := agentcapabilities.PrepareToolRegistryExecution(name, args)`,
`agentcapabilities.NewUnknownToolResult(name)`,
`agentcapabilities.NewControlToolsDisabledToolResult()`,
`return result.NormalizeCollections(), err`,
// Invocation-level enforcement runs before the handler and
// consumes the same descriptor the projection filters with.
`class = tool.Invocation.Classify(args)`,
`if class.Mutation == agentcapabilities.MutationInfrastructure {`,
`agentcapabilities.NewInvocationBlockedToolResult(name, class)`,
`descriptor.Validate(name, discriminatorEnum(tool.Definition, descriptor.Discriminator))`,
`func projectToolForPolicy(tool RegisteredTool, policy InvocationPolicy) (RegisteredTool, bool)`,
} {
if !strings.Contains(registrySrc, fragment) {
t.Errorf("Assistant tool registry must use shared agentcapabilities contracts; missing %s", fragment)

View file

@ -2917,7 +2917,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1202,
"line": 1203,
"heading_line": 141,
}
],
@ -4246,7 +4246,7 @@ class SubsystemLookupTest(unittest.TestCase):
),
_contract_reference(
"docs/release-control/v6/internal/subsystems/ai-runtime.md",
"35. `internal/api/ai_handler.go` shared with `api-contracts`",
"36. `internal/api/ai_handler.go` shared with `api-contracts`",
"internal/api/ai_handler.go",
),
_contract_reference(
@ -4258,7 +4258,7 @@ class SubsystemLookupTest(unittest.TestCase):
api_contracts_expected = [
_contract_reference(
"docs/release-control/v6/internal/subsystems/api-contracts.md",
"63. `internal/api/ai_handler.go` shared with `ai-runtime`",
"64. `internal/api/ai_handler.go` shared with `ai-runtime`",
"internal/api/ai_handler.go",
),
_contract_reference(
@ -4302,7 +4302,7 @@ class SubsystemLookupTest(unittest.TestCase):
[
_contract_reference(
"docs/release-control/v6/internal/subsystems/api-contracts.md",
"63. `internal/api/ai_handler.go` shared with `ai-runtime`",
"64. `internal/api/ai_handler.go` shared with `ai-runtime`",
"internal/api/ai_handler.go",
)["line"],
_contract_reference(
@ -4322,8 +4322,8 @@ class SubsystemLookupTest(unittest.TestCase):
rendered = render_pretty(lookup_paths(["internal/api/ai_handler.go"], lean=True))
self.assertIn(
"contract focus: "
f"{_contract_reference('docs/release-control/v6/internal/subsystems/api-contracts.md', '63. `internal/api/ai_handler.go` shared with `ai-runtime`', 'internal/api/ai_handler.go')['heading']} "
f"@L{_contract_reference('docs/release-control/v6/internal/subsystems/api-contracts.md', '63. `internal/api/ai_handler.go` shared with `ai-runtime`', 'internal/api/ai_handler.go')['line']}: "
f"{_contract_reference('docs/release-control/v6/internal/subsystems/api-contracts.md', '64. `internal/api/ai_handler.go` shared with `ai-runtime`', 'internal/api/ai_handler.go')['heading']} "
f"@L{_contract_reference('docs/release-control/v6/internal/subsystems/api-contracts.md', '64. `internal/api/ai_handler.go` shared with `ai-runtime`', 'internal/api/ai_handler.go')['line']}: "
"internal/api/ai_handler.go",
rendered,
)