fix: close chat command authority boundary

This commit is contained in:
rcourtman
2026-07-11 11:54:56 +01:00
parent 91b10b19b5
commit 4edf5f8265
32 changed files with 639 additions and 119 deletions
@@ -3963,6 +3963,21 @@
],
"release_gate_ids": [],
"proof_commands": [
{
"id": "ra35-chat-authority-boundary",
"run": [
"go",
"test",
"./internal/agentcapabilities",
"./internal/agentexec",
"./internal/ai",
"./internal/ai/tools",
"./internal/api",
"-run",
"TestParseProviderToolInputRejectsInternalApprovalMetadata|TestValidateDeclaredToolArgumentsRejectsUnknownProviderProperties|TestExecuteCommand_RoundTripViaWebSocket|TestExecuteCommand_InvalidApprovalAuthorizationNeverMintsOrDispatches|TestService_ExecuteStream_AutonomousRawCommandRequiresApproval|TestPulseToolExecutor_ExecuteRunCommand|TestHandleChat_CannotElevateAutonomousModePerRequest|TestVerifyAndConsumeCommandAuthorization",
"-count=1"
]
},
{
"id": "ra35-typed-host-storage-cleanup-vertical",
"run": [
@@ -4014,6 +4029,26 @@
}
],
"evidence": [
{
"repo": "pulse",
"path": "internal/agentcapabilities/schema.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/agentcapabilities/schema_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/agentexec/server.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/agentexec/server_websocket_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/agentexec/types.go",
@@ -4024,6 +4059,11 @@
"path": "internal/ai/chat/service_investigation.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/tools/control_run_command_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/tools/execution_profile.go",
@@ -4044,6 +4084,16 @@
"path": "internal/ai/tools/tools_propose.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/agent_command_authorization.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/agent_command_authorization_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/host_storage_cleanup_action_executor.go",
@@ -1276,6 +1276,12 @@ the intentionally sparse public response.
Approval-gated command execution must expose stable rejection reasons for
invalid approval grants so fleet operators can distinguish missing, expired,
mismatched, and signature-invalid grants through agent metrics.
The server must not mint that grant from a nonempty approval id alone.
`internal/agentexec` accepts approval-gated arbitrary commands only with a
non-serializable server-owned authorization context and a verifier that
consumes an org/action/command/target-bound approval before signing. A
missing, wrong-org, wrong-action, expired, or already-consumed approval
stops before grant minting and before any WebSocket command frame.
10. Preserve canonical token-lifecycle reads in shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows do not revoke a displayed relay pairing token after `lastUsedAt` proves that an already paired device is actively depending on that credential.
11. Preserve backend-owned Pulse Mobile relay runtime credential minting in those same shared `internal/api/` auth/security helpers so lifecycle-adjacent setup and install flows reuse the canonical mobile token route instead of reintroducing wildcard or browser-authored runtime token bundles.
12. Preserve the dedicated backend-owned `relay:mobile:access` capability and its governed backward-compatible route inventory plus the shared helper call sites around it, so lifecycle-adjacent setup and install flows do not widen the mobile device credential back into general AI chat/execute scope ownership.
@@ -280,6 +280,7 @@ call when building tool-result turns.
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.
Provider projection is closed by default (`additionalProperties:false`), provider-emitted internal approval metadata is rejected before registry execution, and the registry rejects undeclared public arguments before a handler runs. Interactive approved replays may carry only the non-serializable server-attached approval field. Public Assistant chat always installs an approval-gated request posture; caller JSON cannot promote the service to autonomous infrastructure authority, and autonomous model sessions cannot enter either the canonical or legacy raw-command executor.
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.
@@ -1189,6 +1189,7 @@ payload shape change when the portal presents compact client rows.
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.
Provider-facing schemas are closed objects, provider-originated internal approval fields and undeclared properties fail closed, and `/api/ai/chat` does not expose request-owned autonomous authority. `internal/api/agent_command_authorization.go` is the server-owned bridge from an approved interactive action to agentexec: it validates org and action identity and atomically consumes the exact command/target approval immediately before grant signing and WebSocket dispatch.
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.
@@ -218,6 +218,10 @@ regression protection.
restore path must write the URL once with `replace` and must not force row
filtering through a separate page-local state channel.
4. Keep shared auth gating in `internal/api/router.go` cheap and local: pre-auth quick-setup and recovery routing may short-circuit on loopback/session/token checks, but they must not trigger chart, metrics, or broad persistence fan-out on the protected request hot path.
Agent command authorization is likewise a dispatch-time point lookup and
atomic approval consume, not a route-wide scan or request-hot-path fan-out;
grant signing and WebSocket writes happen only after that bounded verifier
succeeds.
Scheduled-report background worker registration is allowed in router startup,
but it must stay outside protected request handling. Due-schedule scans may
enumerate tenant organization IDs and load each workspace schedule store, but
@@ -327,8 +327,12 @@ the `white_label` branding entitlement.
`RequireAuth` and scope checks, resource-policy redaction pass, or
read-only Agent-context boundary. Router glue may connect providers, but
it must not become an alternate command path, raw provider-command path,
config path, environment path, or secret-bearing metadata path. The Patrol
action-broker and proposal-catalog factory glue wired here is bound by the
config path, environment path, or secret-bearing metadata path.
The command-authorization bridge wired by `internal/api/router.go` preserves
that rule: public chat and relay input cannot serialize its org/action
authorization context, and invalid approvals fail before signing or agent
dispatch rather than falling through to a route-local trust shortcut.
The Patrol action-broker and proposal-catalog factory glue wired here is bound by the
same rule: it may connect the investigation orchestrator to the tenant-bound
action lifecycle, but it exposes only typed-proposal capture and gives the
orchestrator no autonomy control, command execution, or command-shaped
@@ -1168,6 +1168,11 @@ recovery scope, or a storage/recovery-owned secret source.
liveness context unless a separate storage/recovery-owned relationship ties
it to backup or restore evidence.
33. Keep infrastructure summary chart bucketing and short response caching presentation-only on the adjacent shared API boundary. When `internal/api/router.go` normalizes mixed-cadence infrastructure history into equal-time summary buckets or serves a cached summary payload for repeated operator-facing summary-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those normalized chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
The same router may wire the adjacent server-owned agent command
authorization verifier, but storage/recovery must not treat an approval id,
signed command grant, or command result as restore authorization or
recovery evidence; that authority remains action-governance and
agent-lifecycle owned.
34. Keep workload chart downsampling and short response caching presentation-only on that same adjacent shared API boundary. When `internal/api/router.go` caps mixed-cadence workload history into equal-time buckets or serves a cached workload-summary payload for repeated operator-facing workload-card requests, storage and recovery may consume the resulting visual context only; they must not reinterpret those shaped chart samples, cached timestamps, or cache hits as recovery freshness windows, backup cadence, or restore evidence.
The same adjacent chart boundary now covers compact storage capacity
transport. `internal/api/router.go` may batch only the canonical `used`
+31 -2
View File
@@ -579,6 +579,11 @@ func ParseProviderToolInput(raw string) (map[string]interface{}, bool) {
if err := json.Unmarshal([]byte(raw), &input); err != nil || input == nil {
return nil, false
}
for name := range input {
if IsInternalToolArgument(name) {
return nil, false
}
}
return input, true
}
@@ -732,8 +737,9 @@ func StrictObjectInputSchema(required []string, properties map[string]any) json.
func ProviderInputSchema(schema InputSchema) map[string]interface{} {
schema = schema.NormalizeCollections()
projected := map[string]interface{}{
"type": "object",
"properties": ProviderPropertySchemas(schema.Properties),
"type": "object",
"properties": ProviderPropertySchemas(schema.Properties),
"additionalProperties": false,
}
if len(schema.Required) > 0 {
@@ -743,6 +749,29 @@ func ProviderInputSchema(schema InputSchema) map[string]interface{} {
return projected
}
// ValidateDeclaredToolArguments rejects provider/adapter arguments that are
// not part of the registered tool schema. Internal runtime metadata is
// validated at the provider parse boundary and remains admissible here only so
// the server can replay an operator-approved call without widening the public
// schema.
func ValidateDeclaredToolArguments(schema InputSchema, args map[string]any) error {
schema = schema.NormalizeCollections()
for name := range args {
if IsInternalToolArgument(name) {
continue
}
if _, declared := schema.Properties[name]; !declared {
return fmt.Errorf("undeclared tool argument %q", name)
}
}
for _, name := range schema.Required {
if _, present := args[name]; !present {
return fmt.Errorf("required tool argument %q is missing", name)
}
}
return nil
}
// ProviderInputSchemaFromRaw projects a manifest-authored JSON Schema into the
// provider-facing schema map used by Assistant provider tools. Manifest schemas
// are already JSON Schema object envelopes; this helper keeps legacy Assistant
+30
View File
@@ -70,6 +70,9 @@ func TestProviderInputSchemaProjectsStructuredToolSchema(t *testing.T) {
if projected["type"] != "object" {
t.Fatalf("projected type = %v, want object", projected["type"])
}
if projected["additionalProperties"] != false {
t.Fatalf("provider schema additionalProperties = %v, want false", projected["additionalProperties"])
}
props, ok := projected["properties"].(map[string]interface{})
if !ok {
@@ -105,6 +108,33 @@ func TestProviderInputSchemaProjectsStructuredToolSchema(t *testing.T) {
}
}
func TestParseProviderToolInputRejectsInternalApprovalMetadata(t *testing.T) {
if input, ok := ParseProviderToolInput(`{"command":"uptime","_approval_id":"fabricated"}`); ok || input != nil {
t.Fatalf("provider input with internal metadata = %#v, ok=%v; want rejected", input, ok)
}
}
func TestValidateDeclaredToolArgumentsRejectsUnknownProviderProperties(t *testing.T) {
schema := InputSchema{
Type: "object",
Properties: map[string]PropertySchema{
"command": {Type: "string"},
},
Required: []string{"command"},
}
if err := ValidateDeclaredToolArguments(schema, map[string]any{
"command": "uptime",
"trusted": true,
}); err == nil || !strings.Contains(err.Error(), "undeclared tool argument") {
t.Fatalf("unknown provider argument error = %v, want undeclared tool argument", err)
}
if err := ValidateDeclaredToolArguments(schema, WithApprovalArgument(map[string]any{
"command": "uptime",
}, "server-owned")); err != nil {
t.Fatalf("server-owned internal approval metadata must remain valid: %v", err)
}
}
func TestProjectProviderToolsProjectsRegistryTools(t *testing.T) {
tools := []Tool{
{
+64 -15
View File
@@ -59,19 +59,33 @@ var hostStorageCleanupFingerprintPattern = regexp.MustCompile(`^sha256:[a-f0-9]{
// Server manages WebSocket connections from agents
type Server struct {
mu sync.RWMutex
agents map[string]*agentConn // agentID -> connection
pendingReqs map[string]chan CommandResultPayload // scoped request key -> response channel
pendingHostStorageCleanups map[string]chan HostStorageCleanupResultPayload // scoped request key -> typed storage-cleanup response
pendingHostUpdates map[string]chan HostUpdateResultPayload // scoped request key -> typed host-update response
deploySubs map[string]chan DeployProgressPayload // deploySubKey(agentID, jobID) -> progress subscriber
validateToken func(token string, agentID string, hostname string) bool
commandPolicy *CommandPolicy
ipConnCounts map[string]int
maxConnsPerIP int
shutdown chan struct{}
shutdownOnce sync.Once
pingInterval time.Duration
mu sync.RWMutex
agents map[string]*agentConn // agentID -> connection
pendingReqs map[string]chan CommandResultPayload // scoped request key -> response channel
pendingHostStorageCleanups map[string]chan HostStorageCleanupResultPayload // scoped request key -> typed storage-cleanup response
pendingHostUpdates map[string]chan HostUpdateResultPayload // scoped request key -> typed host-update response
deploySubs map[string]chan DeployProgressPayload // deploySubKey(agentID, jobID) -> progress subscriber
validateToken func(token string, agentID string, hostname string) bool
commandPolicy *CommandPolicy
ipConnCounts map[string]int
maxConnsPerIP int
shutdown chan struct{}
shutdownOnce sync.Once
pingInterval time.Duration
commandAuthorizationVerifier func(CommandAuthorizationRequest) error
newCommandApprovalGrant func([]byte, string, ExecuteCommandPayload, time.Time, time.Duration) (*CommandApprovalGrant, error)
}
// CommandAuthorizationRequest is the complete server-side approval scope
// verified and consumed immediately before an approval grant is signed.
type CommandAuthorizationRequest struct {
ApprovalID string
OrgID string
ActionID string
AgentID string
Command string
TargetType string
TargetID string
}
type agentConn struct {
@@ -121,9 +135,19 @@ func NewServer(validateToken func(token string, agentID string, hostname string)
maxConnsPerIP: defaultMaxWebSocketConnectionsPerIP,
shutdown: make(chan struct{}),
pingInterval: defaultPingInterval,
newCommandApprovalGrant: NewCommandApprovalGrant,
}
}
// SetCommandAuthorizationVerifier installs the server-owned authorization
// consumer used for approval-gated arbitrary commands.
func (s *Server) SetCommandAuthorizationVerifier(verifier func(CommandAuthorizationRequest) error) {
if s == nil {
return
}
s.commandAuthorizationVerifier = verifier
}
func (s *Server) isShuttingDown() bool {
select {
case <-s.shutdown:
@@ -216,6 +240,12 @@ func (s *Server) authorizeCommandPayload(cmd ExecuteCommandPayload) error {
if cmd.ApprovalID == "" {
return fmt.Errorf("command requires approval")
}
if cmd.authorization == nil || strings.TrimSpace(cmd.authorization.ActionID) == "" {
return fmt.Errorf("command requires server-owned approval authorization")
}
if s.commandAuthorizationVerifier == nil {
return fmt.Errorf("command approval authorization verifier is unavailable")
}
}
return nil
@@ -1067,8 +1097,27 @@ func (s *Server) ExecuteCommand(ctx context.Context, agentID string, cmd Execute
if err := s.authorizeCommandPayload(cmd); err != nil {
return nil, err
}
if !cmd.Trusted && s.commandPolicy != nil && s.commandPolicy.Evaluate(cmd.Command) == PolicyRequireApproval && cmd.ApprovalGrant == nil && len(ac.approvalGrantKey) > 0 {
grant, grantErr := NewCommandApprovalGrant(ac.approvalGrantKey, agentID, cmd, time.Now(), DefaultApprovalGrantTTL)
requiresApproval := !cmd.Trusted && s.commandPolicy != nil && s.commandPolicy.Evaluate(cmd.Command) == PolicyRequireApproval
if requiresApproval {
if len(ac.approvalGrantKey) == 0 {
return nil, fmt.Errorf("command approval grant signer is unavailable")
}
auth := cmd.authorization
if err := s.commandAuthorizationVerifier(CommandAuthorizationRequest{
ApprovalID: cmd.ApprovalID,
OrgID: auth.OrgID,
ActionID: auth.ActionID,
AgentID: agentID,
Command: cmd.Command,
TargetType: cmd.TargetType,
TargetID: cmd.TargetID,
}); err != nil {
return nil, fmt.Errorf("command approval authorization rejected: %w", err)
}
// Approval grants are an internal transport credential. Never accept a
// caller-supplied grant, even if it happens to be structurally valid.
grant, grantErr := s.newCommandApprovalGrant(ac.approvalGrantKey, agentID, cmd, time.Now(), DefaultApprovalGrantTTL)
if grantErr != nil {
return nil, fmt.Errorf("failed to issue approval grant: %w", grantErr)
}
+12 -12
View File
@@ -519,10 +519,10 @@ func TestExecuteCommandTimeoutAndCancel(t *testing.T) {
s.mu.Unlock()
_, err := s.ExecuteCommand(context.Background(), "a1", ExecuteCommandPayload{
RequestID: "r-timeout",
Command: "echo ok",
ApprovalID: "approval-1",
Timeout: 1,
RequestID: "r-timeout",
Command: "echo ok",
Trusted: true,
Timeout: 1,
})
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("expected timeout error, got %v", err)
@@ -531,10 +531,10 @@ func TestExecuteCommandTimeoutAndCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = s.ExecuteCommand(ctx, "a1", ExecuteCommandPayload{
RequestID: "r-cancel",
Command: "echo ok",
ApprovalID: "approval-1",
Timeout: 1,
RequestID: "r-cancel",
Command: "echo ok",
Trusted: true,
Timeout: 1,
})
if err == nil {
t.Fatalf("expected cancel error")
@@ -569,9 +569,9 @@ func TestExecuteCommandDefaultTimeout(t *testing.T) {
}()
result, err := s.ExecuteCommand(context.Background(), "a1", ExecuteCommandPayload{
RequestID: "r-default",
Command: "echo ok",
ApprovalID: "approval-1",
RequestID: "r-default",
Command: "echo ok",
Trusted: true,
})
if err != nil || result == nil || !result.Success {
t.Fatalf("expected success, got result=%v err=%v", result, err)
@@ -750,7 +750,7 @@ func TestExecuteCommandAndReadFileReturnShutdownError(t *testing.T) {
errCh := make(chan error, 1)
go func() {
_, err := s.ExecuteCommand(context.Background(), "a1", ExecuteCommandPayload{RequestID: "r-shutdown", Command: "echo test", ApprovalID: "approval-1", Timeout: 60})
_, err := s.ExecuteCommand(context.Background(), "a1", ExecuteCommandPayload{RequestID: "r-shutdown", Command: "echo test", Trusted: true, Timeout: 60})
errCh <- err
}()
+70 -6
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -364,6 +365,13 @@ func TestHandleWebSocket_AgentPingRespondsWithPong(t *testing.T) {
func TestExecuteCommand_RoundTripViaWebSocket(t *testing.T) {
s := NewServer(allowAllTestTokens)
callerGrant := &CommandApprovalGrant{Signature: "caller-supplied"}
s.SetCommandAuthorizationVerifier(func(req CommandAuthorizationRequest) error {
if req.ApprovalID != "approval-1" || req.OrgID != "org-1" || req.ActionID != "action-1" {
return fmt.Errorf("authorization mismatch: %+v", req)
}
return nil
})
ts := newWSServer(t, s)
defer ts.Close()
@@ -408,6 +416,10 @@ func TestExecuteCommand_RoundTripViaWebSocket(t *testing.T) {
agentErr <- fmt.Errorf("missing approval grant")
return
}
if payload.ApprovalGrant.Signature == callerGrant.Signature {
agentErr <- fmt.Errorf("caller-supplied approval grant was forwarded")
return
}
if err := VerifyCommandApprovalGrant("any", "a1", payload, time.Now()); err != nil {
agentErr <- err
return
@@ -431,12 +443,15 @@ func TestExecuteCommand_RoundTripViaWebSocket(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
result, err := s.ExecuteCommand(ctx, "a1", ExecuteCommandPayload{
RequestID: "req1",
Command: "echo ok",
ApprovalID: "approval-1",
Timeout: 1,
})
payload := ExecuteCommandPayload{
RequestID: "req1",
Command: "echo ok",
ApprovalID: "approval-1",
ApprovalGrant: callerGrant,
Timeout: 1,
}
payload.BindCommandAuthorization("org-1", "action-1")
result, err := s.ExecuteCommand(ctx, "a1", payload)
if err != nil {
t.Fatalf("ExecuteCommand: %v", err)
}
@@ -455,6 +470,55 @@ func TestExecuteCommand_RoundTripViaWebSocket(t *testing.T) {
}
}
func TestExecuteCommand_InvalidApprovalAuthorizationNeverMintsOrDispatches(t *testing.T) {
cases := []struct {
name string
err string
}{
{name: "nonexistent", err: "approval not found"},
{name: "wrong-org", err: "approval belongs to another org"},
{name: "expired", err: "approval expired"},
{name: "consumed", err: "approval already consumed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := NewServer(allowAllTestTokens)
s.SetCommandAuthorizationVerifier(func(CommandAuthorizationRequest) error { return errors.New(tc.err) })
grantCalls := 0
s.newCommandApprovalGrant = func([]byte, string, ExecuteCommandPayload, time.Time, time.Duration) (*CommandApprovalGrant, error) {
grantCalls++
return nil, errors.New("grant must not be minted")
}
ts := newWSServer(t, s)
defer ts.Close()
conn, _, err := dialAgentExecWebSocket(t, ts.URL)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
wsWriteMessage(t, conn, mustNewMessage(t, MsgTypeAgentRegister, "", AgentRegisterPayload{
AgentID: "a1", Hostname: "host1", Version: "1.2.3", Platform: "linux", Token: "any",
}))
_ = wsReadRegisteredPayload(t, conn)
payload := ExecuteCommandPayload{
RequestID: "req-invalid", Command: "echo rejected", ApprovalID: "approval-invalid", Timeout: 1,
}
payload.BindCommandAuthorization("org-1", "action-1")
if _, err := s.ExecuteCommand(context.Background(), "a1", payload); err == nil || !strings.Contains(err.Error(), tc.err) {
t.Fatalf("ExecuteCommand error = %v, want %q", err, tc.err)
}
if grantCalls != 0 {
t.Fatalf("signed grant calls = %d, want 0", grantCalls)
}
if _, err := wsReadRawMessageWithTimeout(conn, 100*time.Millisecond); err == nil {
t.Fatal("unexpected WebSocket dispatch for rejected approval")
}
})
}
}
func TestExecuteHostUpdateRoundTripUsesTypedCommandFreeEnvelope(t *testing.T) {
inventoryHash := "sha256:" + strings.Repeat("a", 64)
emptyInventoryHash := "sha256:" + strings.Repeat("b", 64)
+21
View File
@@ -3,6 +3,7 @@ package agentexec
import (
"encoding/json"
"errors"
"strings"
"time"
)
@@ -108,6 +109,26 @@ type ExecuteCommandPayload struct {
// This field must only be set by trusted internal call sites — never
// from a deserialised HTTP body or any user-driven path.
Trusted bool `json:"trusted,omitempty"`
authorization *commandAuthorizationContext
}
type commandAuthorizationContext struct {
OrgID string
ActionID string
}
// BindCommandAuthorization attaches server-owned approval scope to a command.
// The scope is deliberately not serializable, so provider arguments, HTTP
// payloads, relay callers, and agent wire messages cannot manufacture it.
func (p *ExecuteCommandPayload) BindCommandAuthorization(orgID, actionID string) {
if p == nil {
return
}
p.authorization = &commandAuthorizationContext{
OrgID: strings.TrimSpace(orgID),
ActionID: strings.TrimSpace(actionID),
}
}
// ReadFilePayload is sent by server to request file content
+15 -4
View File
@@ -563,11 +563,22 @@ func TestToolsForExecutionMode_PatrolScopeUsesConfigNotPrompt(t *testing.T) {
}
}
func testRunCommandToolDefinition() tools.Tool {
return tools.Tool{
Name: agentcapabilities.PulseRunCommandToolName,
InputSchema: tools.InputSchema{Properties: map[string]tools.PropertySchema{
"command": {Type: "string"},
"run_on_host": {Type: "boolean"},
"target_host": {Type: "string"},
}},
}
}
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},
Definition: testRunCommandToolDefinition(),
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
},
@@ -591,7 +602,7 @@ 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},
Definition: testRunCommandToolDefinition(),
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewErrorResult(context.Canceled), nil
},
@@ -609,7 +620,7 @@ func TestExecuteCommand_ErrorAndApprovalPaths(t *testing.T) {
approvalExec := tools.NewPulseToolExecutor(tools.ExecutorConfig{})
approvalExec.RegisterTool(tools.RegisteredTool{
Invocation: tools.StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: tools.Tool{Name: agentcapabilities.PulseRunCommandToolName},
Definition: testRunCommandToolDefinition(),
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.NewTextResult("APPROVAL_REQUIRED: requires approval"), nil
},
@@ -626,7 +637,7 @@ 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},
Definition: testRunCommandToolDefinition(),
Handler: func(ctx context.Context, exec *tools.PulseToolExecutor, args map[string]interface{}) (tools.CallToolResult, error) {
return tools.CallToolResult{
Content: []tools.Content{
+17 -3
View File
@@ -2576,12 +2576,14 @@ Always execute the commands rather than telling the user how to do it.`
continue
}
if req.RequireCommandApproval || (!isAuto && policyDecision == agentexec.PolicyRequireApproval) {
if req.RequireCommandApproval || isAuto || policyDecision == agentexec.PolicyRequireApproval {
needsApproval = true
anyNeedsApproval = true
approvalReason = "Security policy requires approval"
if req.RequireCommandApproval {
approvalReason = "This handoff requires operator approval before command execution"
} else if isAuto {
approvalReason = "Autonomous model sessions cannot dispatch raw commands; operator approval is required"
}
approvalID = createRunCommandApprovalRecord(
approvalOrgID,
@@ -3132,13 +3134,16 @@ func (s *Service) executeTool(ctx context.Context, req ExecuteRequest, tc provid
execution.Output = formatPolicyBlockedToolResult(command, "This command is blocked by security policy")
return execution.Output, execution
}
if req.RequireCommandApproval || (decision == agentexec.PolicyRequireApproval && !s.isAutonomousForRequest(req)) {
isAuto := s.isAutonomousForRequest(req)
if req.RequireCommandApproval || isAuto || decision == agentexec.PolicyRequireApproval {
s.mu.RLock()
approvalOrgID := s.orgID
s.mu.RUnlock()
approvalReason := "Security policy requires approval"
if req.RequireCommandApproval {
approvalReason = "This handoff requires operator approval before command execution"
} else if isAuto {
approvalReason = "Autonomous model sessions cannot dispatch raw commands; operator approval is required"
}
approvalID := createRunCommandApprovalRecord(
approvalOrgID,
@@ -3794,6 +3799,10 @@ func (s *Service) executeOnAgent(ctx context.Context, req ExecuteRequest, comman
}
func (s *Service) executeOnAgentWithApproval(ctx context.Context, req ExecuteRequest, command, approvalID string) (string, error) {
return s.executeOnAgentWithAuthorization(ctx, req, command, approvalID, "", "")
}
func (s *Service) executeOnAgentWithAuthorization(ctx context.Context, req ExecuteRequest, command, approvalID, orgID, actionID string) (string, error) {
if s.agentServer == nil {
return "", fmt.Errorf("agent server not available")
}
@@ -3872,6 +3881,9 @@ func (s *Service) executeOnAgentWithApproval(ctx context.Context, req ExecuteReq
TargetID: targetID,
Timeout: 300, // 5 minutes - commands like du, backups, etc. can take a while
}
if strings.TrimSpace(approvalID) != "" {
cmd.BindCommandAuthorization(orgID, actionID)
}
result, err := s.agentServer.ExecuteCommand(ctx, agentID, cmd)
if err != nil {
@@ -3906,6 +3918,8 @@ type RunCommandRequest struct {
RunOnHost bool `json:"run_on_host"` // If true, run on host instead of target
VMID string `json:"vmid,omitempty"`
TargetHost string `json:"target_host,omitempty"` // Explicit host for routing
OrgID string `json:"-"`
ActionID string `json:"-"`
}
// RunCommandResponse represents the result of running a command
@@ -3948,7 +3962,7 @@ func (s *Service) RunCommand(ctx context.Context, req RunCommandRequest) (*RunCo
Msg("RunCommand using explicit target_host for routing")
}
output, err := s.executeOnAgentWithApproval(ctx, execReq, req.Command, req.ApprovalID)
output, err := s.executeOnAgentWithAuthorization(ctx, execReq, req.Command, req.ApprovalID, req.OrgID, req.ActionID)
if err != nil {
return &RunCommandResponse{
Success: false,
+26 -31
View File
@@ -2171,18 +2171,15 @@ func TestService_ExecuteStream_PolicyBlock(t *testing.T) {
}
}
func TestService_ExecuteStream_ResultTruncation(t *testing.T) {
func TestService_ExecuteStream_AutonomousRawCommandRequiresApproval(t *testing.T) {
tmpDir := t.TempDir()
persistence := config.NewConfigPersistence(tmpDir)
dispatches := 0
svc := NewService(persistence, &mockAgentServer{
agents: []agentexec.ConnectedAgent{
{AgentID: "agent-1", Hostname: "agent-1"},
},
executeFunc: func(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) {
return &agentexec.CommandResultPayload{
Success: true,
Stdout: strings.Repeat("long output ", 1000),
}, nil
agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "agent-1"}},
executeFunc: func(context.Context, string, agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) {
dispatches++
return &agentexec.CommandResultPayload{Success: true}, nil
},
})
svc.cfg = &config.AIConfig{Enabled: true, ControlLevel: config.ControlLevelAutonomous, Model: "anthropic:test"}
@@ -2191,35 +2188,33 @@ func TestService_ExecuteStream_ResultTruncation(t *testing.T) {
mock := &mockProvider{
chatFunc: func(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
iteration++
if iteration == 1 {
return &providers.ChatResponse{
ToolCalls: []providers.ToolCall{
{ID: "call-1", Name: "run_command", Input: map[string]interface{}{"command": "large-cmd", "target_host": "agent-1"}},
},
StopReason: "tool_use",
}, nil
}
// Check if the previous tool result was truncated in the request messages
for _, msg := range req.Messages {
if msg.Role == "user" && msg.ToolResult != nil {
if strings.Contains(msg.ToolResult.Content, "[TRUNCATED: 4000 characters cut.") &&
!strings.Contains(msg.ToolResult.Content, "bytes omitted") {
return &providers.ChatResponse{Content: "Verified truncation", StopReason: "end_turn"}, nil
}
}
}
return &providers.ChatResponse{Content: "Not truncated", StopReason: "end_turn"}, nil
return &providers.ChatResponse{
ToolCalls: []providers.ToolCall{{ID: "call-1", Name: "run_command", Input: map[string]interface{}{
"command": "touch /tmp/autonomous-raw-command", "target_host": "agent-1",
}}},
StopReason: "tool_use",
}, nil
},
}
svc.provider = mock
resp, err := svc.ExecuteStream(context.Background(), ExecuteRequest{TargetType: "agent"}, func(StreamEvent) {})
approvalEvents := 0
_, err := svc.ExecuteStream(context.Background(), ExecuteRequest{TargetType: "agent"}, func(event StreamEvent) {
if event.Type == "approval_needed" {
approvalEvents++
}
})
if err != nil {
t.Fatalf("ExecuteStream failed: %v", err)
}
if resp.Content != "Verified truncation" {
t.Errorf("Expected 'Verified truncation', got %s", resp.Content)
if dispatches != 0 {
t.Fatalf("agent dispatches = %d, want 0", dispatches)
}
if iteration != 1 {
t.Fatalf("provider iterations = %d, want 1", iteration)
}
if approvalEvents != 1 {
t.Fatalf("approval events = %d, want 1", approvalEvents)
}
}
+3
View File
@@ -171,6 +171,9 @@ func (e *PulseToolExecutor) executeCommandWithAudit(
}
payload.ApprovalID = strings.TrimSpace(approvalID)
if approvalReq != nil && approvalReq.Plan != nil {
payload.BindCommandAuthorization(e.orgID, approvalReq.Plan.ActionID)
}
result, err := e.agentServer.ExecuteCommand(ctx, agentID, payload)
finalMessage := "command completed"
executionResult := &unifiedresources.ExecutionResult{Success: true}
+1 -1
View File
@@ -122,7 +122,7 @@ func TestConsumeApprovalWithValidation_AllowsMatchingOrg(t *testing.T) {
stored, found := store.GetApproval("app-2")
require.True(t, found)
assert.True(t, stored.Consumed)
assert.False(t, stored.Consumed, "validation must not consume before the agentexec authorization boundary")
}
func TestFormattingHelpers(t *testing.T) {
+60 -1
View File
@@ -41,6 +41,65 @@ func TestPulseToolExecutor_ExecuteRunCommand(t *testing.T) {
assert.Contains(t, result.Content[0].Text, "command is required")
})
t.Run("AutonomousRejectsRawCommandBeforeDispatch", func(t *testing.T) {
agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}}}
exec := NewPulseToolExecutor(ExecutorConfig{
AgentServer: agentSrv,
ControlLevel: ControlLevelAutonomous,
})
exec.SetAutonomousMode(true)
result, err := exec.executeRunCommand(ctx, agentcapabilities.WithApprovalArgument(map[string]interface{}{
"command": "touch /tmp/authority-boundary-bypass",
"target_host": "tower",
}, "fabricated"))
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, result.Content[0].Text, "raw command execution is unavailable in autonomous model sessions")
agentSrv.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("NeverAutoRemediateUsesCanonicalIdentityBehindHostAlias", func(t *testing.T) {
store, err := approval.NewStore(approval.StoreConfig{DataDir: t.TempDir(), DisablePersistence: true})
require.NoError(t, err)
previous := approval.GetStore()
approval.SetStore(store)
t.Cleanup(func() { approval.SetStore(previous) })
const canonicalID = "agent:agent-1"
const command = "systemctl restart workload"
planHash := approvalPlanHash(
"action-alias", "approval-alias", "pulse_control", canonicalID,
command, "agent", "", `run command "systemctl restart workload" on tower`,
)
approvalReq := &approval.ApprovalRequest{
ID: "approval-alias", OrgID: "org-1", Command: command, TargetType: "agent", TargetID: "agent-1",
Plan: &unifiedresources.ActionPlan{ActionID: "action-alias", RequestID: "approval-alias", Allowed: true, RequiresApproval: true, ApprovalPolicy: unifiedresources.ApprovalAdmin, PlanHash: planHash},
}
require.NoError(t, store.CreateApproval(approvalReq))
_, err = store.Approve("approval-alias", "operator@example.com")
require.NoError(t, err)
actionStore := unifiedresources.NewMemoryStore()
require.NoError(t, actionStore.SetResourceOperatorState(unifiedresources.ResourceOperatorState{
CanonicalID: canonicalID, NeverAutoRemediate: true,
}))
agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower.example.com"}}}
exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv, ControlLevel: ControlLevelControlled, ActionAuditStore: actionStore})
exec.SetOrgID("org-1")
exec.SetResolvedContext(&mockResolvedContext{aliases: map[string]ResolvedResourceInfo{
"tower": &mockResource{resourceID: canonicalID, resourceType: "agent", targetHost: "tower.example.com", agentID: "agent-1", kind: "agent", aliases: []string{"tower"}},
}})
result, err := exec.executeRunCommand(ctx, agentcapabilities.WithApprovalArgument(map[string]interface{}{
"command": command, "target_host": "tower",
}, "approval-alias"))
require.NoError(t, err)
require.True(t, result.IsError)
assert.Contains(t, result.Content[0].Text, unifiedresources.ErrResourceRemediationLocked.Error())
agentSrv.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("PolicyBlocked", func(t *testing.T) {
policy := &mockCommandPolicy{}
policy.On("Evaluate", "rm -rf /").Return(agentexec.PolicyBlock).Once()
@@ -231,7 +290,7 @@ func TestPulseToolExecutor_ExecuteRunCommand(t *testing.T) {
consumed, found := store.GetApproval("approval-1")
require.True(t, found)
assert.True(t, consumed.Consumed)
assert.False(t, consumed.Consumed, "mock agent servers do not own the final authorization-consumption boundary")
audits, err := actionStore.GetActionAudits("", time.Time{}, 10)
require.NoError(t, err)
+4 -1
View File
@@ -337,7 +337,10 @@ func TestToolRegistryExecuteNormalizesSharedToolCallParams(t *testing.T) {
var gotArgsLenBeforeMutation int
registry.RegisterExtension(RegisteredTool{
Invocation: StaticInvocation(agentcapabilities.ToolCallKindWrite, agentcapabilities.MutationPulseState),
Definition: Tool{Name: "test_tool"},
Definition: Tool{Name: "test_tool", InputSchema: InputSchema{Properties: map[string]PropertySchema{
"resource_id": {Type: "string"},
"body": {Type: "object"},
}}},
Handler: func(ctx context.Context, e *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) {
gotArgs = args
gotArgsLenBeforeMutation = len(args)
+5 -3
View File
@@ -650,7 +650,7 @@ func TestExecuteKubernetesExec(t *testing.T) {
mockAgent.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything)
})
t.Run("MatchingApprovedIDBypassesAndConsumes", func(t *testing.T) {
t.Run("MatchingApprovedIDCarriesAuthorizationToAgentBoundary", func(t *testing.T) {
store, err := approval.NewStore(approval.StoreConfig{
DataDir: t.TempDir(),
DisablePersistence: true,
@@ -699,14 +699,16 @@ func TestExecuteKubernetesExec(t *testing.T) {
assert.NotContains(t, result.Content[0].Text, "APPROVAL_REQUIRED")
mockAgent.AssertExpectations(t)
// Approval is single-use; second attempt should not bypass.
// The mock agent boundary does not consume. Production agentexec consumes
// atomically before grant minting and dispatch; that replay behavior is
// covered by the agentexec/API authorization tests.
result, err = exec.executeKubernetesExec(ctx, agentcapabilities.WithApprovalArgument(map[string]interface{}{
"cluster": "cluster-1",
"pod": "nginx-pod",
"command": "cat /etc/nginx/nginx.conf",
}, "approval-2"))
require.NoError(t, err)
assert.Contains(t, result.Content[0].Text, "APPROVAL_REQUIRED")
assert.NotContains(t, result.Content[0].Text, "APPROVAL_REQUIRED")
})
}
+3 -1
View File
@@ -400,7 +400,6 @@ func (r *ToolRegistry) Execute(ctx context.Context, e *PulseToolExecutor, name s
if !exists {
return agentcapabilities.NewUnknownToolResult(name), nil
}
// 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
@@ -425,6 +424,9 @@ func (r *ToolRegistry) Execute(ctx context.Context, e *PulseToolExecutor, name s
return agentcapabilities.NewControlToolsDisabledToolResult(), nil
}
}
if err := agentcapabilities.ValidateDeclaredToolArguments(tool.Definition.InputSchema, args); err != nil {
return agentcapabilities.NewInvalidToolCallParamsResult(err), nil
}
result, err := tool.Handler(ctx, e, args)
return result.NormalizeCollections(), err
+31 -17
View File
@@ -318,6 +318,10 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
command, _ := args["command"].(string)
targetHost, _ := args["target_host"].(string)
approvalID := agentcapabilities.ApprovalArgument(args)
auditResourceID := strings.TrimSpace(targetHost)
if e.isAutonomous {
return NewErrorResult(fmt.Errorf("raw command execution is unavailable in autonomous model sessions; use a typed governed action proposal")), nil
}
if command == "" {
return NewErrorResult(fmt.Errorf("command is required")), nil
@@ -340,6 +344,9 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
Str("validation_error", validation.ErrorMsg).
Msg("[Control] Target resource not in resolved context - may indicate model hallucination")
}
if validation.Resource != nil && strings.TrimSpace(validation.Resource.GetResourceID()) != "" {
auditResourceID = strings.TrimSpace(validation.Resource.GetResourceID())
}
// Validate routing context - block if targeting a host node when child resources exist
// This prevents accidentally executing commands on the host when user meant to target a container/VM
@@ -391,21 +398,15 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
approvalTargetType, approvalTargetID, approvalTargetName := approvalTargetForCommand(targetHost, routing)
// Check if this is a pre-approved execution with command hash validation.
// This validates the approval matches this exact command+target and marks it as consumed.
// Final single-use consumption is owned by agentexec immediately before
// grant signing and WebSocket dispatch.
preApproved := consumeApprovalWithValidation(args, e.orgID, command, approvalTargetType, approvalTargetID)
// Skip approval checks if pre-approved or in autonomous mode.
if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled {
if !preApproved && e.controlLevel == ControlLevelControlled {
approvalID := e.createApprovalRecord(command, approvalTargetType, approvalTargetID, approvalTargetName, "Control level requires approval")
return NewTextResult(formatApprovalNeeded(command, "Control level requires approval", approvalID)), nil
}
if e.isAutonomous {
log.Debug().
Str("command", command).
Bool("read_only", safety.IsReadOnlyCommand(command)).
Msg("Auto-approving command for autonomous investigation")
}
if !preApproved && decision == agentexec.PolicyRequireApproval && !e.isAutonomous {
if !preApproved && decision == agentexec.PolicyRequireApproval {
approvalID := e.createApprovalRecord(command, approvalTargetType, approvalTargetID, approvalTargetName, "Security policy requires approval")
return NewTextResult(formatApprovalNeeded(command, "Security policy requires approval", approvalID)), nil
}
@@ -425,6 +426,9 @@ func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[stri
ctx,
"pulse_control",
func() string {
if auditResourceID != "" {
return auditResourceID
}
if targetHost != "" {
return targetHost
}
@@ -1832,9 +1836,10 @@ func isPreApproved(args map[string]interface{}) bool {
return false
}
// consumeApprovalWithValidation validates and consumes an approval for a specific command.
// It verifies the command hash matches the approval and marks it as consumed (single-use).
// Returns true if the approval is valid and was consumed, false otherwise.
// consumeApprovalWithValidation validates that an approved, org-bound record
// exists for this exact command and target. Final single-use consumption now
// happens inside agentexec immediately before grant minting and WebSocket
// dispatch, so no caller can turn a merely nonempty ID into authority.
func consumeApprovalWithValidation(args map[string]interface{}, orgID, command, targetType, targetID string) bool {
approvalID := agentcapabilities.ApprovalArgument(args)
if approvalID == "" {
@@ -1861,12 +1866,21 @@ func consumeApprovalWithValidation(args map[string]interface{}, orgID, command,
return false
}
_, err := store.ConsumeApproval(approvalID, command, targetType, targetID)
if err != nil {
log.Warn().Err(err).Str("approval_id", approvalID).Msg("failed to consume approval")
if req.Status != approval.StatusApproved || req.Consumed || time.Now().After(req.ExpiresAt) {
log.Warn().Str("approval_id", approvalID).Str("status", string(req.Status)).Bool("consumed", req.Consumed).Time("expires_at", req.ExpiresAt).Msg("approval is not dispatchable")
return false
}
actualHash := approval.ComputeCommandHash(command, targetType, targetID)
approvedHash := func() string {
if strings.TrimSpace(req.CommandHash) != "" {
return req.CommandHash
}
return approval.ComputeCommandHash(req.Command, req.TargetType, req.TargetID)
}()
if actualHash != approvedHash {
log.Warn().Str("approval_id", approvalID).Msg("approval command or target does not match dispatch")
return false
}
return true
}
@@ -0,0 +1,46 @@
package api
import (
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
)
// verifyAndConsumeCommandAuthorization is the final server-owned authority
// boundary for approval-gated arbitrary agent commands. It validates the
// tenant and action identity before atomically consuming the exact
// command/target approval. Grant signing and WebSocket dispatch happen only
// after this function succeeds.
func verifyAndConsumeCommandAuthorization(req agentexec.CommandAuthorizationRequest) error {
store := approval.GetStore()
if store == nil {
return fmt.Errorf("approval store is unavailable")
}
approvalReq, found := store.GetApproval(strings.TrimSpace(req.ApprovalID))
if !found || approvalReq == nil {
return fmt.Errorf("approval not found")
}
if !approval.BelongsToOrg(approvalReq, req.OrgID) {
return fmt.Errorf("approval belongs to another org")
}
if approvalReq.Plan == nil || strings.TrimSpace(approvalReq.Plan.ActionID) == "" ||
strings.TrimSpace(approvalReq.Plan.ActionID) != strings.TrimSpace(req.ActionID) {
return fmt.Errorf("approval action does not match")
}
if approvalReq.Status != approval.StatusApproved {
return fmt.Errorf("approval is not approved (status: %s)", approvalReq.Status)
}
if approvalReq.Consumed {
return fmt.Errorf("approval has already been consumed")
}
if time.Now().After(approvalReq.ExpiresAt) {
return fmt.Errorf("approval has expired")
}
if _, err := store.ConsumeApproval(req.ApprovalID, req.Command, req.TargetType, req.TargetID); err != nil {
return err
}
return nil
}
@@ -0,0 +1,94 @@
package api
import (
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func installCommandAuthorizationStore(t *testing.T, req *approval.ApprovalRequest) *approval.Store {
t.Helper()
store, err := approval.NewStore(approval.StoreConfig{DataDir: t.TempDir(), DisablePersistence: true})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
previous := approval.GetStore()
approval.SetStore(store)
t.Cleanup(func() { approval.SetStore(previous) })
if req != nil {
if err := store.CreateApproval(req); err != nil {
t.Fatalf("CreateApproval: %v", err)
}
if _, err := store.Approve(req.ID, "operator@example.com"); err != nil {
t.Fatalf("Approve: %v", err)
}
}
return store
}
func commandAuthorizationApproval(id string) *approval.ApprovalRequest {
return &approval.ApprovalRequest{
ID: id, OrgID: "org-1", Command: "echo ok", TargetType: "agent", TargetID: "agent-1",
Plan: &unifiedresources.ActionPlan{ActionID: "action-1", RequestID: id, Allowed: true, RequiresApproval: true},
}
}
func TestVerifyAndConsumeCommandAuthorizationRejectsInvalidApprovalsWithoutNewConsumption(t *testing.T) {
tests := []struct {
name string
approval *approval.ApprovalRequest
prepare func(*approval.Store, *approval.ApprovalRequest)
request agentexec.CommandAuthorizationRequest
wantError string
}{
{name: "nonexistent", request: agentexec.CommandAuthorizationRequest{ApprovalID: "missing", OrgID: "org-1", ActionID: "action-1", Command: "echo ok", TargetType: "agent", TargetID: "agent-1"}, wantError: "approval not found"},
{name: "wrong org", approval: commandAuthorizationApproval("wrong-org"), request: agentexec.CommandAuthorizationRequest{ApprovalID: "wrong-org", OrgID: "org-2", ActionID: "action-1", Command: "echo ok", TargetType: "agent", TargetID: "agent-1"}, wantError: "another org"},
{name: "wrong action", approval: commandAuthorizationApproval("wrong-action"), request: agentexec.CommandAuthorizationRequest{ApprovalID: "wrong-action", OrgID: "org-1", ActionID: "action-2", Command: "echo ok", TargetType: "agent", TargetID: "agent-1"}, wantError: "action does not match"},
{name: "expired", approval: commandAuthorizationApproval("expired"), prepare: func(_ *approval.Store, req *approval.ApprovalRequest) { req.ExpiresAt = time.Now().Add(-time.Minute) }, request: agentexec.CommandAuthorizationRequest{ApprovalID: "expired", OrgID: "org-1", ActionID: "action-1", Command: "echo ok", TargetType: "agent", TargetID: "agent-1"}, wantError: "expired"},
{name: "consumed", approval: commandAuthorizationApproval("consumed"), prepare: func(store *approval.Store, _ *approval.ApprovalRequest) {
if _, err := store.ConsumeApproval("consumed", "echo ok", "agent", "agent-1"); err != nil {
t.Fatalf("pre-consume: %v", err)
}
}, request: agentexec.CommandAuthorizationRequest{ApprovalID: "consumed", OrgID: "org-1", ActionID: "action-1", Command: "echo ok", TargetType: "agent", TargetID: "agent-1"}, wantError: "already been consumed"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := installCommandAuthorizationStore(t, tc.approval)
if tc.prepare != nil {
tc.prepare(store, tc.approval)
}
wasConsumed := tc.approval != nil && tc.approval.Consumed
wasStatus := approval.ApprovalStatus("")
if tc.approval != nil {
wasStatus = tc.approval.Status
}
err := verifyAndConsumeCommandAuthorization(tc.request)
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("error = %v, want %q", err, tc.wantError)
}
if tc.approval != nil && (tc.approval.Consumed != wasConsumed || tc.approval.Status != wasStatus) {
t.Fatalf("invalid authorization mutated approval to status=%s consumed=%v, want status=%s consumed=%v", tc.approval.Status, tc.approval.Consumed, wasStatus, wasConsumed)
}
})
}
}
func TestVerifyAndConsumeCommandAuthorizationConsumesExactBoundApprovalOnce(t *testing.T) {
req := commandAuthorizationApproval("valid")
installCommandAuthorizationStore(t, req)
auth := agentexec.CommandAuthorizationRequest{ApprovalID: "valid", OrgID: "org-1", ActionID: "action-1", AgentID: "agent-1", Command: "echo ok", TargetType: "agent", TargetID: "agent-1"}
if err := verifyAndConsumeCommandAuthorization(auth); err != nil {
t.Fatalf("first authorization: %v", err)
}
if !req.Consumed {
t.Fatal("valid authorization was not consumed")
}
if err := verifyAndConsumeCommandAuthorization(auth); err == nil || !strings.Contains(err.Error(), "already been consumed") {
t.Fatalf("replay error = %v, want already consumed", err)
}
}
+6 -5
View File
@@ -1004,7 +1004,6 @@ type ChatRequest struct {
HandoffResources []chat.HandoffResource `json:"handoff_resources,omitempty"`
HandoffActions []chat.HandoffAction `json:"handoff_actions,omitempty"`
HandoffMetadata chat.HandoffMetadata `json:"handoff_metadata,omitempty"`
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
}
type AssistantWorkflowPromptRenderRequest struct {
@@ -1033,9 +1032,11 @@ const (
)
func chatAutonomousModeForScopedHandoff(requested *bool, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction, handoffMetadata chat.HandoffMetadata) *bool {
if strings.TrimSpace(handoffContext) == "" && len(handoffResources) == 0 && len(handoffActions) == 0 && chat.NormalizeHandoffMetadata(handoffMetadata) == (chat.HandoffMetadata{}) {
return requested
}
_ = requested
_ = handoffContext
_ = handoffResources
_ = handoffActions
_ = handoffMetadata
return chatApprovalRequiredAutonomousMode()
}
@@ -2694,7 +2695,7 @@ func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
HandoffResources: handoffResources,
HandoffActions: handoffActions,
HandoffMetadata: handoffMetadata,
AutonomousMode: chatAutonomousModeForFindingHandoff(req.AutonomousMode, findingID, handoffContext, handoffResources, handoffActions, handoffMetadata),
AutonomousMode: chatAutonomousModeForFindingHandoff(nil, findingID, handoffContext, handoffResources, handoffActions, handoffMetadata),
SuppressSessionEvent: true,
}, func(event chat.StreamEvent) {
if event.Type == "done" {
+8 -8
View File
@@ -1185,7 +1185,7 @@ func TestHandleChat_PreservesCanonicalMentionTypes(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
}
func TestHandleChat_PassesAutonomousModeOverride(t *testing.T) {
func TestHandleChat_CannotElevateAutonomousModePerRequest(t *testing.T) {
cfg := &config.Config{}
h := newTestAIHandler(cfg, nil, nil)
mockSvc := new(MockAIService)
@@ -1202,7 +1202,7 @@ func TestHandleChat_PassesAutonomousModeOverride(t *testing.T) {
}
})
body := `{"prompt":"summarize dashboard","autonomous_mode":false}`
body := `{"prompt":"run a command","autonomous_mode":true}`
req := httptest.NewRequest("POST", "/api/ai/chat", strings.NewReader(body))
w := httptest.NewRecorder()
@@ -1214,14 +1214,14 @@ func TestHandleChat_PassesAutonomousModeOverride(t *testing.T) {
func TestChatAutonomousModeForFindingHandoffRequiresApprovalForProductHandoffs(t *testing.T) {
requestedAutonomous := true
requestedManual := false
if got := chatAutonomousModeForFindingHandoff(&requestedAutonomous, "", "", nil, nil, chat.HandoffMetadata{}); got != &requestedAutonomous {
t.Fatalf("plain chat should pass requested autonomous mode through, got %#v", got)
if got := chatAutonomousModeForFindingHandoff(&requestedAutonomous, "", "", nil, nil, chat.HandoffMetadata{}); got == nil || *got {
t.Fatalf("plain chat must not accept requested autonomous authority, got %#v", got)
}
if got := chatAutonomousModeForFindingHandoff(&requestedManual, "", "", nil, nil, chat.HandoffMetadata{}); got != &requestedManual {
t.Fatalf("plain chat should pass requested manual mode through, got %#v", got)
if got := chatAutonomousModeForFindingHandoff(&requestedManual, "", "", nil, nil, chat.HandoffMetadata{}); got == nil || *got {
t.Fatalf("plain chat should remain approval-gated, got %#v", got)
}
if got := chatAutonomousModeForFindingHandoff(nil, "", "", nil, nil, chat.HandoffMetadata{}); got != nil {
t.Fatalf("plain chat with no override should preserve nil autonomous mode, got %#v", got)
if got := chatAutonomousModeForFindingHandoff(nil, "", "", nil, nil, chat.HandoffMetadata{}); got == nil || *got {
t.Fatalf("plain chat with no override should be explicitly approval-gated, got %#v", got)
}
cases := []struct {
+12 -3
View File
@@ -4199,9 +4199,16 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
http.Error(w, "Approval request not found", http.StatusNotFound)
return
}
if _, err := store.ConsumeApproval(req.ApprovalID, req.Command, approvalTargetType, approvalTargetID); err != nil {
log.Error().Err(err).Str("approval_id", req.ApprovalID).Msg("Failed to consume approval")
http.Error(w, "Failed to consume approval", http.StatusConflict)
if approvalReq.Plan == nil || strings.TrimSpace(approvalReq.Plan.ActionID) == "" {
http.Error(w, "Approval authorization is incomplete", http.StatusConflict)
return
}
approvedHash := strings.TrimSpace(approvalReq.CommandHash)
if approvedHash == "" {
approvedHash = approval.ComputeCommandHash(approvalReq.Command, approvalReq.TargetType, approvalReq.TargetID)
}
if approvedHash != approval.ComputeCommandHash(req.Command, approvalTargetType, approvalTargetID) {
http.Error(w, "Approval command does not match", http.StatusConflict)
return
}
@@ -4226,6 +4233,8 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ
RunOnHost: req.RunOnHost,
VMID: req.VMID,
TargetHost: strings.ToLower(strings.TrimSpace(req.TargetHost)),
OrgID: orgID,
ActionID: approvalReq.Plan.ActionID,
})
if err != nil {
+4 -2
View File
@@ -2676,7 +2676,7 @@ func TestHandleRunCommand_RequiresApprovalID(t *testing.T) {
}
}
func TestHandleRunCommand_ConsumesApproval(t *testing.T) {
func TestHandleRunCommand_DefersConsumptionUntilAgentDispatch(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
@@ -2695,6 +2695,7 @@ func TestHandleRunCommand_ConsumesApproval(t *testing.T) {
Command: "uptime",
TargetType: "vm",
TargetID: "vm-101",
Plan: &unifiedresources.ActionPlan{ActionID: "action-1", RequestID: "approval-1", Allowed: true, RequiresApproval: true},
}
require.NoError(t, store.CreateApproval(appReq))
_, err = store.Approve(appReq.ID, "tester")
@@ -2709,7 +2710,7 @@ func TestHandleRunCommand_ConsumesApproval(t *testing.T) {
stored, found := store.GetApproval(appReq.ID)
require.True(t, found)
require.True(t, stored.Consumed)
require.False(t, stored.Consumed)
}
func TestHandleRunCommand_RejectsCommandMismatch(t *testing.T) {
@@ -2731,6 +2732,7 @@ func TestHandleRunCommand_RejectsCommandMismatch(t *testing.T) {
Command: "uptime",
TargetType: "vm",
TargetID: "vm-101",
Plan: &unifiedresources.ActionPlan{ActionID: "action-2", RequestID: "approval-2", Allowed: true, RequiresApproval: true},
}
require.NoError(t, store.CreateApproval(appReq))
_, err = store.Approve(appReq.ID, "tester")
+1 -1
View File
@@ -542,7 +542,7 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
"func chatAutonomousModeForFindingHandoff(requested *bool, findingID, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction, handoffMetadata chat.HandoffMetadata) *bool",
`if strings.TrimSpace(findingID) != ""`,
"chatApprovalRequiredAutonomousMode()",
"AutonomousMode: chatAutonomousModeForFindingHandoff(req.AutonomousMode, findingID, handoffContext, handoffResources, handoffActions, handoffMetadata)",
"AutonomousMode: chatAutonomousModeForFindingHandoff(nil, findingID, handoffContext, handoffResources, handoffActions, handoffMetadata)",
`appendChatContextLine(&b, "Finding Status", unifiedFindingChatStatus(f, time.Now()))`,
`appendChatContextLine(&b, "Finding Detected At", f.DetectedAt.Format(time.RFC3339))`,
`appendChatContextLine(&b, "Finding Last Seen At", f.LastSeenAt.Format(time.RFC3339))`,
+1
View File
@@ -631,6 +631,7 @@ func (r *Router) setupRoutes() {
// Agent execution server for AI tool use
r.agentExecServer = agentexec.NewServer(r.validateAgentExecToken)
r.agentExecServer.SetCommandAuthorizationVerifier(verifyAndConsumeCommandAuthorization)
if r.resourceHandlers != nil {
r.resourceHandlers.SetActionExecutor(newRoutedActionExecutor(
r.resourceHandlers,
@@ -2919,7 +2919,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1204,
"line": 1205,
"heading_line": 142,
}
],