mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 13:10:03 +00:00
fix: close chat command authority boundary
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))`,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user