diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 65a2054db1..8adf177f20 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -4047,7 +4047,7 @@ "./internal/ai/tools", "./internal/api", "-run", - "TestParseProviderToolInputRejectsInternalApprovalMetadata|TestValidateDeclaredToolArgumentsRejectsUnknownProviderProperties|TestExecuteCommand_RoundTripViaWebSocket|TestExecuteCommand_InvalidApprovalAuthorizationNeverMintsOrDispatches|TestService_ExecuteStream_AutonomousRawCommandRequiresApproval|TestService_Execute_RepeatedDeniedToolCallTerminatesBounded|TestServiceToolLoop_|TestPulseToolExecutor_ExecuteRunCommand|TestHandleChat_CannotElevateAutonomousModePerRequest|TestVerifyAndConsumeCommandAuthorization", + "TestParseProviderToolInputRejectsInternalApprovalMetadata|TestValidateDeclaredToolArgumentsRejectsUnknownProviderProperties|TestExecuteCommand_RoundTripViaWebSocket|TestExecuteCommand_InvalidApprovalAuthorizationNeverMintsOrDispatches|TestService_ExecuteStream_AutonomousRawCommandRequiresApproval|TestService_Execute_RepeatedDeniedToolCallTerminatesBounded|TestServiceToolLoop_|TestRetiredMutationProfileMatrixDeniesBeforeHandlers|TestHandleChat_CannotElevateAutonomousModePerRequest|TestVerifyAndConsumeCommandAuthorization", "-count=1" ] }, @@ -4189,11 +4189,6 @@ "kind": "file", "evidence_tier": "test-proof" }, - { - "repo": "pulse", - "path": "internal/ai/tools/control_run_command_test.go", - "kind": "file" - }, { "repo": "pulse", "path": "internal/ai/tools/execution_profile.go", diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c239e39fde..9965b2132b 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -2150,13 +2150,13 @@ and search matches through `addCanonicalGuestSearchMatches` / disk summaries flow through `appendGuestDiskSummaries` in `internal/ai/tools/tools_storage.go`. A new guest family extends those helpers (their view-constraint interfaces name the read-state methods they -need) rather than re-rolling a parallel loop. The same shape applies to -mutating tool pipelines: file append/write share `executeFileMutation` -driven by `fileMutationSpec` (`internal/ai/tools/tools_file.go`), and -namespaced kubectl actions share `executeKubernetesResourceAction` driven by -`kubernetesResourceAction` (`internal/ai/tools/tools_kubernetes.go`) — new -file or kubectl actions add a spec, keeping approval-command text stable, -instead of duplicating the approval/audit pipeline. OpenAI-compatible message +need) rather than re-rolling a parallel loop. The Assistant tool layer holds +no mutating tool pipeline to extend: `pulse_control` only plans typed resource +actions into Pulse Actions through `executeControlResource` +(`internal/ai/tools/tools_control.go`), and the shell, guest, Docker, +Kubernetes and file-mutation handlers that once carried their own approval and +audit steps were removed because nothing in production called them. +OpenAI-compatible message conversion for both non-streaming and streaming turns is shared through `convertMessagesToOpenAI` (`internal/ai/providers/openai.go`), Anthropic message conversion for both non-streaming and streaming turns is shared by the API-key @@ -4412,9 +4412,9 @@ resolve canonical/source IDs and unique aliases before collection, reject presenting stale read-only summaries. Frontend approval cards must surface backend approval risk/description without hiding a pending approval when skip or deny fails. Action-producing tools must also persist the unified - `ActionPlan.Preflight` dry-run boundary through - `internal/ai/tools/action_audit.go` rather than leaving dry-run availability - as chat-only text. + `ActionPlan.Preflight` dry-run boundary through the canonical planner + (`internal/actionplanner/planner.go`) rather than leaving dry-run + availability as chat-only text. The `pulse_discovery` tool response must carry each discovered fact's provenance — the `source` that produced it and its `confidence` — alongside the fact's category/key/value, so the model can attribute and weight what it @@ -4574,65 +4574,20 @@ resolve canonical/source IDs and unique aliases before collection, reject and to leave `impact` empty rather than fabricate one when the consequence is genuinely unknown; the runtime must not synthesize a default in that case. - The action broker enforces a plan-hash drift check at the execute - boundary: when an approval ID resolves to a stored plan with a - PlanHash, the freshly-recomputed approval-equivalent hash from the - actual payload (using `approvalPlanHash`, the same function used at - approval-creation time) must match. Mismatch returns - `ErrActionPlanDrift` and refuses dispatch; the contract is "the - operator approved exactly this (command, target, reason) - combination" and a different one cannot run under the stale - approval. When `approvedHash` is empty (older approval records, or - contract paths that did not author one), validation is skipped to - preserve existing behavior. The check is currently wired in - `executeCommandWithAudit` for shell-command actions; the native- - action path uses a different hash function (`actionPlanHashForParams`) - so a coherent canonical-hash refactor must precede adding the same - check there. - The broker runs a class-derived read-after-write verification check - immediately after a successful dispatch. `VerificationCommandForCommand` - in `internal/ai/tools/tools_control.go` returns the executable check - keyed on the same command class as the preflight authoring (e.g. - `systemctl is-active ` after a service-restart). The check - runs through the same agent path as the dispatch and the outcome is - persisted on `ExecutionResult.Verification` so the audit history - shows not only what the action did but whether the read-back - confirmed the intended state. Container-class verification is - deferred to pulse_docker's existing tool-level runtime inspect - check (`docker inspect` or `podman inspect`, according to the - resolved container runtime); classes without a derivable verification command leave - `Verification` nil rather than fabricating a verified=true entry. - The approval preflight presented to operators authors per-command-class - safety and verification context on top of the default broker-level - posture. `classifyApprovalCommand` and - `approvalCommandClassPreflightAdditions` in - `internal/ai/tools/tools_control.go` bucket common Pulse remediation - actions (service-restart, service-stop, service-start, service-reload, - container-restart, container-stop, k8s-rollout-restart, plus the - Proxmox VM lifecycle classes proxmox-vm-reboot, proxmox-vm-stop, - proxmox-vm-start, proxmox-vm-shutdown and the matching pct-driven - proxmox-ct-\* container lifecycle classes) and return hand-authored - operational copy: what the command actually touches, how Pulse will - read back success. The additions append onto the default - safety/verification arrays rather than replacing them, so the - broker's structural posture (org scope, hash match, single-use - approval) remains visible alongside the class-specific copy. - Unknown command classes must return empty additions rather than - fabricated padding — operators see only the default content, not - invented assertions about what an unrecognized command will do. - The Proxmox classes intentionally do not derive a broker-level - `VerificationCommandForCommand` check because pulse_control's - `verifyGuestAction` already runs `qm status` / `pct status` at the - tool layer; adding a parallel broker dispatch would double-run the - same read-after-write check. - Drift refusal must also persist a Failed audit record with the - Request, Plan, and Approvals snapshots intact and a Result whose - ErrorMessage is prefixed `plan_drift:` so the audit trail shows - every drift attempt that was caught. Operators reviewing the action - audit history must be able to see drift refusals as first-class - audit rows, not only in WARN-level logs; the `plan_drift:` prefix - is a stable token for audit-UI filters and alert rules to - distinguish drift from generic execution failures. + The Assistant tool layer carries no action broker. Its former + shell-command, guest, Docker, Kubernetes and file-mutation handlers, + the `executeCommandWithAudit` and `executeNativeActionWithAudit` + execution lanes, and the approval-record, preflight and plan-hash helpers + behind them (`createApprovalRecord`, `approvalPreflight`, + `classifyApprovalCommand`, `VerificationCommandForCommand`, + `approvalPlanHash`) were removed because nothing in production called + them: `pulse_control` only plans a typed resource action through + `executeControlResource`, and plan freshness + (`unifiedresources.ErrActionPlanDrift`) and the operator remediation lock + are enforced by `internal/actionlifecycle`. Class-derived read-after-write + verification, per-class preflight copy and the `plan_drift:` audit prefix + therefore have no tool-layer implementation; a change that needs one + belongs in the lifecycle, not back in `internal/ai/tools`. `FindingsStore.GetTrustSummary` returns a snapshot of how currently tracked findings have resolved (tracked, currently-active, resolved, auto-resolved, fix-verified, fix-failed, dismissed-as-noise, @@ -5799,19 +5754,15 @@ approvals (the common shape on the approval hot path) still carry a canonical resource id agents can match against the rest of Pulse. -`PulseToolExecutor` exposes `SetOnActionCompleted(cb)` as the -parallel seam for action-audit terminal states. The action-audit -hot path in `internal/ai/tools/action_audit.go` routes every -terminal-state record (Completed, runtime-Failed, plan-drift -refusal, operator-lock refusal, recovery-branch fail) through a -single helper `publishActionCompleted(record)` which guards on -nil callback, defensively filters non-terminal states, and fires -the callback on its own goroutine after the audit record has -been persisted. Refused-before-dispatch failures preserve the -canonical `plan_drift:` and `resource_remediation_locked:` -error-token prefixes on `record.Result.ErrorMessage` so the agent -SSE stream's `action.completed` payload carries them verbatim — -agents branch on the prefix rather than parsing human text. +`PulseToolExecutor` still exposes `SetOnActionCompleted(cb)` as the +parallel seam for action-audit terminal states, and the API layer installs +the agent SSE bridge on it, but no production path dispatches the callback: +its only dispatcher, `publishActionCompleted` in +`internal/ai/tools/action_audit.go`, was reachable solely from the retired +`executeCommandWithAudit` and `executeNativeActionWithAudit` lanes and was +removed with them. The `action.completed` agent event is therefore published +only by API-owned execution (`ResourceHandlers` through +`PublishActionCompletedRecord`), not by the Assistant executor. The investigation runtime now hands the orchestrator a Finding pre-enriched with operator-set state and operational memory. @@ -5891,28 +5842,15 @@ both are evaluated by the canonical operator-state model. Descendant-scope inheritance is alert-intent behavior and does not weaken exact-resource action-remediation locks or invent inherited action authority. -The action broker consults the same `resource_operator_state` table -on every dispatch — both the agent-command path -(`executeCommandWithAudit`) and the native provider path -(`executeNativeActionWithAudit`) in -`internal/ai/tools/action_audit.go` run the shared -`checkRemediationLockForDispatch` gate — and refuses with -`unifiedresources.ErrResourceRemediationLocked` when the operator -has set `NeverAutoRemediate=true` on the target resource. Refusal -persists a Failed audit record whose `ErrorMessage` is prefixed -`resource_remediation_locked:` so the audit timeline shows every -refused dispatch, paralleling the `plan_drift:` shape from the drift -guard. Operator state outranks per-action approval — the broker -refuses even when the approval ID resolves and the plan hash matches. -When the lock state cannot be determined (no audit store wired, or -the operator-state lookup errors), dispatches that do not carry an -approved human decision fail CLOSED with -`ErrRemediationLockStateUnknown` and a Failed audit record prefixed -`remediation_lock_state_unknown:` — the broker must not assume an -unreadable lock is unset while Patrol or Assistant run autonomously. -Dispatches backed by an approved human decision keep the historical -fail-open posture on unknown lock state (the operator explicitly -signed off on the exact plan), with the degraded lookup logged. The `IntentionallyOffline` branch is the indefinite +Operator state outranks per-action approval on the Pulse Actions path: +`validateExecutionPolicy` in `internal/actionlifecycle/service.go` reads the +same `resource_operator_state` table at the dispatch decision point, refuses +with `unifiedresources.ErrResourceRemediationLocked` when the operator has set +`NeverAutoRemediate=true` on the target resource, and fails closed when the +audit store is unavailable. The Assistant tool layer no longer consults the +table itself: the tool-layer broker that did so (`checkRemediationLockForDispatch` +inside the retired execution lanes) and its `ErrRemediationLockStateUnknown` +were removed with the unreachable handlers. The `IntentionallyOffline` branch is the indefinite counterpart — same auto-dismiss but with `operator_state_cause: intentionally_offline` and no `maintenance_end_at` field because the suppression has no scheduled @@ -6255,8 +6193,8 @@ then highest operational risk, then oldest request time, with approval ID as the final tie-break so map iteration cannot decide which governed action looks most urgent. That same approval boundary also owns approved command execution. When -`internal/api/ai_handlers.go`, `internal/ai/service.go`, or -`internal/ai/tools/action_audit.go` consume a governed approval record, the +`internal/api/ai_handlers.go` or `internal/ai/service.go` consume a governed +approval record, the runtime must carry that approval identifier into the final `agentexec.ExecuteCommandPayload` so the host agent can re-check the shared command policy locally and fail closed on blocked or still-unapproved commands diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 40a872088d..08f2275044 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -5921,24 +5921,18 @@ TargetName)` via the same rule the store uses internally — agents match the result against canonical resource ids elsewhere in Pulse without depending on a `Plan` being populated. -`PulseToolExecutor` exposes `SetOnActionCompleted(cb)` as the -parallel seam for action-audit terminal states. Every dispatch -that reaches `ActionStateCompleted` or `ActionStateFailed` — -including refused-before-dispatch refusals routed through the -plan-drift and operator-lock guards in `executeCommandWithAudit`, -the per-execution result lane shared by `executeCommandWithAudit` -and `executeNativeActionWithAudit`, and the recovery branch when -state-machine normalization fails — calls -`publishActionCompleted(record)`, which dispatches the callback on -its own goroutine after the audit record has already been -persisted. The callback is installed once per chat-service per -org through `wireAIChatDependenciesForService` against -`chatService.GetExecutor()`, so multi-tenant chat-service rebuilds -re-wire the bridge without coupling the tools package to the api -package. API-owned execution installs `ResourceHandlers` on the -same `PublishActionCompletedRecord` projector so direct +`PulseToolExecutor` still exposes `SetOnActionCompleted(cb)` as an +executor-side seam for action-audit terminal states, and the callback is +installed once per chat-service per org through +`wireAIChatDependenciesForService` against `chatService.GetExecutor()`, but +nothing in the tools package dispatches it: `publishActionCompleted` and the +`executeCommandWithAudit` / `executeNativeActionWithAudit` lanes that called +it were removed with the unreachable Assistant mutation handlers +(`pulse_control` only plans typed actions into Pulse Actions). +API-owned execution installs `ResourceHandlers` on the +`PublishActionCompletedRecord` projector so direct `/api/actions/{id}/execute` completions and stale-plan refusals -emit the identical payload shape. `action.completed` payloads preserve the canonical +emit the `action.completed` payload. `action.completed` payloads preserve the canonical refusal-token prefixes (`plan_drift:`, `action_plan_expired:`, `action_dry_run_only:`, `resource_remediation_locked:`) on `errorMessage` verbatim so agents branch on the stable code diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 67685755da..86b534a2fb 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2861,10 +2861,9 @@ application resource-provider or WebSocket lifecycle. 21. Keep post-execution verification outcome on the canonical execution result. `ExecutionResult.Verification` carries `ActionVerificationResult` with `Ran`, `Command`, `Output`, - `Success`, `RanAt`, and `Note`. The broker runs the - class-derived read-after-write check after a successful dispatch - and persists the outcome through the existing `result_json` - column; no schema migration is required. Verification is + `Success`, `RanAt`, and `Note`. When a read-after-write check runs + after a successful dispatch, its outcome persists through the + existing `result_json` column; no schema migration is required. Verification is best-effort: classes without a derivable check leave `Verification` nil rather than fabricating a verified=true entry, matching the no-fabrication rule used elsewhere in the trust @@ -2881,13 +2880,11 @@ application resource-provider or WebSocket lifecycle. approved a different action vs approval window passed) and audit review and operator UI surfaces should treat them differently. The drift contract is "the operator approved exactly this (command, - target, reason) combination"; the freshly-recomputed - approval-equivalent hash and the recorded `planHash` are how we - enforce that. New broker call sites that introduce additional - governed-action paths must either reuse the existing approval- - equivalent hash or extend the canonical hash set in - `internal/ai/tools/action_audit.go` rather than adding ad-hoc - comparison logic. + target, reason) combination"; the freshly recomputed plan hash and + the recorded `planHash` are how we enforce that. New broker call + sites that introduce additional governed-action paths must reuse + the canonical plan hash from `internal/actionplanner/planner.go` + rather than adding ad-hoc comparison logic. 23. Keep the `ActionVerificationResult` frontend mirror canonical and pin the operator-facing render location. `frontend-modern/src/types/actionAudit.ts` defines the TS @@ -3560,13 +3557,13 @@ DELETE) in `internal/api/resources_operator_state.go` is the operator-facing consumer of this contract; the URL canonical_id always wins over the body, server-side `setAt` / `setBy` populate from request time and authenticated identity, and validation rejections surface a stable -`operator_state_invalid` error code. The action broker -(`executeCommandWithAudit`) consults the same store on every dispatch -and refuses with `ErrResourceRemediationLocked` when the operator has -set `NeverAutoRemediate=true`; the refusal is persisted as a Failed -audit record with `resource_remediation_locked:` prefix on the -`ErrorMessage` so audit-UI filters and alert rules can branch on the -stable token. Finding-suppression integrations consume the same +`operator_state_invalid` error code. The Pulse Actions lifecycle +(`validateExecutionPolicy` in `internal/actionlifecycle/service.go`) +consults the same store at the dispatch decision point and refuses with +`ErrResourceRemediationLocked` when the operator has set +`NeverAutoRemediate=true`; the API reports it as +`resource_remediation_locked` so audit-UI filters and alert rules can +branch on the stable token. Finding-suppression integrations consume the same `ResourceOperatorState` shape via the `ResourceOperatorStateProvider` interface in `internal/ai`. diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index 330d80fe6d..ee904278c6 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -888,7 +888,6 @@ func (s *Service) ExecuteStream(ctx context.Context, req ExecuteRequest, callbac if baseExecutor != nil { effectiveExecutor = baseExecutor.Clone() effectiveExecutor.SetControlLevel(effectiveControlLevel) - effectiveExecutor.SetAutonomousMode(autonomousMode) effectiveExecutor.ApplyExecutionProfile(tools.ProfileInteractiveAssistant) effectiveExecutor.SetExecuteAuthority(req.HasExecuteAuthority) effectiveExecutor.SetResolvedContext(resolvedCtx) @@ -3106,7 +3105,6 @@ func (s *Service) ListAvailableTools(ctx context.Context, prompt string) []strin autonomousMode := s.isAutonomousModeEnabled() effectiveExecutor := executor.Clone() effectiveExecutor.SetControlLevel(effectiveControlLevel) - effectiveExecutor.SetAutonomousMode(autonomousMode) effectiveExecutor.ApplyExecutionProfile(tools.ProfileInteractiveAssistant) effectiveExecutor.SetExecuteAuthority(executeAuthorityFromContext(ctx)) availableTools := s.toolsForExecutor(effectiveExecutor, autonomousMode) @@ -3702,9 +3700,6 @@ func (s *Service) SetAutonomousMode(enabled bool) { s.mu.Lock() defer s.mu.Unlock() s.autonomousMode = enabled - if s.executor != nil { - s.executor.SetContext("", "", enabled) - } if s.agenticLoop != nil { s.agenticLoop.SetAutonomousMode(enabled) } diff --git a/internal/ai/chat/service_execute_additional_test.go b/internal/ai/chat/service_execute_additional_test.go index dc2ad38695..e12768ce31 100644 --- a/internal/ai/chat/service_execute_additional_test.go +++ b/internal/ai/chat/service_execute_additional_test.go @@ -1800,7 +1800,7 @@ func TestService_ExecuteStream_RequestAutonomousOverrideClampsToolExecutor(t *te AgentServer: agentServer, ControlLevel: tools.ControlLevelAutonomous, }) - executor.SetContext("agent", "agent-1", true) + executor.SetContext("agent", "agent-1") providerCallCount := 0 provider := &stubServiceProvider{ diff --git a/internal/ai/tools/action_audit.go b/internal/ai/tools/action_audit.go index 06b5920a8a..9c0c25422c 100644 --- a/internal/ai/tools/action_audit.go +++ b/internal/ai/tools/action_audit.go @@ -1,18 +1,10 @@ package tools import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" "strings" "time" - "github.com/google/uuid" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rs/zerolog/log" @@ -20,498 +12,6 @@ import ( const approvalAuditActor = approval.RequesterPulseAssistant -// ErrRemediationLockStateUnknown is returned when the broker cannot -// determine whether the operator has set NeverAutoRemediate on the -// target resource — no audit store is wired, or the operator-state -// lookup failed. Autonomous (non-human-approved) dispatches must fail -// CLOSED on this error: a broker that cannot read the operator's -// "never touch this" flag must not assume it is unset. Human-approved -// dispatches may proceed, since the operator has explicitly signed -// off on the specific plan. -var ErrRemediationLockStateUnknown = errors.New("remediation lock state unknown; operator approval required") - -func (e *PulseToolExecutor) executeCommandWithAudit( - ctx context.Context, - capabilityName string, - resourceID string, - approvalID string, - requiresApproval bool, - agentID string, - payload agentexec.ExecuteCommandPayload, - requestedBy string, - reason string, -) (*agentexec.CommandResultPayload, error) { - if e.agentServer == nil { - return nil, fmt.Errorf("no agent server available") - } - - actionID := uuid.NewString() - requestCorrelationID := strings.TrimSpace(approvalID) - if requestCorrelationID == "" { - requestCorrelationID = actionID - } - - now := time.Now().UTC() - approvalReq := approvalRequestForID(approvalID) - planFromApproval := approvalReq != nil && approvalReq.Plan != nil - if planFromApproval { - if approvedActionID := strings.TrimSpace(approvalReq.Plan.ActionID); approvedActionID != "" { - actionID = approvedActionID - } - if approvedRequestID := strings.TrimSpace(approvalReq.Plan.RequestID); approvedRequestID != "" { - requestCorrelationID = approvedRequestID - } - } - plan := unifiedresources.ActionPlan{ - ActionID: actionID, - RequestID: requestCorrelationID, - Allowed: true, - RequiresApproval: requiresApproval, - ApprovalPolicy: func() unifiedresources.ActionApprovalLevel { - if requiresApproval { - return unifiedresources.ApprovalAdmin - } - return unifiedresources.ApprovalNone - }(), - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "", - PolicyVersion: "", - PlanHash: actionPlanHash(actionID, requestCorrelationID, capabilityName, resourceID, payload, reason), - Message: reason, - Preflight: actionAuditPreflight(resourceID, reason, now), - } - if planFromApproval { - plan = mergeApprovedActionPlan(*approvalReq.Plan, plan) - } - approvalRecords := approvalRecordsForID(approvalID, &plan) - - record := unifiedresources.ActionAuditRecord{ - ID: actionID, - CreatedAt: now, - UpdatedAt: now, - State: actionPreExecutionState(planFromApproval, approvalRecords), - Request: unifiedresources.ActionRequest{ - RequestID: requestCorrelationID, - ResourceID: strings.TrimSpace(resourceID), - CapabilityName: capabilityName, - Params: map[string]any{ - "command": payload.Command, - "targetType": payload.TargetType, - "targetId": payload.TargetID, - "agentId": agentID, - "approvalId": approvalID, - "requestedBy": requestedBy, - }, - Reason: reason, - RequestedBy: requestedBy, - }, - Plan: plan, - } - record.Approvals = approvalRecords - - // Plan-drift check: the operator approved a specific command + target + - // reason combination, and the broker must refuse to run a different one - // even when the approval ID resolves. Compares the approved hash against - // a freshly-recomputed approval-equivalent hash from the actual payload. - // Persists a refused audit record so operators can see drift caught in - // the audit history rather than only in WARN logs. - if planFromApproval { - if driftErr := validateApprovedCommandPlanHash( - plan.PlanHash, - plan.ActionID, - plan.RequestID, - capabilityName, - resourceID, - payload.Command, - payload.TargetType, - payload.TargetID, - reason, - ); driftErr != nil { - log.Warn(). - Str("action_id", plan.ActionID). - Str("approval_id", approvalID). - Str("capability", capabilityName). - Err(driftErr). - Msg("Refusing action execution: payload does not match approved plan hash") - now := time.Now().UTC() - record.State = unifiedresources.ActionStateFailed - record.UpdatedAt = now - record.Result = &unifiedresources.ExecutionResult{ - Success: false, - ErrorMessage: fmt.Sprintf("plan_drift: %s", driftErr.Error()), - } - if err := persistFailedActionAudit(e.actionAuditStore, record, requestedBy, "plan drift refused"); err != nil { - log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist plan-drift refusal") - } - e.publishActionCompleted(record) - return nil, driftErr - } - } - - // Operator-set NeverAutoRemediate refusal: when the operator has - // flagged the target resource as never-auto-remediate, the broker - // must refuse the dispatch even with a valid approval and matching - // plan hash. The operator's per-resource intent outranks the - // per-action approval — this is the safety mechanism for "do not - // touch this resource even if you think you should." When the lock - // state cannot be determined (no store, or lookup error), only a - // dispatch backed by an approved human decision may proceed; - // autonomous dispatches fail closed. Persists a Failed audit record - // with `resource_remediation_locked:` or - // `remediation_lock_state_unknown:` prefix so the audit history - // shows every refused dispatch. - if refusal := e.checkRemediationLockForDispatch(plan.ActionID, resourceID, capabilityName, hasApprovedActionApproval(approvalRecords)); refusal != nil { - e.refuseDispatchForRemediationLock(record, requestedBy, refusal) - return nil, refusal - } - - record, err := e.recordActionExecutionStart(record, approvalID, requestedBy, fmt.Sprintf("dispatching command to agent %s", agentID), planFromApproval) - if err != nil { - return nil, err - } - - 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} - - if err != nil { - finalMessage = err.Error() - executionResult.Success = false - executionResult.ErrorMessage = err.Error() - } else { - output := result.Stdout - if strings.TrimSpace(result.Stderr) != "" { - if output != "" { - output += "\n" - } - output += result.Stderr - } - executionResult.Output = output - if result.ExitCode != 0 { - finalMessage = fmt.Sprintf("exit code %d", result.ExitCode) - executionResult.Success = false - executionResult.ErrorMessage = finalMessage - } - } - - // Read-after-write verification: if execution succeeded AND the command - // class has a derivable verification check, run it via the same agent - // path and record the outcome on the audit record. Verification is - // best-effort: if no check is derivable, or the check fails to run, we - // record that fact rather than fabricating a verified=true. - if executionResult.Success { - if vCmd, ok := VerificationCommandForCommand(payload.TargetType, payload.Command); ok { - vRanAt := time.Now().UTC() - vResult, vErr := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{ - Command: vCmd, - TargetType: payload.TargetType, - TargetID: payload.TargetID, - }) - verification := &unifiedresources.ActionVerificationResult{ - Ran: true, - Command: vCmd, - RanAt: vRanAt, - } - if vErr != nil { - verification.Success = false - verification.Note = fmt.Sprintf("verification check failed to dispatch: %s", vErr.Error()) - } else { - output := strings.TrimSpace(vResult.Stdout) - if vResult.Stderr != "" { - if output != "" { - output += "\n" - } - output += strings.TrimSpace(vResult.Stderr) - } - verification.Output = output - verification.Success = vResult.ExitCode == 0 - if !verification.Success { - verification.Note = fmt.Sprintf("verification check exit code %d", vResult.ExitCode) - } - } - executionResult.Verification = verification - } - } - - e.recordActionExecutionResult(record, executionResult, requestedBy, finalMessage) - - return result, err -} - -func (e *PulseToolExecutor) executeNativeActionWithAudit( - ctx context.Context, - capabilityName string, - resourceID string, - approvalID string, - requiresApproval bool, - params map[string]any, - requestedBy string, - reason string, - execute func(context.Context) (*unifiedresources.ExecutionResult, error), -) (*unifiedresources.ExecutionResult, error) { - actionID := uuid.NewString() - requestCorrelationID := strings.TrimSpace(approvalID) - if requestCorrelationID == "" { - requestCorrelationID = actionID - } - - now := time.Now().UTC() - approvalReq := approvalRequestForID(approvalID) - planFromApproval := approvalReq != nil && approvalReq.Plan != nil - if planFromApproval { - if approvedActionID := strings.TrimSpace(approvalReq.Plan.ActionID); approvedActionID != "" { - actionID = approvedActionID - } - if approvedRequestID := strings.TrimSpace(approvalReq.Plan.RequestID); approvedRequestID != "" { - requestCorrelationID = approvedRequestID - } - } - plan := unifiedresources.ActionPlan{ - ActionID: actionID, - RequestID: requestCorrelationID, - Allowed: true, - RequiresApproval: requiresApproval, - ApprovalPolicy: func() unifiedresources.ActionApprovalLevel { - if requiresApproval { - return unifiedresources.ApprovalAdmin - } - return unifiedresources.ApprovalNone - }(), - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "", - PolicyVersion: "", - PlanHash: actionPlanHashForParams(actionID, requestCorrelationID, capabilityName, resourceID, params, reason), - Message: reason, - Preflight: actionAuditPreflight(resourceID, reason, now), - } - if planFromApproval { - plan = mergeApprovedActionPlan(*approvalReq.Plan, plan) - } - approvalRecords := approvalRecordsForID(approvalID, &plan) - - record := unifiedresources.ActionAuditRecord{ - ID: actionID, - CreatedAt: now, - UpdatedAt: now, - State: actionPreExecutionState(planFromApproval, approvalRecords), - Request: unifiedresources.ActionRequest{ - RequestID: requestCorrelationID, - ResourceID: strings.TrimSpace(resourceID), - CapabilityName: capabilityName, - Params: cloneActionParams(params), - Reason: reason, - RequestedBy: requestedBy, - }, - Plan: plan, - } - record.Approvals = approvalRecords - - // Same operator-lock gate as the agent-command dispatch path: native - // provider dispatches (e.g. TrueNAS app start/stop/restart) are write - // actions on the resource, and the operator's NeverAutoRemediate flag - // must gate them identically — including failing closed for - // autonomous dispatches when the lock state cannot be determined. - if refusal := e.checkRemediationLockForDispatch(plan.ActionID, resourceID, capabilityName, hasApprovedActionApproval(approvalRecords)); refusal != nil { - return e.refuseDispatchForRemediationLock(record, requestedBy, refusal), refusal - } - - record, err := e.recordActionExecutionStart(record, approvalID, requestedBy, "dispatching native resource action", planFromApproval) - if err != nil { - return &unifiedresources.ExecutionResult{Success: false, ErrorMessage: err.Error()}, err - } - - result, err := execute(ctx) - finalMessage := "native resource action completed" - executionResult := &unifiedresources.ExecutionResult{Success: true} - - if err != nil { - finalMessage = err.Error() - executionResult.Success = false - executionResult.ErrorMessage = err.Error() - } else if result != nil { - executionResult = result - if !result.Success { - finalMessage = strings.TrimSpace(result.ErrorMessage) - if finalMessage == "" { - finalMessage = "native resource action failed" - } - } - } - - e.recordActionExecutionResult(record, executionResult, requestedBy, finalMessage) - - return executionResult, err -} - -func actionPreExecutionState(planFromApproval bool, approvals []unifiedresources.ActionApprovalRecord) unifiedresources.ActionState { - if planFromApproval || hasApprovedActionApproval(approvals) { - return unifiedresources.ActionStateApproved - } - return unifiedresources.ActionStatePlanned -} - -func hasApprovedActionApproval(approvals []unifiedresources.ActionApprovalRecord) bool { - for _, approval := range approvals { - if approval.Outcome == unifiedresources.OutcomeApproved { - return true - } - } - return false -} - -func (e *PulseToolExecutor) recordActionExecutionStart(record unifiedresources.ActionAuditRecord, approvalID, actor, message string, planFromApproval bool) (unifiedresources.ActionAuditRecord, error) { - now := time.Now().UTC() - if strings.TrimSpace(actor) == "" { - actor = approvalAuditActor - } - if planFromApproval { - if record.Plan.ApprovalPolicy == unifiedresources.ApprovalDryRun { - refusalRecord := record - if e != nil && e.actionAuditStore != nil { - if current, found, err := e.actionAuditStore.GetActionAudit(record.ID); err == nil && found { - refusalRecord = current - } - } - return e.recordActionExecutionRefusal(refusalRecord, unifiedresources.ErrActionDryRunOnly, actor, now) - } - var err error - approvalRecord := record - record, err = e.ensureApprovalDecisionBeforeExecution(record, approvalID, actor, now) - if err != nil { - if unifiedresources.IsPermanentActionExecutionRefusal(err) { - return e.recordActionExecutionRefusal(actionExecutionRefusalRecord(record, approvalRecord), err, actor, now) - } - return record, err - } - } else if e != nil && e.actionAuditStore != nil { - plannedEvent := unifiedresources.ActionLifecycleEvent{ - ActionID: record.ID, Timestamp: now, State: unifiedresources.ActionStatePlanned, - Actor: actor, Message: strings.TrimSpace(record.Request.Reason), - } - current, _, err := e.actionAuditStore.CreateActionAudit(record, []unifiedresources.ActionLifecycleEvent{plannedEvent}) - if err != nil { - log.Warn(). - Err(err). - Str("action_id", record.ID). - Str("resource_id", record.Request.ResourceID). - Msg("failed to persist planned action audit") - return record, err - } - record = current - } - - started, event, err := unifiedresources.BeginActionExecution(record, actor, now) - if err != nil { - log.Warn(). - Err(err). - Str("action_id", record.ID). - Str("state", string(record.State)). - Msg("failed to normalize action execution start") - if unifiedresources.IsPermanentActionExecutionRefusal(err) { - return e.recordActionExecutionRefusal(record, err, actor, now) - } - return record, err - } - if strings.TrimSpace(message) != "" { - event.Message = strings.TrimSpace(message) - } - if e == nil || e.actionAuditStore == nil { - return started, nil - } - if err := e.actionAuditStore.RecordActionExecutionStart(started, event); err != nil { - log.Warn(). - Err(err). - Str("action_id", started.ID). - Str("state", string(started.State)). - Msg("failed to persist action execution start") - return record, err - } - return started, nil -} - -func (e *PulseToolExecutor) recordActionExecutionRefusal(record unifiedresources.ActionAuditRecord, reason error, actor string, now time.Time) (unifiedresources.ActionAuditRecord, error) { - failed, event, err := unifiedresources.RefuseActionExecution(record, reason, actor, now) - if err != nil { - return record, err - } - if e == nil || e.actionAuditStore == nil { - e.publishActionCompleted(failed) - return failed, reason - } - _, found, queryErr := e.actionAuditStore.GetActionAudit(failed.ID) - if queryErr != nil { - return record, queryErr - } - var persistErr error - if found { - persistErr = e.actionAuditStore.RecordActionExecutionRefusal(failed, event) - } else { - _, _, persistErr = e.actionAuditStore.CreateActionAudit(failed, []unifiedresources.ActionLifecycleEvent{event}) - } - if persistErr != nil { - log.Warn(). - Err(persistErr). - Str("action_id", failed.ID). - Str("state", string(failed.State)). - Msg("failed to persist action execution refusal") - return record, persistErr - } - e.publishActionCompleted(failed) - return failed, reason -} - -func actionExecutionRefusalRecord(record, fallback unifiedresources.ActionAuditRecord) unifiedresources.ActionAuditRecord { - if len(record.Approvals) == 0 && len(fallback.Approvals) > 0 { - record.Approvals = append([]unifiedresources.ActionApprovalRecord(nil), fallback.Approvals...) - } - if record.Request.Params == nil && len(fallback.Request.Params) > 0 { - record.Request.Params = cloneActionParams(fallback.Request.Params) - } - return record -} - -func (e *PulseToolExecutor) ensureApprovalDecisionBeforeExecution(record unifiedresources.ActionAuditRecord, approvalID, actor string, now time.Time) (unifiedresources.ActionAuditRecord, error) { - if e == nil || e.actionAuditStore == nil { - return record, nil - } - - current, ok, err := e.actionAuditStore.GetActionAudit(record.ID) - if err != nil { - log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to query action audit before execution") - return record, err - } - if !ok { - decisionEvent := unifiedresources.ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: record.State, Actor: actor, Message: "Action approval was recorded before execution."} - current, _, err := e.actionAuditStore.CreateActionAudit(record, []unifiedresources.ActionLifecycleEvent{decisionEvent}) - if err != nil { - log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist approved action audit before execution") - return record, err - } - return current, nil - } - if current.State != unifiedresources.ActionStatePending { - return current, nil - } - - approvalRecord := actionApprovalRecordForExecution(current, approvalID, actor, now, unifiedresources.OutcomeApproved) - updated, event, err := unifiedresources.ApplyActionDecision(current, approvalRecord, approvalRecord.Timestamp) - if err != nil { - log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to normalize approval decision before execution") - return current, err - } - if err := e.actionAuditStore.RecordActionDecision(updated, event); err != nil { - log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist approval decision before execution") - return current, err - } - return updated, nil -} - func actionApprovalRecordForExecution(action unifiedresources.ActionAuditRecord, approvalID, actor string, now time.Time, fallbackOutcome unifiedresources.ApprovalOutcome) unifiedresources.ActionApprovalRecord { if fallbackOutcome == "" { fallbackOutcome = unifiedresources.OutcomeApproved @@ -542,70 +42,6 @@ func actionApprovalRecordForExecution(action unifiedresources.ActionAuditRecord, } } -func (e *PulseToolExecutor) recordActionExecutionResult(record unifiedresources.ActionAuditRecord, result *unifiedresources.ExecutionResult, actor, message string) unifiedresources.ActionAuditRecord { - now := time.Now().UTC() - if strings.TrimSpace(actor) == "" { - actor = approvalAuditActor - } - completed, event, err := unifiedresources.CompleteActionExecution(record, result, actor, now) - if err != nil { - log.Warn(). - Err(err). - Str("action_id", record.ID). - Str("state", string(record.State)). - Msg("failed to normalize action execution result") - record.UpdatedAt = now - record.Result = result - if result != nil && result.Success { - record.State = unifiedresources.ActionStateCompleted - } else { - record.State = unifiedresources.ActionStateFailed - } - log.Warn().Str("action_id", record.ID).Msg("refusing to persist a synthetic terminal action result after transition normalization failed") - return record - } - if strings.TrimSpace(message) != "" { - event.Message = strings.TrimSpace(message) - } - if e == nil || e.actionAuditStore == nil { - e.publishActionCompleted(completed) - return completed - } - if err := e.actionAuditStore.RecordActionExecutionResult(completed, event); err != nil { - log.Warn(). - Err(err). - Str("action_id", completed.ID). - Str("state", string(completed.State)). - Msg("failed to persist action execution result") - } - e.publishActionCompleted(completed) - return completed -} - -// publishActionCompleted dispatches the executor's post-completion -// callback, if installed. Fire-and-forget on its own goroutine: the -// callback runs after the audit record has already been persisted, -// so a panic or stall on the consumer side must not back up the -// dispatch hot path. Snapshots the record by value so the consumer -// reads a stable view independent of any subsequent record -// mutations on the originating goroutine. -func (e *PulseToolExecutor) publishActionCompleted(record unifiedresources.ActionAuditRecord) { - if e == nil { - return - } - cb := e.onActionCompleted - if cb == nil { - return - } - if record.State != unifiedresources.ActionStateCompleted && record.State != unifiedresources.ActionStateFailed { - // Defensive — only Completed/Failed are terminal states the - // agent SSE stream surfaces. Any other state would confuse - // agents that branch on "this action is done." - return - } - go cb(record) -} - // RecordApprovalDecision updates the unified action audit for an approval that // reached a terminal or pre-execution decision state. func (e *PulseToolExecutor) RecordApprovalDecision(approvalID string, state unifiedresources.ActionState, actor, message string) { @@ -640,13 +76,6 @@ func RecordApprovalDecision(store unifiedresources.ResourceStore, approvalID str } } -func (e *PulseToolExecutor) recordApprovalDecisionAtomically(approvalID string, record unifiedresources.ActionAuditRecord, actor string) bool { - if e == nil { - return false - } - return recordApprovalDecisionAtomically(e.actionAuditStore, approvalID, record, actor) -} - func recordApprovalDecisionAtomically(store unifiedresources.ResourceStore, approvalID string, record unifiedresources.ActionAuditRecord, actor string) bool { if store == nil { return false @@ -692,130 +121,6 @@ func recordApprovalDecisionAtomically(store unifiedresources.ResourceStore, appr return true } -func (e *PulseToolExecutor) recordPendingApprovalAction(req *approval.ApprovalRequest) { - if e == nil { - return - } - RecordPendingApprovalAction(e.actionAuditStore, req) -} - -// AttachApprovalActionPlan normalizes an approval request onto the shared -// action-governance model before it is persisted by the approval store. -func AttachApprovalActionPlan(req *approval.ApprovalRequest, now time.Time) { - if req == nil { - return - } - if now.IsZero() { - now = time.Now().UTC() - } else { - now = now.UTC() - } - req.ID = strings.TrimSpace(req.ID) - if req.ID == "" { - req.ID = uuid.NewString() - } - capabilityName := approvalCapabilityForTargetType(req.TargetType) - resourceID := approvalAuditResourceID(req.TargetType, req.TargetID, req.TargetName) - if req.Plan == nil { - actionID := uuid.NewString() - req.Plan = &unifiedresources.ActionPlan{ - ActionID: actionID, - RequestID: req.ID, - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PredictedBlastRadius: approvalBlastRadius(req.TargetType, req.Command), - RollbackAvailable: approvalRollbackAvailable(req.TargetType, req.Command), - Message: strings.TrimSpace(req.Context), - PlannedAt: now, - PlanHash: approvalPlanHash(actionID, req.ID, capabilityName, resourceID, req.Command, req.TargetType, req.TargetID, req.Context), - } - } - if strings.TrimSpace(req.Plan.ActionID) == "" { - req.Plan.ActionID = uuid.NewString() - } - if strings.TrimSpace(req.Plan.RequestID) == "" { - req.Plan.RequestID = req.ID - } - if req.Plan.PlannedAt.IsZero() { - req.Plan.PlannedAt = now - } - if strings.TrimSpace(req.Plan.Message) == "" { - req.Plan.Message = strings.TrimSpace(req.Context) - } - req.Plan.Allowed = true - req.Plan.RequiresApproval = true - if req.Plan.ApprovalPolicy == "" { - req.Plan.ApprovalPolicy = unifiedresources.ApprovalAdmin - } - if strings.TrimSpace(req.Plan.PlanHash) == "" { - req.Plan.PlanHash = approvalPlanHash(req.Plan.ActionID, req.Plan.RequestID, capabilityName, resourceID, req.Command, req.TargetType, req.TargetID, req.Context) - } - req.ContextConfidence = approvalContextConfidence(req) - req.Preflight = approvalPreflight(req) - req.Plan.Preflight = req.Preflight -} - -// RecordPendingApprovalAction persists the planned and pending audit state for -// a newly-created governed approval request. -func RecordPendingApprovalAction(store unifiedresources.ResourceStore, req *approval.ApprovalRequest) { - if store == nil || req == nil || req.Plan == nil { - return - } - actor := approval.RequesterForRequest(req) - record := actionAuditRecordFromApproval(req, unifiedresources.ActionStatePending, actor) - events := []unifiedresources.ActionLifecycleEvent{ - {ActionID: req.Plan.ActionID, State: unifiedresources.ActionStatePlanned, Timestamp: record.CreatedAt, Actor: actor, Message: strings.TrimSpace(req.Context)}, - {ActionID: req.Plan.ActionID, State: unifiedresources.ActionStatePending, Timestamp: record.CreatedAt, Actor: actor, Message: "waiting for approval"}, - } - if _, _, err := store.CreateActionAudit(record, events); err != nil { - log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist pending approval action") - } -} - -func recordApprovalLifecycle(store unifiedresources.ResourceStore, actionID string, state unifiedresources.ActionState, actor, message string) { - event := unifiedresources.ActionLifecycleEvent{ - ActionID: strings.TrimSpace(actionID), - State: state, - Timestamp: time.Now().UTC(), - Actor: strings.TrimSpace(actor), - Message: strings.TrimSpace(message), - } - if event.Actor == "" { - event.Actor = approvalAuditActor - } - if err := store.RecordActionLifecycleEvent(event); err != nil { - log.Warn().Err(err).Str("action_id", actionID).Str("state", string(state)).Msg("failed to persist pending approval lifecycle event") - } -} - -func mergeApprovedActionPlan(approved unifiedresources.ActionPlan, fallback unifiedresources.ActionPlan) unifiedresources.ActionPlan { - if strings.TrimSpace(approved.ActionID) == "" { - approved.ActionID = fallback.ActionID - } - if strings.TrimSpace(approved.RequestID) == "" { - approved.RequestID = fallback.RequestID - } - if approved.PlannedAt.IsZero() { - approved.PlannedAt = fallback.PlannedAt - } - if approved.ExpiresAt.IsZero() { - approved.ExpiresAt = fallback.ExpiresAt - } - if strings.TrimSpace(approved.PlanHash) == "" { - approved.PlanHash = fallback.PlanHash - } - if strings.TrimSpace(approved.Message) == "" { - approved.Message = fallback.Message - } - approved.Allowed = true - approved.RequiresApproval = approved.RequiresApproval || fallback.RequiresApproval - if approved.ApprovalPolicy == "" { - approved.ApprovalPolicy = fallback.ApprovalPolicy - } - return approved -} - func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedresources.ActionState, _ string) unifiedresources.ActionAuditRecord { now := time.Now().UTC() plan := *req.Plan @@ -924,98 +229,6 @@ func approvalAuditResourceID(targetType, targetID, targetName string) string { return targetType + ":" + targetID } -// validateApprovedCommandPlanHash recomputes the approval-equivalent plan -// hash from the actual payload presented at execute time and compares it to -// the hash recorded at approval time. If approvedHash is empty (older -// approval records, or contract paths that did not author one), validation -// is skipped and existing behavior is preserved. If the hashes differ, the -// broker must refuse execution: the operator approved a specific command + -// target + reason combination, and a different one is now being requested. -// -// The hash function used here matches `approvalPlanHash` (the function -// invoked at approval-creation time) so direct comparison is meaningful. -// `actionPlanHash` shape differs slightly (no field-level whitespace -// trimming) and would produce false-positive drift on legitimate payloads. -func validateApprovedCommandPlanHash( - approvedHash string, - actionID, requestID, capabilityName, resourceID string, - command, targetType, targetID, reason string, -) error { - approvedHash = strings.TrimSpace(approvedHash) - if approvedHash == "" { - return nil - } - fresh := approvalPlanHash(actionID, requestID, capabilityName, resourceID, command, targetType, targetID, reason) - if fresh == approvedHash { - return nil - } - return fmt.Errorf("%w (approved %s, payload %s)", unifiedresources.ErrActionPlanDrift, approvedHash, fresh) -} - -func actionPlanHash(actionID, requestID, capabilityName, resourceID string, payload agentexec.ExecuteCommandPayload, reason string) string { - sum := sha256.Sum256([]byte(strings.Join([]string{ - actionID, - requestID, - capabilityName, - strings.TrimSpace(resourceID), - payload.Command, - payload.TargetType, - payload.TargetID, - reason, - }, "|"))) - return hex.EncodeToString(sum[:]) -} - -func actionPlanHashForParams(actionID, requestID, capabilityName, resourceID string, params map[string]any, reason string) string { - encoded, _ := json.Marshal(cloneActionParams(params)) - sum := sha256.Sum256([]byte(strings.Join([]string{ - actionID, - requestID, - capabilityName, - strings.TrimSpace(resourceID), - string(encoded), - reason, - }, "|"))) - return hex.EncodeToString(sum[:]) -} - -func approvalPlanHash(actionID, requestID, capabilityName, resourceID, command, targetType, targetID, context string) string { - sum := sha256.Sum256([]byte(strings.Join([]string{ - actionID, - requestID, - capabilityName, - strings.TrimSpace(resourceID), - command, - strings.TrimSpace(targetType), - strings.TrimSpace(targetID), - strings.TrimSpace(context), - }, "|"))) - return hex.EncodeToString(sum[:]) -} - -func cloneActionParams(params map[string]any) map[string]any { - if len(params) == 0 { - return nil - } - cloned := make(map[string]any, len(params)) - for key, value := range params { - cloned[key] = value - } - return cloned -} - -func actionAuditPreflight(resourceID, reason string, generatedAt time.Time) *unifiedresources.ActionPreflight { - return unifiedresources.NormalizeActionPreflight(&unifiedresources.ActionPreflight{ - Target: strings.TrimSpace(resourceID), - IntendedChange: strings.TrimSpace(reason), - DryRunAvailable: false, - DryRunSummary: "No provider-supported dry run is available for this action.", - SafetyChecks: []string{"Approval and execution are scoped to the resolved resource."}, - VerificationSteps: []string{"Read back the target state after execution."}, - GeneratedAt: generatedAt, - }, unifiedresources.ActionRequest{ResourceID: resourceID, Reason: reason}, unifiedresources.ActionPlan{PlannedAt: generatedAt, Message: reason}) -} - func approvalRecordsForID(approvalID string, plan *unifiedresources.ActionPlan) []unifiedresources.ActionApprovalRecord { approvalID = strings.TrimSpace(approvalID) if approvalID == "" { @@ -1079,131 +292,3 @@ func approvalTimestamp(req *approval.ApprovalRequest) time.Time { } return req.DecidedAt.UTC() } - -// isResourceRemediationLocked returns whether the operator has set -// NeverAutoRemediate=true on the resource the dispatch targets. -// Returns (false, nil) when the resource has no operator state, or -// when only IntentionallyOffline (without NeverAutoRemediate) is set. -// When the lock state cannot be determined — no audit store is wired, -// or the store lookup fails — it returns an error wrapping -// ErrRemediationLockStateUnknown so the caller can -// distinguish "not locked" from "unknown". Caller policy lives in -// checkRemediationLockForDispatch: autonomous (non-human-approved) -// dispatches fail closed on unknown state; human-approved dispatches -// keep the historical fail-open posture. -func (e *PulseToolExecutor) isResourceRemediationLocked(resourceID string) (bool, error) { - canonical := strings.TrimSpace(resourceID) - if canonical == "" { - return false, nil - } - if e == nil || e.actionAuditStore == nil { - return false, fmt.Errorf("%w: no action audit store is wired", ErrRemediationLockStateUnknown) - } - state, found, err := e.actionAuditStore.GetResourceOperatorState(canonical) - if err != nil { - return false, fmt.Errorf("%w: operator-state lookup failed: %s", ErrRemediationLockStateUnknown, err.Error()) - } - if !found { - return false, nil - } - return state.BlocksRemediation(), nil -} - -// checkRemediationLockForDispatch applies the operator NeverAutoRemediate -// policy at the dispatch decision point. Returns nil when the dispatch may -// proceed, ErrResourceRemediationLocked when the operator has locked the -// resource, or an ErrRemediationLockStateUnknown-wrapped error when the -// lock state cannot be determined and the dispatch carries no approved -// human decision. Autonomous dispatches fail CLOSED on unknown lock state: -// a broker that cannot read the operator's "never touch this" flag must -// not assume it is unset. Human-approved dispatches keep the historical -// fail-open posture — the operator explicitly signed off on this exact -// plan, so a degraded operator-state lookup does not override their -// decision — but the degraded lookup is still logged. -func (e *PulseToolExecutor) checkRemediationLockForDispatch(actionID, resourceID, capabilityName string, humanApproved bool) error { - locked, lockErr := e.isResourceRemediationLocked(resourceID) - if lockErr != nil { - if humanApproved { - log.Warn(). - Str("action_id", actionID). - Str("resource_id", resourceID). - Str("capability", capabilityName). - Err(lockErr). - Msg("Remediation lock state unknown; allowing human-approved dispatch (fail-open) but logging") - return nil - } - log.Warn(). - Str("action_id", actionID). - Str("resource_id", resourceID). - Str("capability", capabilityName). - Err(lockErr). - Msg("Refusing autonomous action execution: remediation lock state unknown (fail-closed); operator approval required") - return lockErr - } - if locked { - log.Warn(). - Str("action_id", actionID). - Str("resource_id", resourceID). - Str("capability", capabilityName). - Msg("Refusing action execution: resource is operator-locked against automated remediation") - return unifiedresources.ErrResourceRemediationLocked - } - return nil -} - -// refuseDispatchForRemediationLock persists a Failed audit record for a -// dispatch refused by the remediation-lock gate and returns the -// ExecutionResult describing the refusal. The ErrorMessage carries a -// stable prefix (`resource_remediation_locked:` or -// `remediation_lock_state_unknown:`) so audit-UI filters and alert rules -// can branch on the token. -func (e *PulseToolExecutor) refuseDispatchForRemediationLock(record unifiedresources.ActionAuditRecord, requestedBy string, refusal error) *unifiedresources.ExecutionResult { - now := time.Now().UTC() - record.State = unifiedresources.ActionStateFailed - record.UpdatedAt = now - result := &unifiedresources.ExecutionResult{ - Success: false, - ErrorMessage: remediationLockRefusalMessage(refusal), - } - record.Result = result - if err := persistFailedActionAudit(e.actionAuditStore, record, requestedBy, remediationLockLifecycleMessage(refusal)); err != nil { - log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist remediation-lock refusal") - } - e.publishActionCompleted(record) - return result -} - -func persistFailedActionAudit(store unifiedresources.ResourceStore, record unifiedresources.ActionAuditRecord, actor, message string) error { - if store == nil { - return nil - } - event := unifiedresources.ActionLifecycleEvent{ActionID: record.ID, Timestamp: record.UpdatedAt, State: unifiedresources.ActionStateFailed, Actor: actor, Message: message} - current, found, err := store.GetActionAudit(record.ID) - if err != nil { - return err - } - if found { - current.State = unifiedresources.ActionStateFailed - current.UpdatedAt = record.UpdatedAt - current.Result = record.Result - current.Verification = record.Verification - current.VerificationOutcome = record.VerificationOutcome - return store.RecordActionExecutionRefusal(current, event) - } - _, _, err = store.CreateActionAudit(record, []unifiedresources.ActionLifecycleEvent{event}) - return err -} - -func remediationLockRefusalMessage(refusal error) string { - if errors.Is(refusal, ErrRemediationLockStateUnknown) { - return fmt.Sprintf("remediation_lock_state_unknown: %s", refusal.Error()) - } - return fmt.Sprintf("resource_remediation_locked: %s", refusal.Error()) -} - -func remediationLockLifecycleMessage(refusal error) string { - if errors.Is(refusal, ErrRemediationLockStateUnknown) { - return "remediation lock state unknown refused" - } - return "resource remediation lock refused" -} diff --git a/internal/ai/tools/action_audit_execution_test.go b/internal/ai/tools/action_audit_execution_test.go index 854e04cb66..e6c6870ee9 100644 --- a/internal/ai/tools/action_audit_execution_test.go +++ b/internal/ai/tools/action_audit_execution_test.go @@ -1,174 +1,15 @@ package tools import ( - "context" - "errors" "strings" "testing" "time" - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" - "github.com/stretchr/testify/mock" ) -const ( - testAuditVerificationCommandRedacted = "[redacted-verification-command]" - testAuditVerificationOutputRedacted = "[redacted-verification-output]" - testAuditVerificationNoteRedacted = "[redacted-verification-note]" -) - -func TestExecuteCommandWithAuditUsesExecutionStateMachine(t *testing.T) { - store := unifiedresources.NewMemoryStore() - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "uptime" && payload.TargetType == "agent" && payload.TargetID == "agent-1" - })).Return(&agentexec.CommandResultPayload{ - Success: true, - Stdout: "ok", - ExitCode: 0, - }, nil).Once() - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: store, - }) - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "node-1", - "", - false, - "agent-1", - agentexec.ExecuteCommandPayload{ - Command: "uptime", - TargetType: "agent", - TargetID: "agent-1", - }, - "pulse_control", - "run uptime", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit: %v", err) - } - if result == nil || result.Stdout != "ok" { - t.Fatalf("command result = %#v", result) - } - - audits, err := store.GetActionAudits("node-1", time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionAudits: %v", err) - } - if len(audits) != 1 { - t.Fatalf("audits len = %d, want 1", len(audits)) - } - audit := audits[0] - if audit.State != unifiedresources.ActionStateCompleted || audit.Result == nil || audit.Result.Output != "ok" { - t.Fatalf("audit = %#v", audit) - } - - events, err := store.GetActionLifecycleEvents(audit.ID, time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionLifecycleEvents: %v", err) - } - seen := map[unifiedresources.ActionState]string{} - for _, event := range events { - seen[event.State] = event.Message - } - for _, state := range []unifiedresources.ActionState{ - unifiedresources.ActionStatePlanned, - unifiedresources.ActionStateExecuting, - unifiedresources.ActionStateCompleted, - } { - if _, ok := seen[state]; !ok { - t.Fatalf("missing lifecycle state %q in %#v", state, events) - } - } - if seen[unifiedresources.ActionStateExecuting] != "dispatching command to agent agent-1" { - t.Fatalf("executing event message = %q", seen[unifiedresources.ActionStateExecuting]) - } - agentServer.AssertExpectations(t) -} - -func TestAttachApprovalActionPlanRecordsPendingActionAudit(t *testing.T) { - store := unifiedresources.NewMemoryStore() - now := time.Date(2026, 5, 7, 9, 30, 0, 0, time.UTC) - req := &approval.ApprovalRequest{ - ID: " approval-patrol-queued ", - OrgID: "tenant-1", - ToolID: "investigation_fix", - Command: "systemctl restart nginx", - TargetType: "investigation", - TargetID: "finding-123", - TargetName: "web-1", - Context: "Restart nginx after Patrol investigation", - RiskLevel: approval.RiskHigh, - } - - AttachApprovalActionPlan(req, now) - - if req.ID != "approval-patrol-queued" { - t.Fatalf("approval id = %q", req.ID) - } - if req.Plan == nil { - t.Fatal("expected governed action plan") - } - if req.Plan.ActionID == "" { - t.Fatalf("expected action id: %#v", req.Plan) - } - if req.Plan.RequestID != req.ID { - t.Fatalf("plan request id = %q, want %q", req.Plan.RequestID, req.ID) - } - if !req.Plan.Allowed || !req.Plan.RequiresApproval || req.Plan.ApprovalPolicy != unifiedresources.ApprovalAdmin { - t.Fatalf("approval plan posture = %#v", req.Plan) - } - if !req.Plan.PlannedAt.Equal(now) { - t.Fatalf("planned at = %s, want %s", req.Plan.PlannedAt, now) - } - if req.Preflight == nil || req.ContextConfidence == nil || req.Plan.Preflight != req.Preflight { - t.Fatalf("expected preflight and context confidence on approval: %#v", req) - } - - RecordPendingApprovalAction(store, req) - - audit, ok, err := store.GetActionAudit(req.Plan.ActionID) - if err != nil { - t.Fatalf("GetActionAudit: %v", err) - } - if !ok { - t.Fatal("expected pending approval action audit") - } - if audit.State != unifiedresources.ActionStatePending { - t.Fatalf("audit state = %q, want pending", audit.State) - } - if audit.Request.RequestID != req.ID { - t.Fatalf("audit request id = %q, want %q", audit.Request.RequestID, req.ID) - } - if audit.Request.ResourceID != "investigation:finding-123" { - t.Fatalf("audit resource id = %q", audit.Request.ResourceID) - } - if audit.Request.RequestedBy != approval.RequesterPulsePatrol { - t.Fatalf("audit requested by = %q", audit.Request.RequestedBy) - } - - events, err := store.GetActionLifecycleEvents(req.Plan.ActionID, time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionLifecycleEvents: %v", err) - } - states := map[unifiedresources.ActionState]bool{} - for _, event := range events { - states[event.State] = true - if event.Actor != approval.RequesterPulsePatrol { - t.Fatalf("lifecycle actor = %q, want %q", event.Actor, approval.RequesterPulsePatrol) - } - } - if !states[unifiedresources.ActionStatePlanned] || !states[unifiedresources.ActionStatePending] { - t.Fatalf("expected planned and pending lifecycle states, got %#v", events) - } -} - -func TestExecuteNativeActionWithApprovedAuditRecordsDecisionBeforeExecution(t *testing.T) { +func TestRecordApprovalDecisionDoesNotRegressExecutingAudit(t *testing.T) { actionStore := unifiedresources.NewMemoryStore() approvalStore, err := approval.NewStore(approval.StoreConfig{ DataDir: t.TempDir(), @@ -183,8 +24,8 @@ func TestExecuteNativeActionWithApprovedAuditRecordsDecisionBeforeExecution(t *t now := time.Now().UTC() plan := unifiedresources.ActionPlan{ - ActionID: "act-approved-native", - RequestID: "approval-native", + ActionID: "act-decision-no-regress", + RequestID: "approval-decision-no-regress", Allowed: true, RequiresApproval: true, ApprovalPolicy: unifiedresources.ApprovalAdmin, @@ -195,73 +36,50 @@ func TestExecuteNativeActionWithApprovedAuditRecordsDecisionBeforeExecution(t *t PlanHash: "sha256:test", } req := &approval.ApprovalRequest{ - ID: "approval-native", - Command: "restart app", - TargetType: "docker", - TargetID: "docker-host:app", - TargetName: "app", - Context: "restart app during maintenance", + ID: "approval-decision-no-regress", + Command: "restart service", + TargetType: "agent", + TargetID: "agent-1", + TargetName: "agent-1", + Context: "restart service during maintenance", Plan: &plan, } if err := approvalStore.CreateApproval(req); err != nil { t.Fatalf("CreateApproval: %v", err) } - - executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: actionStore}) - executor.recordPendingApprovalAction(req) - if _, err := approvalStore.Approve("approval-native", "operator@example.com"); err != nil { + if _, err := approvalStore.Approve("approval-decision-no-regress", "operator@example.com"); err != nil { t.Fatalf("Approve: %v", err) } - - result, err := executor.executeNativeActionWithAudit( - context.Background(), - "pulse_docker", - "docker-host:app", - "approval-native", - true, - map[string]any{"action": "restart"}, - "pulse_control", - "restart app during maintenance", - func(context.Context) (*unifiedresources.ExecutionResult, error) { - return &unifiedresources.ExecutionResult{Success: false, ErrorMessage: "provider rejected restart"}, nil - }, - ) - if err != nil { - t.Fatalf("executeNativeActionWithAudit: %v", err) - } - if result == nil || result.Success || result.ErrorMessage != "provider rejected restart" { - t.Fatalf("execution result = %#v", result) + executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: actionStore}) + record := actionAuditRecordFromApproval(req, unifiedresources.ActionStateExecuting, "pulse_control") + record.Approvals = approvalRecordsForID(req.ID, req.Plan) + if err := actionStore.RecordActionAudit(record); err != nil { + t.Fatalf("RecordActionAudit: %v", err) } - audit, ok, err := actionStore.GetActionAudit("act-approved-native") + executor.RecordApprovalDecision("approval-decision-no-regress", unifiedresources.ActionStateApproved, "operator@example.com", "approval granted") + + audit, ok, err := actionStore.GetActionAudit("act-decision-no-regress") if err != nil { t.Fatalf("GetActionAudit: %v", err) } - if !ok || audit.State != unifiedresources.ActionStateFailed || len(audit.Approvals) != 1 || audit.Result == nil { + if !ok || audit.State != unifiedresources.ActionStateExecuting || audit.Result != nil { t.Fatalf("audit = %#v, ok=%v", audit, ok) } - if audit.Approvals[0].Actor != "operator@example.com" || audit.Approvals[0].Outcome != unifiedresources.OutcomeApproved { - t.Fatalf("approval audit = %#v", audit.Approvals[0]) - } +} - events, err := actionStore.GetActionLifecycleEvents("act-approved-native", time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionLifecycleEvents: %v", err) +// seedPendingApprovalAudit persists the planned and pending audit state a +// governed approval request carries before an operator decides it. +func seedPendingApprovalAudit(t *testing.T, store unifiedresources.ResourceStore, req *approval.ApprovalRequest) { + t.Helper() + actor := approval.RequesterForRequest(req) + record := actionAuditRecordFromApproval(req, unifiedresources.ActionStatePending, actor) + events := []unifiedresources.ActionLifecycleEvent{ + {ActionID: req.Plan.ActionID, State: unifiedresources.ActionStatePlanned, Timestamp: record.CreatedAt, Actor: actor, Message: strings.TrimSpace(req.Context)}, + {ActionID: req.Plan.ActionID, State: unifiedresources.ActionStatePending, Timestamp: record.CreatedAt, Actor: actor, Message: "waiting for approval"}, } - seen := map[unifiedresources.ActionState]bool{} - for _, event := range events { - seen[event.State] = true - } - for _, state := range []unifiedresources.ActionState{ - unifiedresources.ActionStatePlanned, - unifiedresources.ActionStatePending, - unifiedresources.ActionStateApproved, - unifiedresources.ActionStateExecuting, - unifiedresources.ActionStateFailed, - } { - if !seen[state] { - t.Fatalf("missing lifecycle state %q in %#v", state, events) - } + if _, _, err := store.CreateActionAudit(record, events); err != nil { + t.Fatalf("CreateActionAudit: %v", err) } } @@ -304,7 +122,7 @@ func TestRecordApprovalDecisionUsesActionDecisionStoreContract(t *testing.T) { t.Fatalf("CreateApproval: %v", err) } executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: actionStore}) - executor.recordPendingApprovalAction(req) + seedPendingApprovalAudit(t, actionStore, req) if _, err := approvalStore.Approve("approval-decision", "operator@example.com"); err != nil { t.Fatalf("Approve: %v", err) } @@ -378,8 +196,7 @@ func TestRecordApprovalDecisionUsesRejectedActionDecisionStoreContract(t *testin if err := approvalStore.CreateApproval(req); err != nil { t.Fatalf("CreateApproval: %v", err) } - executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: actionStore}) - executor.recordPendingApprovalAction(req) + seedPendingApprovalAudit(t, actionStore, req) if _, err := approvalStore.Deny("approval-rejected-decision", "operator@example.com", "outside maintenance"); err != nil { t.Fatalf("Deny: %v", err) } @@ -417,1144 +234,3 @@ func TestRecordApprovalDecisionUsesRejectedActionDecisionStoreContract(t *testin } } } - -func TestRecordApprovalDecisionDoesNotRegressExecutingAudit(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - approvalStore, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - previousApprovalStore := approval.GetStore() - approval.SetStore(approvalStore) - t.Cleanup(func() { approval.SetStore(previousApprovalStore) }) - - now := time.Now().UTC() - plan := unifiedresources.ActionPlan{ - ActionID: "act-decision-no-regress", - RequestID: "approval-decision-no-regress", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: "sha256:test", - } - req := &approval.ApprovalRequest{ - ID: "approval-decision-no-regress", - Command: "restart service", - TargetType: "agent", - TargetID: "agent-1", - TargetName: "agent-1", - Context: "restart service during maintenance", - Plan: &plan, - } - if err := approvalStore.CreateApproval(req); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - if _, err := approvalStore.Approve("approval-decision-no-regress", "operator@example.com"); err != nil { - t.Fatalf("Approve: %v", err) - } - executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: actionStore}) - record := actionAuditRecordFromApproval(req, unifiedresources.ActionStateExecuting, "pulse_control") - record.Approvals = approvalRecordsForID(req.ID, req.Plan) - if err := actionStore.RecordActionAudit(record); err != nil { - t.Fatalf("RecordActionAudit: %v", err) - } - - executor.RecordApprovalDecision("approval-decision-no-regress", unifiedresources.ActionStateApproved, "operator@example.com", "approval granted") - - audit, ok, err := actionStore.GetActionAudit("act-decision-no-regress") - if err != nil { - t.Fatalf("GetActionAudit: %v", err) - } - if !ok || audit.State != unifiedresources.ActionStateExecuting || audit.Result != nil { - t.Fatalf("audit = %#v, ok=%v", audit, ok) - } -} - -// TestExecuteCommandWithAuditRefusesPayloadDriftAgainstApprovedPlan covers the -// safety guarantee that the operator approved exactly one (command, target, -// reason) combination. If the payload presented at execute time hashes to -// anything different than the approved plan, the broker must refuse rather -// than dispatch a drifted command under a stale approval. -func TestExecuteCommandWithAuditRefusesPayloadDriftAgainstApprovedPlan(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - approvalStore, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - previousApprovalStore := approval.GetStore() - approval.SetStore(approvalStore) - t.Cleanup(func() { approval.SetStore(previousApprovalStore) }) - - now := time.Now().UTC() - approvedHash := approvalPlanHash( - "act-drift", - "approval-drift", - "pulse_control", - "agent-1", - "systemctl restart workload", - "agent", - "agent-1", - "restart workload service", - ) - plan := unifiedresources.ActionPlan{ - ActionID: "act-drift", - RequestID: "approval-drift", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - PlanHash: approvedHash, - } - req := &approval.ApprovalRequest{ - ID: "approval-drift", - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-1", - TargetName: "agent-1", - Context: "restart workload service", - Plan: &plan, - } - if err := approvalStore.CreateApproval(req); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - if _, err := approvalStore.Approve("approval-drift", "operator@example.com"); err != nil { - t.Fatalf("Approve: %v", err) - } - - agentServer := &mockAgentServer{} - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - executor.recordPendingApprovalAction(req) - - // Payload deliberately differs from the approved command. The hash check - // must catch this and refuse. - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-1", - "approval-drift", - true, - "agent-1", - agentexec.ExecuteCommandPayload{ - Command: "rm -rf /var/log/pulse", - TargetType: "agent", - TargetID: "agent-1", - }, - "pulse_control", - "restart workload service", - ) - if !errors.Is(err, unifiedresources.ErrActionPlanDrift) { - t.Fatalf("executeCommandWithAudit error = %v, want ErrActionPlanDrift", err) - } - if result != nil { - t.Fatalf("expected nil result on drift refusal, got %#v", result) - } - agentServer.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) - - // Drift refusal must be observable in the audit history, not just in - // WARN logs. Operators reviewing the action audit trail need to see - // "Pulse caught this drift attempt" recorded as a Failed action with - // a plan_drift error message. - audits, err := actionStore.GetActionAudits("agent:agent-1", time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionAudits: %v", err) - } - if len(audits) != 1 { - t.Fatalf("expected 1 drift-refused audit record, got %d", len(audits)) - } - driftAudit := audits[0] - if driftAudit.State != unifiedresources.ActionStateFailed { - t.Fatalf("drift audit state = %q, want %q", driftAudit.State, unifiedresources.ActionStateFailed) - } - if driftAudit.Result == nil || driftAudit.Result.Success { - t.Fatalf("expected drift audit Result.Success=false, got %#v", driftAudit.Result) - } - if driftAudit.Result == nil || !strings.Contains(driftAudit.Result.ErrorMessage, "plan_drift") { - t.Fatalf("expected drift audit ErrorMessage to include plan_drift, got %q", driftAudit.Result.ErrorMessage) - } -} - -// TestExecuteCommandWithAuditRunsClassDerivedVerificationAfterDispatch -// covers the read-after-write loop: when a successful dispatch matches a -// known command class, the broker derives the verification command and -// runs it via the same agent path. The result is persisted on the audit -// record's ExecutionResult.Verification so the audit history shows not -// only what the action did but whether the read-back confirmed it. -func TestExecuteCommandWithAuditRunsClassDerivedVerificationAfterDispatch(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - - agentServer := &mockAgentServer{} - // Dispatch: systemctl restart nginx — succeeds. - agentServer.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "systemctl restart nginx" - })).Return(&agentexec.CommandResultPayload{ - Success: true, - Stdout: "", - ExitCode: 0, - }, nil).Once() - // Verification: systemctl is-active nginx — succeeds with "active". - agentServer.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "systemctl is-active 'nginx'" - })).Return(&agentexec.CommandResultPayload{ - Success: true, - Stdout: "active", - ExitCode: 0, - }, nil).Once() - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - _, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "node-1", - "", - false, - "agent-1", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart nginx", - TargetType: "agent", - TargetID: "agent-1", - }, - "pulse_control", - "restart nginx after Patrol detected stale config", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit: %v", err) - } - - audits, err := actionStore.GetActionAudits("node-1", time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionAudits: %v", err) - } - if len(audits) != 1 { - t.Fatalf("audits len = %d, want 1", len(audits)) - } - verification := audits[0].Result.Verification - if verification == nil { - t.Fatalf("expected ExecutionResult.Verification to be populated, got nil") - } - if !verification.Ran { - t.Fatalf("expected Verification.Ran=true") - } - if verification.Command != testAuditVerificationCommandRedacted { - t.Fatalf("Verification.Command = %q, want stored audit redaction marker", verification.Command) - } - if !verification.Success { - t.Fatalf("expected Verification.Success=true after exit code 0") - } - if verification.Output != testAuditVerificationOutputRedacted { - t.Fatalf("Verification.Output = %q, want stored audit redaction marker", verification.Output) - } - agentServer.AssertExpectations(t) -} - -// TestExecuteCommandWithAuditMarksVerificationFailedWhenReadbackDoesNotConfirm -// covers the negative case: dispatch succeeded, but the read-after-write -// check returned a non-zero exit code (e.g. service is "failed" not -// "active"). Verification.Success must reflect the read-back outcome -// rather than the dispatch outcome so the audit row honestly shows that -// Pulse ran the action but couldn't confirm it took. -func TestExecuteCommandWithAuditMarksVerificationFailedWhenReadbackDoesNotConfirm(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "systemctl restart workload" - })).Return(&agentexec.CommandResultPayload{ - Success: true, - ExitCode: 0, - }, nil).Once() - agentServer.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "systemctl is-active 'workload'" - })).Return(&agentexec.CommandResultPayload{ - Success: false, - Stdout: "failed", - ExitCode: 3, - }, nil).Once() - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - _, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "node-1", - "", - false, - "agent-1", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-1", - }, - "pulse_control", - "restart workload after backup window", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit: %v", err) - } - - audits, _ := actionStore.GetActionAudits("node-1", time.Time{}, 10) - verification := audits[0].Result.Verification - if verification == nil || !verification.Ran { - t.Fatalf("expected verification ran, got %#v", verification) - } - if verification.Success { - t.Fatalf("expected Verification.Success=false after exit code 3") - } - if verification.Output != testAuditVerificationOutputRedacted { - t.Fatalf("Verification.Output = %q, want stored audit redaction marker", verification.Output) - } - if verification.Note != testAuditVerificationNoteRedacted { - t.Fatalf("Verification.Note = %q, want stored audit redaction marker", verification.Note) - } -} - -// TestExecuteCommandWithAuditSkipsVerificationForUnclassifiedCommands -// covers the no-fabrication boundary: when no verification command is -// derivable for the action class, the broker leaves Verification nil -// rather than recording a fake "verified" entry. -func TestExecuteCommandWithAuditSkipsVerificationForUnclassifiedCommands(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "echo hello" - })).Return(&agentexec.CommandResultPayload{ - Success: true, - Stdout: "hello", - ExitCode: 0, - }, nil).Once() - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - _, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "node-1", - "", - false, - "agent-1", - agentexec.ExecuteCommandPayload{ - Command: "echo hello", - TargetType: "agent", - TargetID: "agent-1", - }, - "pulse_control", - "echo greeting", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit: %v", err) - } - - audits, _ := actionStore.GetActionAudits("node-1", time.Time{}, 10) - if audits[0].Result.Verification != nil { - t.Fatalf("expected nil Verification for unclassified command, got %#v", audits[0].Result.Verification) - } - // Exactly one ExecuteCommand call expected (no verification dispatch). - agentServer.AssertExpectations(t) -} - -// TestExecuteCommandWithAuditAllowsMatchingPlanHash covers the positive case: -// when the payload at execute time hashes identically to the approved plan, -// dispatch proceeds normally. -func TestExecuteCommandWithAuditAllowsMatchingPlanHash(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - approvalStore, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - previousApprovalStore := approval.GetStore() - approval.SetStore(approvalStore) - t.Cleanup(func() { approval.SetStore(previousApprovalStore) }) - - now := time.Now().UTC() - approvedHash := approvalPlanHash( - "act-match", - "approval-match", - "pulse_control", - "agent-1", - "uptime", - "agent", - "agent-1", - "check uptime", - ) - plan := unifiedresources.ActionPlan{ - ActionID: "act-match", - RequestID: "approval-match", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - PlanHash: approvedHash, - } - req := &approval.ApprovalRequest{ - ID: "approval-match", - Command: "uptime", - TargetType: "agent", - TargetID: "agent-1", - TargetName: "agent-1", - Context: "check uptime", - Plan: &plan, - } - if err := approvalStore.CreateApproval(req); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - if _, err := approvalStore.Approve("approval-match", "operator@example.com"); err != nil { - t.Fatalf("Approve: %v", err) - } - - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-1", mock.Anything).Return(&agentexec.CommandResultPayload{ - Success: true, - Stdout: "up 4 days", - ExitCode: 0, - }, nil).Once() - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - executor.recordPendingApprovalAction(req) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-1", - "approval-match", - true, - "agent-1", - agentexec.ExecuteCommandPayload{ - Command: "uptime", - TargetType: "agent", - TargetID: "agent-1", - }, - "pulse_control", - "check uptime", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit: %v", err) - } - if result == nil || result.Stdout != "up 4 days" { - t.Fatalf("command result = %#v", result) - } - agentServer.AssertExpectations(t) -} - -func TestExecuteCommandWithDeniedApprovalDoesNotDispatch(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - approvalStore, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - previousApprovalStore := approval.GetStore() - approval.SetStore(approvalStore) - t.Cleanup(func() { approval.SetStore(previousApprovalStore) }) - - now := time.Now().UTC() - // PlanHash is the approval-equivalent hash of the (command, target, - // reason) the operator approved. This test covers the denial path, so - // the hash must match the executing payload to isolate denial as the - // reason for refusal — otherwise the broker's drift check fires first. - deniedHash := approvalPlanHash( - "act-denied-command", - "approval-denied-command", - "pulse_control", - "agent-1", - "rm -rf /tmp/pulse-test", - "agent", - "agent-1", - "unsafe cleanup command", - ) - plan := unifiedresources.ActionPlan{ - ActionID: "act-denied-command", - RequestID: "approval-denied-command", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: deniedHash, - } - req := &approval.ApprovalRequest{ - ID: "approval-denied-command", - Command: "rm -rf /tmp/pulse-test", - TargetType: "agent", - TargetID: "agent-1", - TargetName: "agent-1", - Context: "unsafe cleanup command", - Plan: &plan, - } - if err := approvalStore.CreateApproval(req); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - if _, err := approvalStore.Deny("approval-denied-command", "operator@example.com", "unsafe command"); err != nil { - t.Fatalf("Deny: %v", err) - } - - agentServer := &mockAgentServer{} - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - executor.recordPendingApprovalAction(req) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-1", - "approval-denied-command", - true, - "agent-1", - agentexec.ExecuteCommandPayload{ - Command: "rm -rf /tmp/pulse-test", - TargetType: "agent", - TargetID: "agent-1", - }, - "pulse_control", - "unsafe cleanup command", - ) - if !errors.Is(err, unifiedresources.ErrActionNotApproved) { - t.Fatalf("executeCommandWithAudit error = %v, want %v", err, unifiedresources.ErrActionNotApproved) - } - if result != nil { - t.Fatalf("command result = %#v, want nil", result) - } - agentServer.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) - - audit, ok, err := actionStore.GetActionAudit("act-denied-command") - if err != nil { - t.Fatalf("GetActionAudit: %v", err) - } - if !ok || audit.State != unifiedresources.ActionStateRejected || len(audit.Approvals) != 1 || audit.Approvals[0].Outcome != unifiedresources.OutcomeRejected { - t.Fatalf("audit = %#v, ok=%v", audit, ok) - } - events, err := actionStore.GetActionLifecycleEvents("act-denied-command", time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionLifecycleEvents: %v", err) - } - for _, event := range events { - if event.State == unifiedresources.ActionStateExecuting || event.State == unifiedresources.ActionStateCompleted { - t.Fatalf("denied approval must not create execution lifecycle event: %#v", event) - } - } -} - -func TestExecuteCommandWithAuditRefusesApprovedDryRunOnlyAndExpiredPlans(t *testing.T) { - for _, tc := range []struct { - name string - suffix string - configure func(*approval.ApprovalRequest) - wantErr error - wantPrefix string - }{ - { - name: "dry run only", - suffix: "dryrun", - configure: func(req *approval.ApprovalRequest) { - req.Plan.ApprovalPolicy = unifiedresources.ApprovalDryRun - }, - wantErr: unifiedresources.ErrActionDryRunOnly, - wantPrefix: "action_dry_run_only:", - }, - { - name: "expired", - suffix: "expired", - configure: func(req *approval.ApprovalRequest) { - req.Plan.ExpiresAt = time.Now().UTC().Add(-time.Minute) - }, - wantErr: unifiedresources.ErrActionPlanExpired, - wantPrefix: "action_plan_expired:", - }, - } { - t.Run(tc.name, func(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - approvalStore, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - previousApprovalStore := approval.GetStore() - approval.SetStore(approvalStore) - t.Cleanup(func() { approval.SetStore(previousApprovalStore) }) - - now := time.Now().UTC() - actionID := "act-" + tc.suffix + "-command" - approvalID := "approval-" + tc.suffix + "-command" - targetID := "agent-" + tc.suffix - command := "systemctl restart workload" - reason := "restart workload service" - approvedHash := approvalPlanHash( - actionID, - approvalID, - "pulse_control", - targetID, - command, - "agent", - targetID, - reason, - ) - plan := unifiedresources.ActionPlan{ - ActionID: actionID, - RequestID: approvalID, - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - ResourceVersion: "resource:sha256:test", - PolicyVersion: "policy:sha256:test", - PlanHash: approvedHash, - } - req := &approval.ApprovalRequest{ - ID: approvalID, - Command: command, - TargetType: "agent", - TargetID: targetID, - TargetName: targetID, - Context: reason, - Plan: &plan, - } - if err := approvalStore.CreateApproval(req); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - approved, err := approvalStore.Approve(approvalID, "operator@example.com") - if err != nil { - t.Fatalf("Approve: %v", err) - } - tc.configure(approved) - - agentServer := &mockAgentServer{} - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - executor.recordPendingApprovalAction(approved) - - completed := make(chan unifiedresources.ActionAuditRecord, 1) - executor.SetOnActionCompleted(func(record unifiedresources.ActionAuditRecord) { - completed <- record - }) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - targetID, - approvalID, - true, - targetID, - agentexec.ExecuteCommandPayload{ - Command: command, - TargetType: "agent", - TargetID: targetID, - }, - "pulse_control", - reason, - ) - if !errors.Is(err, tc.wantErr) { - t.Fatalf("executeCommandWithAudit error = %v, want %v", err, tc.wantErr) - } - if result != nil { - t.Fatalf("expected nil command result on refusal, got %#v", result) - } - agentServer.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) - - audit, ok, err := actionStore.GetActionAudit(actionID) - if err != nil { - t.Fatalf("GetActionAudit: %v", err) - } - if !ok { - t.Fatalf("expected refused action audit %q", actionID) - } - if audit.State != unifiedresources.ActionStateFailed { - t.Fatalf("audit state = %q, want %q", audit.State, unifiedresources.ActionStateFailed) - } - if audit.Result == nil || audit.Result.Success { - t.Fatalf("expected Result.Success=false, got %#v", audit.Result) - } - if !strings.HasPrefix(audit.Result.ErrorMessage, tc.wantPrefix) { - t.Fatalf("ErrorMessage = %q, want prefix %q", audit.Result.ErrorMessage, tc.wantPrefix) - } - if errors.Is(tc.wantErr, unifiedresources.ErrActionDryRunOnly) { - if len(audit.Approvals) != 0 { - t.Fatalf("dry-run-only refusal minted executable approval authority: %#v", audit.Approvals) - } - } else if len(audit.Approvals) != 1 || audit.Approvals[0].Outcome != unifiedresources.OutcomeApproved { - t.Fatalf("expected approved audit record to be preserved, got %#v", audit.Approvals) - } - - events, err := actionStore.GetActionLifecycleEvents(actionID, time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionLifecycleEvents: %v", err) - } - seenFailed := false - for _, event := range events { - if event.State == unifiedresources.ActionStateExecuting || event.State == unifiedresources.ActionStateCompleted { - t.Fatalf("refusal must not create dispatch lifecycle event: %#v", event) - } - if event.State == unifiedresources.ActionStateFailed { - seenFailed = true - if !strings.HasPrefix(event.Message, tc.wantPrefix) { - t.Fatalf("failed lifecycle message = %q, want prefix %q", event.Message, tc.wantPrefix) - } - } - } - if !seenFailed { - t.Fatalf("missing failed refusal lifecycle event in %#v", events) - } - - select { - case received := <-completed: - if received.State != unifiedresources.ActionStateFailed { - t.Fatalf("callback state = %q, want %q", received.State, unifiedresources.ActionStateFailed) - } - if received.Result == nil || !strings.HasPrefix(received.Result.ErrorMessage, tc.wantPrefix) { - t.Fatalf("callback result = %#v, want prefix %q", received.Result, tc.wantPrefix) - } - case <-time.After(2 * time.Second): - t.Fatal("terminal refusal callback did not fire within 2s") - } - }) - } -} - -// TestExecuteCommandWithAuditRefusesWhenResourceIsRemediationLocked covers -// the operator-set NeverAutoRemediate safety. When the operator has flagged -// the target resource as never-auto-remediate (via the -// /api/resources/{id}/operator-state surface), the broker must refuse the -// dispatch even with a valid approval and matching plan hash. The -// per-resource intent outranks the per-action approval — this is the -// safety mechanism for "do not touch this resource even if you think you -// should." The refusal must be visible on the audit timeline as a Failed -// record with a `resource_remediation_locked:` ErrorMessage prefix. -func TestExecuteCommandWithAuditRefusesWhenResourceIsRemediationLocked(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - - // Operator has marked the target resource as never-auto-remediate. - if err := actionStore.SetResourceOperatorState(unifiedresources.ResourceOperatorState{ - CanonicalID: "agent-locked", - NeverAutoRemediate: true, - Note: "manual-only — Pulse must not touch this host", - SetAt: time.Now().UTC(), - SetBy: "operator:richard", - }); err != nil { - t.Fatalf("SetResourceOperatorState: %v", err) - } - - approvalStore, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - previousApprovalStore := approval.GetStore() - approval.SetStore(approvalStore) - t.Cleanup(func() { approval.SetStore(previousApprovalStore) }) - - now := time.Now().UTC() - approvedHash := approvalPlanHash( - "act-locked", - "approval-locked", - "pulse_control", - "agent-locked", - "systemctl restart workload", - "agent", - "agent-locked", - "restart workload service", - ) - plan := unifiedresources.ActionPlan{ - ActionID: "act-locked", - RequestID: "approval-locked", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - PlanHash: approvedHash, - } - req := &approval.ApprovalRequest{ - ID: "approval-locked", - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-locked", - TargetName: "agent-locked", - Context: "restart workload service", - Plan: &plan, - } - if err := approvalStore.CreateApproval(req); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - if _, err := approvalStore.Approve("approval-locked", "operator@example.com"); err != nil { - t.Fatalf("Approve: %v", err) - } - - agentServer := &mockAgentServer{} - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - executor.recordPendingApprovalAction(req) - - // Payload matches the approval (no drift); only the operator-set lock - // should refuse this. If the broker dispatches anyway, the operator's - // per-resource intent has been overridden by the approval, which is - // the failure we're guarding against. - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-locked", - "approval-locked", - true, - "agent-locked", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-locked", - }, - "pulse_control", - "restart workload service", - ) - if !errors.Is(err, unifiedresources.ErrResourceRemediationLocked) { - t.Fatalf("executeCommandWithAudit error = %v, want ErrResourceRemediationLocked", err) - } - if result != nil { - t.Fatalf("expected nil result on remediation-locked refusal, got %#v", result) - } - agentServer.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) - - // Refusal must be observable in the audit history with the canonical - // `resource_remediation_locked:` ErrorMessage prefix so audit-UI - // filters and alert rules can branch on the stable token. - audits, err := actionStore.GetActionAudits("agent:agent-locked", time.Time{}, 10) - if err != nil { - t.Fatalf("GetActionAudits: %v", err) - } - if len(audits) != 1 { - t.Fatalf("expected 1 refused audit record, got %d", len(audits)) - } - refused := audits[0] - if refused.State != unifiedresources.ActionStateFailed { - t.Fatalf("audit state = %q, want %q", refused.State, unifiedresources.ActionStateFailed) - } - if refused.Result == nil || refused.Result.Success { - t.Fatalf("expected Result.Success=false, got %#v", refused.Result) - } - if !strings.HasPrefix(refused.Result.ErrorMessage, "resource_remediation_locked:") { - t.Fatalf("expected ErrorMessage to start with resource_remediation_locked:, got %q", refused.Result.ErrorMessage) - } -} - -// TestExecuteCommandWithAuditAllowsDispatchWhenResourceUnlocked covers -// the negative case: a resource that has operator state recorded but -// without NeverAutoRemediate set must NOT block the dispatch. Pin this -// so the refusal branch can't drift into "always refuse when state -// exists". -func TestExecuteCommandWithAuditAllowsDispatchWhenResourceUnlocked(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - // State recorded but with NeverAutoRemediate=false — should not refuse. - if err := actionStore.SetResourceOperatorState(unifiedresources.ResourceOperatorState{ - CanonicalID: "agent-allowed", - IntentionallyOffline: true, - NeverAutoRemediate: false, - SetAt: time.Now().UTC(), - }); err != nil { - t.Fatalf("SetResourceOperatorState: %v", err) - } - - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-allowed", mock.Anything). - Return(&agentexec.CommandResultPayload{Stdout: "OK", ExitCode: 0}, nil) - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-allowed", - "", // no approval (not testing approval flow here) - false, // requiresApproval=false - "agent-allowed", - agentexec.ExecuteCommandPayload{ - Command: "systemctl status workload", - TargetType: "agent", - TargetID: "agent-allowed", - }, - "pulse_control", - "check status", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected dispatch to proceed; got nil result") - } - agentServer.AssertCalled(t, "ExecuteCommand", mock.Anything, "agent-allowed", mock.Anything) -} - -func TestSetOnActionCompleted_FiresOnSuccessfulDispatch(t *testing.T) { - // The post-completion callback is the seam the API layer uses to - // bridge action audits into the agent SSE stream - // (action.completed events). Pin the contract: the callback - // fires once per terminal-state record, sees the persisted - // audit, and runs without blocking the dispatch caller — - // fire-and-forget on its own goroutine. - store := unifiedresources.NewMemoryStore() - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-cb", mock.Anything). - Return(&agentexec.CommandResultPayload{Success: true, Stdout: "ok", ExitCode: 0}, nil).Once() - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: store, - }) - - got := make(chan unifiedresources.ActionAuditRecord, 1) - executor.SetOnActionCompleted(func(rec unifiedresources.ActionAuditRecord) { - got <- rec - }) - - if _, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-cb", - "", - false, - "agent-cb", - agentexec.ExecuteCommandPayload{ - Command: "uptime", - TargetType: "agent", - TargetID: "agent-cb", - }, - "pulse_control", - "run uptime", - ); err != nil { - t.Fatalf("executeCommandWithAudit: %v", err) - } - - select { - case received := <-got: - if received.State != unifiedresources.ActionStateCompleted { - t.Errorf("callback record state = %q, want %q", received.State, unifiedresources.ActionStateCompleted) - } - if received.ID == "" { - t.Error("callback record must carry the canonical action id") - } - if received.Request.ResourceID != "agent-cb" { - t.Errorf("callback resource id = %q, want %q", received.Request.ResourceID, "agent-cb") - } - if received.Result == nil || !received.Result.Success { - t.Errorf("callback result must surface success: %#v", received.Result) - } - case <-time.After(2 * time.Second): - t.Fatal("post-completion callback did not fire within 2s") - } -} - -func TestSetOnActionCompleted_RecordCarriesVerificationResult(t *testing.T) { - // Verification is the post-execution read-after-write probe the - // broker derives for action classes with a known check command - // (systemctl-style service control). Pin the contract: the - // callback record's Verification block is populated when the - // dispatch class supports it, with the derived command and the - // success/failure outcome of running it. Agents reading the - // callback (or the SSE event downstream) close the certainty - // loop on this field. - store := unifiedresources.NewMemoryStore() - agentServer := &mockAgentServer{} - // First call: the action itself. - agentServer.On("ExecuteCommand", mock.Anything, "agent-vrf", - mock.MatchedBy(func(p agentexec.ExecuteCommandPayload) bool { - return p.Command == "systemctl restart nginx" - })). - Return(&agentexec.CommandResultPayload{Success: true, Stdout: "", ExitCode: 0}, nil).Once() - // Second call: the broker-derived verification probe. The - // expected command is `systemctl is-active 'nginx'`. - agentServer.On("ExecuteCommand", mock.Anything, "agent-vrf", - mock.MatchedBy(func(p agentexec.ExecuteCommandPayload) bool { - return strings.HasPrefix(p.Command, "systemctl is-active") - })). - Return(&agentexec.CommandResultPayload{Success: true, Stdout: "active\n", ExitCode: 0}, nil).Once() - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: store, - }) - got := make(chan unifiedresources.ActionAuditRecord, 1) - executor.SetOnActionCompleted(func(rec unifiedresources.ActionAuditRecord) { - got <- rec - }) - - if _, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-vrf", - "", - false, - "agent-vrf", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart nginx", - TargetType: "agent", - TargetID: "agent-vrf", - }, - "pulse_control", - "restart nginx", - ); err != nil { - t.Fatalf("executeCommandWithAudit: %v", err) - } - - select { - case received := <-got: - if received.Result == nil { - t.Fatal("callback record must carry an ExecutionResult") - } - if received.Result.Verification == nil { - t.Fatal("callback record must carry a Verification block for service-restart actions — drift here breaks the certainty loop on action.completed") - } - if received.Verification == nil { - t.Fatal("callback record must carry the canonical top-level Verification block") - } - v := received.Result.Verification - if !v.Ran { - t.Error("Verification.Ran must be true after the broker dispatched the probe") - } - if received.Verification.Command != v.Command { - t.Errorf("canonical Verification.Command = %q, want %q", received.Verification.Command, v.Command) - } - if !v.Success { - t.Errorf("Verification.Success: probe returned exit 0, expected Success=true; got %+v", v) - } - if !strings.Contains(v.Command, "systemctl is-active") { - t.Errorf("Verification.Command must surface the derived probe command; got %q", v.Command) - } - if v.RanAt.IsZero() { - t.Error("Verification.RanAt must be populated so agents can reason about probe freshness") - } - case <-time.After(2 * time.Second): - t.Fatal("post-completion callback did not fire within 2s") - } - agentServer.AssertExpectations(t) -} - -func TestSetOnActionCompleted_FiresOnPlanDriftRefusal(t *testing.T) { - // Plan-drift refusal lands in the audit store as Failed with the - // stable `plan_drift:` prefix; the callback must surface that - // terminal state so agents can branch on the refusal token - // without polling. - now := time.Date(2026, 5, 9, 9, 30, 0, 0, time.UTC) - approvedPlan := unifiedresources.ActionPlan{ - ActionID: "drift-action", - RequestID: "appr-drift", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - PlannedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - PlanHash: "approved-but-drifted", - Message: "approved reason", - } - approvedReq := &approval.ApprovalRequest{ - ID: "appr-drift", - Command: "approved-cmd", - TargetType: "agent", - TargetID: "agent-drift", - TargetName: "agent-drift", - Status: approval.StatusApproved, - Plan: &approvedPlan, - RequestedAt: now, - ExpiresAt: now.Add(5 * time.Minute), - } - approvalStoreOriginal := approval.GetStore() - defer approval.SetStore(approvalStoreOriginal) - memStore := unifiedresources.NewMemoryStore() - approvalStoreLocal, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DefaultTimeout: 5 * time.Minute, - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("approval.NewStore: %v", err) - } - if err := approvalStoreLocal.CreateApproval(approvedReq); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - approval.SetStore(approvalStoreLocal) - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: &mockAgentServer{}, - ActionAuditStore: memStore, - }) - - got := make(chan unifiedresources.ActionAuditRecord, 1) - executor.SetOnActionCompleted(func(rec unifiedresources.ActionAuditRecord) { - got <- rec - }) - - // Submit a payload whose hash will not match the approved plan. - if _, dispatchErr := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-drift", - "appr-drift", - true, - "agent-drift", - agentexec.ExecuteCommandPayload{ - Command: "different-cmd", - TargetType: "agent", - TargetID: "agent-drift", - }, - "pulse_control", - "different reason", - ); dispatchErr == nil { - t.Fatal("expected plan-drift refusal; got nil error") - } else if !strings.Contains(dispatchErr.Error(), "plan") { - // Defensive: the validator's exact error wording can evolve; - // just confirm we got an error from the drift path rather - // than from somewhere else. - t.Logf("drift error: %v", dispatchErr) - } - - select { - case received := <-got: - if received.State != unifiedresources.ActionStateFailed { - t.Errorf("refusal state = %q, want %q", received.State, unifiedresources.ActionStateFailed) - } - if received.Result == nil || !strings.HasPrefix(received.Result.ErrorMessage, "plan_drift:") { - t.Errorf("refusal must carry plan_drift: prefix on ErrorMessage; got %#v", received.Result) - } - case <-time.After(2 * time.Second): - t.Fatal("plan-drift callback did not fire within 2s") - } -} diff --git a/internal/ai/tools/action_audit_remediation_lock_test.go b/internal/ai/tools/action_audit_remediation_lock_test.go deleted file mode 100644 index 65aaefba80..0000000000 --- a/internal/ai/tools/action_audit_remediation_lock_test.go +++ /dev/null @@ -1,405 +0,0 @@ -package tools - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" - unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" - "github.com/stretchr/testify/mock" -) - -// failingOperatorStateStore simulates an audit store whose operator-state -// lookup errors (e.g. sqlite I/O failure) while every other store surface -// keeps working, so refusal audit records can still be asserted. -type failingOperatorStateStore struct { - *unifiedresources.MemoryStore -} - -func (s *failingOperatorStateStore) GetResourceOperatorState(string) (unifiedresources.ResourceOperatorState, bool, error) { - return unifiedresources.ResourceOperatorState{}, false, errors.New("simulated operator-state lookup failure") -} - -// installApprovedApproval seeds the global approval store with an approved, -// plan-less approval and returns its ID. Plan-less keeps the plan-drift -// check out of the way: these tests exercise the remediation-lock gate's -// human-approved branch, which keys off the approved decision record. -func installApprovedApproval(t *testing.T, id, command, targetType, targetID string) { - t.Helper() - approvalStore, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - if err != nil { - t.Fatalf("NewStore: %v", err) - } - previousApprovalStore := approval.GetStore() - approval.SetStore(approvalStore) - t.Cleanup(func() { approval.SetStore(previousApprovalStore) }) - - req := &approval.ApprovalRequest{ - ID: id, - Command: command, - TargetType: targetType, - TargetID: targetID, - TargetName: targetID, - Context: "test approval", - } - if err := approvalStore.CreateApproval(req); err != nil { - t.Fatalf("CreateApproval: %v", err) - } - if _, err := approvalStore.Approve(id, "operator@example.com"); err != nil { - t.Fatalf("Approve: %v", err) - } -} - -// TestExecuteCommandWithAuditFailsClosedOnLockLookupErrorWithoutApproval -// covers the autonomous posture (Patrol / assisted autonomy: no human -// approval on the dispatch) when the operator-state lookup errors. The -// broker cannot tell whether the operator set NeverAutoRemediate, so it -// must fail CLOSED — refuse the dispatch, surface "remediation lock state -// unknown", and leave a Failed audit record with the stable -// `remediation_lock_state_unknown:` prefix. -func TestExecuteCommandWithAuditFailsClosedOnLockLookupErrorWithoutApproval(t *testing.T) { - actionStore := &failingOperatorStateStore{MemoryStore: unifiedresources.NewMemoryStore()} - agentServer := &mockAgentServer{} - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - executor.SetAutonomousMode(true) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-degraded", - "", // no approval — autonomous dispatch - false, - "agent-degraded", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-degraded", - }, - "pulse_patrol", - "restart workload service", - ) - if !errors.Is(err, ErrRemediationLockStateUnknown) { - t.Fatalf("executeCommandWithAudit error = %v, want ErrRemediationLockStateUnknown", err) - } - if result != nil { - t.Fatalf("expected nil result on fail-closed refusal, got %#v", result) - } - agentServer.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) - - audits, auditErr := actionStore.GetActionAudits("agent-degraded", time.Time{}, 10) - if auditErr != nil { - t.Fatalf("GetActionAudits: %v", auditErr) - } - if len(audits) != 1 { - t.Fatalf("expected 1 refused audit record, got %d", len(audits)) - } - refused := audits[0] - if refused.State != unifiedresources.ActionStateFailed { - t.Fatalf("audit state = %q, want %q", refused.State, unifiedresources.ActionStateFailed) - } - if refused.Result == nil || refused.Result.Success { - t.Fatalf("expected Result.Success=false, got %#v", refused.Result) - } - if !strings.HasPrefix(refused.Result.ErrorMessage, "remediation_lock_state_unknown:") { - t.Fatalf("expected ErrorMessage to start with remediation_lock_state_unknown:, got %q", refused.Result.ErrorMessage) - } -} - -// TestExecuteCommandWithAuditFailsClosedWithNilStoreWithoutApproval covers -// the degraded deployment where no audit store is wired at all (e.g. the -// sqlite store failed to initialize and the chat service continued with a -// warning). Autonomous write dispatches must fail closed rather than -// silently ignoring an operator lock the broker cannot read. -func TestExecuteCommandWithAuditFailsClosedWithNilStoreWithoutApproval(t *testing.T) { - agentServer := &mockAgentServer{} - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - // No ActionAuditStore. - }) - executor.SetAutonomousMode(true) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-nostore", - "", - false, - "agent-nostore", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-nostore", - }, - "pulse_patrol", - "restart workload service", - ) - if !errors.Is(err, ErrRemediationLockStateUnknown) { - t.Fatalf("executeCommandWithAudit error = %v, want ErrRemediationLockStateUnknown", err) - } - if result != nil { - t.Fatalf("expected nil result on fail-closed refusal, got %#v", result) - } - agentServer.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) -} - -// TestExecuteCommandWithAuditAllowsHumanApprovedDispatchOnLockLookupError -// pins the interactive posture: when the dispatch is backed by an approved -// human decision, an unknown lock state keeps the historical fail-open -// behavior — the operator explicitly signed off on the action, so a -// degraded operator-state lookup does not override their decision. -func TestExecuteCommandWithAuditAllowsHumanApprovedDispatchOnLockLookupError(t *testing.T) { - actionStore := &failingOperatorStateStore{MemoryStore: unifiedresources.NewMemoryStore()} - installApprovedApproval(t, "approval-degraded", "systemctl restart workload", "agent", "agent-degraded") - - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-degraded", mock.Anything). - Return(&agentexec.CommandResultPayload{Stdout: "OK", ExitCode: 0}, nil) - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - ActionAuditStore: actionStore, - }) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-degraded", - "approval-degraded", - true, - "agent-degraded", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-degraded", - }, - "pulse_control", - "restart workload service", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected human-approved dispatch to proceed; got nil result") - } - agentServer.AssertCalled(t, "ExecuteCommand", mock.Anything, "agent-degraded", mock.Anything) -} - -// TestExecuteCommandWithAuditAllowsHumanApprovedDispatchWithNilStore covers -// the human-approved branch with no audit store wired: same fail-open -// posture as the lookup-error case. -func TestExecuteCommandWithAuditAllowsHumanApprovedDispatchWithNilStore(t *testing.T) { - installApprovedApproval(t, "approval-nostore", "systemctl restart workload", "agent", "agent-nostore") - - agentServer := &mockAgentServer{} - agentServer.On("ExecuteCommand", mock.Anything, "agent-nostore", mock.Anything). - Return(&agentexec.CommandResultPayload{Stdout: "OK", ExitCode: 0}, nil) - - executor := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - // No ActionAuditStore. - }) - - result, err := executor.executeCommandWithAudit( - context.Background(), - "pulse_control", - "agent-nostore", - "approval-nostore", - true, - "agent-nostore", - agentexec.ExecuteCommandPayload{ - Command: "systemctl restart workload", - TargetType: "agent", - TargetID: "agent-nostore", - }, - "pulse_control", - "restart workload service", - ) - if err != nil { - t.Fatalf("executeCommandWithAudit unexpected error: %v", err) - } - if result == nil { - t.Fatal("expected human-approved dispatch to proceed; got nil result") - } - agentServer.AssertCalled(t, "ExecuteCommand", mock.Anything, "agent-nostore", mock.Anything) -} - -// TestExecuteNativeActionWithAuditRefusesWhenResourceIsRemediationLocked -// pins the lock gate on the native-provider dispatch path (e.g. TrueNAS -// app start/stop/restart), which previously skipped the remediation lock -// entirely. The operator's NeverAutoRemediate flag must gate native write -// dispatches identically to agent-command dispatches. -func TestExecuteNativeActionWithAuditRefusesWhenResourceIsRemediationLocked(t *testing.T) { - actionStore := unifiedresources.NewMemoryStore() - if err := actionStore.SetResourceOperatorState(unifiedresources.ResourceOperatorState{ - CanonicalID: "app-locked", - NeverAutoRemediate: true, - SetAt: time.Now().UTC(), - SetBy: "operator:richard", - }); err != nil { - t.Fatalf("SetResourceOperatorState: %v", err) - } - - executor := NewPulseToolExecutor(ExecutorConfig{ - ActionAuditStore: actionStore, - }) - - executed := false - result, err := executor.executeNativeActionWithAudit( - context.Background(), - "pulse_control", - "app-locked", - "", - false, - map[string]any{"action": "restart"}, - "pulse_control", - "restart app-container", - func(context.Context) (*unifiedresources.ExecutionResult, error) { - executed = true - return &unifiedresources.ExecutionResult{Success: true}, nil - }, - ) - if !errors.Is(err, unifiedresources.ErrResourceRemediationLocked) { - t.Fatalf("executeNativeActionWithAudit error = %v, want ErrResourceRemediationLocked", err) - } - if executed { - t.Fatal("native action executed despite operator remediation lock") - } - if result == nil || result.Success { - t.Fatalf("expected failed ExecutionResult, got %#v", result) - } - if !strings.HasPrefix(result.ErrorMessage, "resource_remediation_locked:") { - t.Fatalf("expected ErrorMessage to start with resource_remediation_locked:, got %q", result.ErrorMessage) - } - - audits, auditErr := actionStore.GetActionAudits("app-locked", time.Time{}, 10) - if auditErr != nil { - t.Fatalf("GetActionAudits: %v", auditErr) - } - if len(audits) != 1 { - t.Fatalf("expected 1 refused audit record, got %d", len(audits)) - } - if audits[0].State != unifiedresources.ActionStateFailed { - t.Fatalf("audit state = %q, want %q", audits[0].State, unifiedresources.ActionStateFailed) - } -} - -// TestExecuteNativeActionWithAuditFailsClosedOnUnknownLockStateWithoutApproval -// covers both unknown-state shapes on the native path: nil store and a -// store whose operator-state lookup errors. Autonomous native dispatches -// must fail closed in both. -func TestExecuteNativeActionWithAuditFailsClosedOnUnknownLockStateWithoutApproval(t *testing.T) { - cases := []struct { - name string - store unifiedresources.ResourceStore - }{ - {name: "nil store", store: nil}, - {name: "lookup error", store: &failingOperatorStateStore{MemoryStore: unifiedresources.NewMemoryStore()}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - executor := NewPulseToolExecutor(ExecutorConfig{ - ActionAuditStore: tc.store, - }) - executor.SetAutonomousMode(true) - - executed := false - result, err := executor.executeNativeActionWithAudit( - context.Background(), - "pulse_control", - "app-degraded", - "", - false, - map[string]any{"action": "restart"}, - "pulse_patrol", - "restart app-container", - func(context.Context) (*unifiedresources.ExecutionResult, error) { - executed = true - return &unifiedresources.ExecutionResult{Success: true}, nil - }, - ) - if !errors.Is(err, ErrRemediationLockStateUnknown) { - t.Fatalf("executeNativeActionWithAudit error = %v, want ErrRemediationLockStateUnknown", err) - } - if executed { - t.Fatal("native action executed despite unknown remediation lock state") - } - if result == nil || result.Success { - t.Fatalf("expected failed ExecutionResult, got %#v", result) - } - if !strings.HasPrefix(result.ErrorMessage, "remediation_lock_state_unknown:") { - t.Fatalf("expected ErrorMessage to start with remediation_lock_state_unknown:, got %q", result.ErrorMessage) - } - }) - } -} - -// TestExecuteNativeActionWithAuditAllowsHumanApprovedDispatchOnUnknownLockState -// pins the human-approved fail-open branch on the native path. -func TestExecuteNativeActionWithAuditAllowsHumanApprovedDispatchOnUnknownLockState(t *testing.T) { - actionStore := &failingOperatorStateStore{MemoryStore: unifiedresources.NewMemoryStore()} - installApprovedApproval(t, "approval-native", "truenas app restart myapp", "app-container", "app-degraded") - - executor := NewPulseToolExecutor(ExecutorConfig{ - ActionAuditStore: actionStore, - }) - - executed := false - result, err := executor.executeNativeActionWithAudit( - context.Background(), - "pulse_control", - "app-degraded", - "approval-native", - true, - map[string]any{"action": "restart"}, - "pulse_control", - "restart app-container", - func(context.Context) (*unifiedresources.ExecutionResult, error) { - executed = true - return &unifiedresources.ExecutionResult{Success: true}, nil - }, - ) - if err != nil { - t.Fatalf("executeNativeActionWithAudit unexpected error: %v", err) - } - if !executed { - t.Fatal("expected human-approved native dispatch to proceed") - } - if result == nil || !result.Success { - t.Fatalf("expected successful ExecutionResult, got %#v", result) - } -} - -// A succession moves the operator's row to the successor ID when a rebuild -// applies it, before the registry generation listing the successor is -// published; the dispatch a model asks for in between still names the old ID. -// The lock the operator set on it must still refuse. -func TestRemediationLockGateFollowsASuccessionOffTheIDItNames(t *testing.T) { - store := unifiedresources.NewMemoryStore() - if err := store.SetResourceOperatorState(unifiedresources.ResourceOperatorState{CanonicalID: "agent-before-key", NeverAutoRemediate: true}); err != nil { - t.Fatalf("lock the agent: %v", err) - } - if err := store.ApplyCanonicalIDSuccessions([]unifiedresources.CanonicalIDSuccession{{OldCanonicalID: "agent-before-key", NewCanonicalID: "agent-keyed"}}); err != nil { - t.Fatalf("ApplyCanonicalIDSuccessions: %v", err) - } - executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: store}) - - for _, humanApproved := range []bool{false, true} { - err := executor.checkRemediationLockForDispatch("action-1", "agent-before-key", "restart", humanApproved) - if !errors.Is(err, unifiedresources.ErrResourceRemediationLocked) { - t.Fatalf("humanApproved=%v: error = %v, want ErrResourceRemediationLocked", humanApproved, err) - } - } - if err := executor.checkRemediationLockForDispatch("action-2", "agent-unrelated", "restart", false); err != nil { - t.Fatalf("an ID no succession names was refused: %v", err) - } -} diff --git a/internal/ai/tools/aitools_control_pure_branchcov0716_test.go b/internal/ai/tools/aitools_control_pure_branchcov0716_test.go deleted file mode 100644 index d17180896f..0000000000 --- a/internal/ai/tools/aitools_control_pure_branchcov0716_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package tools - -import ( - "testing" - - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" -) - -// This file raises branch coverage for two pure helpers in tools_control.go: -// - resolvedResourceControlIdentity (nil resource guard, provider-uid path, -// alias-scan loop with whitespace skipping, fallback trimming) -// - formatAvailableAgentHosts (no-agents path, hostname/agentID fallback in -// collectAgentHostnames, maxItems truncation and "(+N more)" suffix) -// -// Both helpers are pure (no I/O, no executor state) and return exact strings, -// so every case asserts the concrete return value rather than "no panic". -// -// The in-package test mock `mockResource` (defined in strict_resolution_test.go) -// implements ResolvedResourceInfo and is reused here to drive the non-nil arm. - -// TestBranchCovResolvedResourceControlIdentity covers every return path of -// resolvedResourceControlIdentity: the nil-resource guard, the providerUID win -// (including whitespace trimming), the alias scan that skips whitespace-only -// aliases and returns the first non-empty one, and the final fallback path -// (no providerUID and no usable alias) including fallback trimming. -func TestBranchCovResolvedResourceControlIdentity(t *testing.T) { - tests := []struct { - name string - resource ResolvedResourceInfo - fallback string - want string - }{ - // --- nil resource: returns trimmed fallback. --- - {name: "nil_resource_trims_fallback", resource: nil, fallback: " nginx-01 ", want: "nginx-01"}, - {name: "nil_resource_empty_fallback", resource: nil, fallback: "", want: ""}, - {name: "nil_resource_whitespace_only_fallback", resource: nil, fallback: " ", want: ""}, - - // --- providerUID path: non-empty (after trim) wins over aliases/fallback. --- - { - name: "providerUID_wins_over_aliases", - resource: &mockResource{providerUID: "ctr-abc", aliases: []string{"nginx", "zzz"}}, - fallback: "fallback", - want: "ctr-abc", - }, - {name: "providerUID_is_trimmed", resource: &mockResource{providerUID: " ctr-abc "}, fallback: "fallback", want: "ctr-abc"}, - // Whitespace-only providerUID is treated as empty -> falls through to aliases. - { - name: "whitespace_only_providerUID_falls_through", - resource: &mockResource{providerUID: " ", aliases: []string{"nginx"}}, - fallback: "fallback", - want: "nginx", - }, - - // --- alias-scan path: returns the first non-empty (after trim) alias. --- - {name: "first_alias_returned", resource: &mockResource{aliases: []string{"nginx", "abc"}}, fallback: "fallback", want: "nginx"}, - {name: "alias_is_trimmed", resource: &mockResource{aliases: []string{" nginx-02 "}}, fallback: "fallback", want: "nginx-02"}, - // Leading whitespace-only alias is skipped, next usable alias is returned. - {name: "skip_whitespace_only_alias", resource: &mockResource{aliases: []string{" ", "nginx"}}, fallback: "fallback", want: "nginx"}, - {name: "skip_multiple_whitespace_aliases", resource: &mockResource{aliases: []string{"", " ", "\t", "real-alias"}}, fallback: "fallback", want: "real-alias"}, - - // --- fallback path: no providerUID and no usable alias. --- - {name: "empty_aliases_falls_back", resource: &mockResource{aliases: []string{}}, fallback: "host-01", want: "host-01"}, - {name: "nil_aliases_falls_back", resource: &mockResource{aliases: nil}, fallback: "host-01", want: "host-01"}, - {name: "all_whitespace_aliases_falls_back", resource: &mockResource{aliases: []string{" ", "\t"}}, fallback: "host-01", want: "host-01"}, - {name: "fallback_is_trimmed_when_used", resource: &mockResource{}, fallback: " host-01 ", want: "host-01"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := resolvedResourceControlIdentity(tc.resource, tc.fallback) - if got != tc.want { - t.Fatalf("resolvedResourceControlIdentity(%v, %q) = %q, want %q", - tc.resource, tc.fallback, got, tc.want) - } - }) - } -} - -// TestBranchCovFormatAvailableAgentHosts covers every return path of -// formatAvailableAgentHosts (and its helper collectAgentHostnames): the -// no-agents path (nil, empty, and agents that all collapse to empty names), -// the hostname-used path, the AgentID fallback when Hostname is blank, the -// dropping of agents whose Hostname and AgentID are both blank, name trimming, -// and the truncation/"(+N more)" suffix at the maxItems=6 boundary. -func TestBranchCovFormatAvailableAgentHosts(t *testing.T) { - // hostnames builds a slice of n agents with distinct, non-empty Hostnames. - hostnames := func(n int) []agentexec.ConnectedAgent { - out := make([]agentexec.ConnectedAgent, n) - for i := 0; i < n; i++ { - out[i] = agentexec.ConnectedAgent{Hostname: string(rune('a' + i))} - } - return out - } - - tests := []struct { - name string - agents []agentexec.ConnectedAgent - want string - }{ - // --- no-agents path. --- - {name: "nil_agents", agents: nil, want: "No agents are currently connected."}, - {name: "empty_agents", agents: []agentexec.ConnectedAgent{}, want: "No agents are currently connected."}, - // Agents exist but every one collapses to an empty name -> still "No agents". - { - name: "all_agents_blank", - agents: []agentexec.ConnectedAgent{{Hostname: " ", AgentID: " "}, {}}, - want: "No agents are currently connected.", - }, - - // --- hostname path (single agent). --- - {name: "single_hostname", agents: []agentexec.ConnectedAgent{{Hostname: "host-1"}}, want: "Available targets: host-1"}, - - // --- AgentID fallback when Hostname is blank/whitespace. --- - { - name: "agentID_used_when_hostname_blank", - agents: []agentexec.ConnectedAgent{{AgentID: "agent-9"}}, - want: "Available targets: agent-9", - }, - { - name: "agentID_used_when_hostname_whitespace", - agents: []agentexec.ConnectedAgent{{Hostname: " ", AgentID: "agent-9"}}, - want: "Available targets: agent-9", - }, - // agentID is also trimmed. - { - name: "agentID_is_trimmed", - agents: []agentexec.ConnectedAgent{{AgentID: " agent-9 "}}, - want: "Available targets: agent-9", - }, - - // --- Hostname is preferred over AgentID. --- - { - name: "hostname_preferred_over_agentID", - agents: []agentexec.ConnectedAgent{{Hostname: "host-1", AgentID: "agent-1"}}, - want: "Available targets: host-1", - }, - - // --- blank agent is dropped; remaining agents are listed in order. --- - { - name: "blank_agent_dropped_others_kept", - agents: []agentexec.ConnectedAgent{ - {Hostname: "host-1"}, - {Hostname: " ", AgentID: " "}, // dropped - {AgentID: "agent-3"}, - }, - want: "Available targets: host-1, agent-3", - }, - - // --- maxItems boundary (maxItems == 6 inside the function). --- - // Exactly 6 hostnames: full list, no "(+N more)" suffix. - { - name: "exactly_max_no_truncation", - agents: hostnames(6), - want: "Available targets: a, b, c, d, e, f", - }, - // 7 hostnames: list truncated to first 6, suffix "(+1 more)". - { - name: "one_over_max_truncated", - agents: hostnames(7), - want: "Available targets: a, b, c, d, e, f (+1 more)", - }, - // 9 hostnames: suffix counts the overflow beyond maxItems. - { - name: "several_over_max_truncated", - agents: hostnames(9), - want: "Available targets: a, b, c, d, e, f (+3 more)", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := formatAvailableAgentHosts(tc.agents) - if got != tc.want { - t.Fatalf("formatAvailableAgentHosts(%v) = %q, want %q", tc.agents, got, tc.want) - } - }) - } -} diff --git a/internal/ai/tools/approval_preflight_test.go b/internal/ai/tools/approval_preflight_test.go deleted file mode 100644 index 29274f54eb..0000000000 --- a/internal/ai/tools/approval_preflight_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package tools - -import ( - "strings" - "testing" - - "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" -) - -func TestClassifyApprovalCommand_BucketsKnownCommandShapes(t *testing.T) { - cases := []struct { - name string - target string - command string - want string - }{ - {"systemctl restart canonical", "agent", "systemctl restart nginx", "service-restart"}, - {"service restart legacy", "agent", "service nginx restart", ""}, - {"service restart canonical", "agent", "service restart nginx", "service-restart"}, - {"systemctl stop", "agent", "systemctl stop postgres", "service-stop"}, - {"systemctl start", "agent", "systemctl start redis", "service-start"}, - {"systemctl reload", "agent", "systemctl reload nginx", "service-reload"}, - {"docker restart", "docker", "docker restart homepage", "container-restart"}, - {"podman restart", "agent", "podman restart pihole", "container-restart"}, - {"docker stop", "docker", "docker stop oldcontainer", "container-stop"}, - {"kubectl rollout restart", "kubernetes", "kubectl rollout restart deployment/api", "k8s-rollout-restart"}, - // Proxmox VM lifecycle classes — Pulse's primary monitored platform. - // Each verb has distinct semantics so they map to distinct classes. - {"qm reboot", "agent", "qm reboot 101", "proxmox-vm-reboot"}, - {"qm restart alias", "agent", "qm restart 101", "proxmox-vm-reboot"}, - {"qm stop hard halt", "agent", "qm stop 101", "proxmox-vm-stop"}, - {"qm start", "agent", "qm start 101", "proxmox-vm-start"}, - {"qm shutdown graceful", "agent", "qm shutdown 101", "proxmox-vm-shutdown"}, - // Proxmox LXC container lifecycle classes. - {"pct reboot", "agent", "pct reboot 200", "proxmox-ct-reboot"}, - {"pct restart alias", "agent", "pct restart 200", "proxmox-ct-reboot"}, - {"pct stop hard halt", "agent", "pct stop 200", "proxmox-ct-stop"}, - {"pct start", "agent", "pct start 200", "proxmox-ct-start"}, - {"pct shutdown graceful", "agent", "pct shutdown 200", "proxmox-ct-shutdown"}, - // Negative cases: tool prefix without a recognized verb must NOT - // classify (defends against `qm migrate`, `pct destroy`, etc.). - {"qm migrate not a lifecycle verb", "agent", "qm migrate 101 pve-2", ""}, - {"pct destroy not a lifecycle verb", "agent", "pct destroy 200", ""}, - {"unknown free-form command", "agent", "echo hello world", ""}, - {"empty command", "agent", "", ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := classifyApprovalCommand(tc.target, tc.command) - if got != tc.want { - t.Fatalf("classifyApprovalCommand(%q, %q) = %q, want %q", tc.target, tc.command, got, tc.want) - } - }) - } -} - -func TestApprovalCommandClassPreflightAdditions_AuthorsConcreteContextForKnownClasses(t *testing.T) { - cases := []struct { - name string - command string - safetyToken string // must appear somewhere in safety checks - verifyToken string // must appear somewhere in verification steps - }{ - {"service-restart names systemctl is-active", "systemctl restart nginx", "briefly unavailable", "systemctl is-active"}, - {"service-stop warns dependent services", "systemctl stop postgres", "dependent services", "inactive"}, - {"container-restart names docker inspect", "docker restart homepage", "briefly unavailable", "docker inspect"}, - {"k8s rollout names rollout status", "kubectl rollout restart deployment/api", "PodDisruptionBudget", "rollout status"}, - // Proxmox classes must concretely warn about the destructive vs - // graceful split (qm stop = hard halt, qm shutdown = graceful) and - // name the canonical `qm status` / `pct status` read-after-write - // check the operator should see post-dispatch. - {"qm reboot names qm status", "qm reboot 101", "ACPI shutdown", "qm status"}, - {"qm stop warns it is hard not graceful", "qm stop 101", "hard stop", "stopped"}, - {"qm shutdown names timeout fallback", "qm shutdown 101", "ACPI shutdown", "qm status"}, - {"pct stop warns it is hard not graceful", "pct stop 200", "hard stop", "stopped"}, - {"pct shutdown names lxc-attach", "pct shutdown 200", "lxc-attach", "pct status"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - safety, verify := approvalCommandClassPreflightAdditions("agent", tc.command) - if len(safety) == 0 || len(verify) == 0 { - t.Fatalf("expected non-empty preflight additions for %q, got safety=%v verify=%v", tc.command, safety, verify) - } - joinedSafety := strings.Join(safety, " ") - if !strings.Contains(joinedSafety, tc.safetyToken) { - t.Fatalf("safety checks missing token %q for %q: %v", tc.safetyToken, tc.command, safety) - } - joinedVerify := strings.Join(verify, " ") - if !strings.Contains(joinedVerify, tc.verifyToken) { - t.Fatalf("verification steps missing token %q for %q: %v", tc.verifyToken, tc.command, verify) - } - }) - } -} - -func TestApprovalCommandClassPreflightAdditions_ReturnsEmptyForUnknownClass(t *testing.T) { - // Unknown command shapes must not be padded with fabricated safety or - // verification copy. Default preflight content stands on its own. - safety, verify := approvalCommandClassPreflightAdditions("agent", "echo hello world") - if safety != nil || verify != nil { - t.Fatalf("expected nil additions for unknown command, got safety=%v verify=%v", safety, verify) - } -} - -func TestVerificationCommandForCommand_DerivesPerClassReadAfterWriteCheck(t *testing.T) { - cases := []struct { - name string - target string - command string - wantCmd string - wantOk bool - }{ - {"systemctl restart", "agent", "systemctl restart nginx", "systemctl is-active 'nginx'", true}, - {"systemctl reload with .service suffix", "agent", "systemctl reload my.service", "systemctl is-active 'my.service'", true}, - {"systemctl stop", "agent", "systemctl stop postgres", "systemctl is-active 'postgres'", true}, - // Docker/podman classes are intentionally excluded from broker-level - // verification — pulse_docker runs its own docker inspect check at - // the tool layer, so adding a broker-level dispatch would double-run. - {"docker restart deferred to tool layer", "docker", "docker restart homepage", "", false}, - {"podman restart deferred to tool layer", "agent", "podman restart pihole", "", false}, - // Proxmox classes are intentionally excluded from broker-level - // verification — pulse_control's verifyGuestAction already runs - // `qm status` and `pct status` at the tool layer, so adding a - // parallel broker-level dispatch would double-run the same check. - // The preflight copy still names the verification narrative; only - // the broker-side derivation is suppressed. - {"qm reboot deferred to pulse_control", "agent", "qm reboot 101", "", false}, - {"qm stop deferred to pulse_control", "agent", "qm stop 101", "", false}, - {"qm shutdown deferred to pulse_control", "agent", "qm shutdown 101", "", false}, - {"pct stop deferred to pulse_control", "agent", "pct stop 200", "", false}, - {"pct shutdown deferred to pulse_control", "agent", "pct shutdown 200", "", false}, - {"unknown command", "agent", "echo hello", "", false}, - {"systemctl with single-quote in unit", "agent", `systemctl restart nasty'name`, "systemctl is-active 'nasty'\\''name'", true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - cmd, ok := VerificationCommandForCommand(tc.target, tc.command) - if ok != tc.wantOk { - t.Fatalf("VerificationCommandForCommand(%q, %q) ok = %v, want %v", tc.target, tc.command, ok, tc.wantOk) - } - if cmd != tc.wantCmd { - t.Fatalf("VerificationCommandForCommand(%q, %q) cmd = %q, want %q", tc.target, tc.command, cmd, tc.wantCmd) - } - }) - } -} - -func TestApprovalPreflight_MergesClassAdditionsIntoSafetyAndVerification(t *testing.T) { - // End-to-end check that approvalPreflight surfaces the per-class - // additions to the operator without losing the default safety / - // verification content. - req := &approval.ApprovalRequest{ - ID: "approval-systemctl-restart", - Command: "systemctl restart nginx", - TargetType: "agent", - TargetID: "agent-1", - TargetName: "edge-1", - Context: "restart nginx after Patrol detected stale config", - } - preflight := approvalPreflight(req) - if preflight == nil { - t.Fatalf("expected preflight, got nil") - } - - joinedSafety := strings.Join(preflight.SafetyChecks, " | ") - if !strings.Contains(joinedSafety, "Approval is scoped to the current organization") { - t.Fatalf("default safety check missing: %v", preflight.SafetyChecks) - } - if !strings.Contains(joinedSafety, "briefly unavailable") { - t.Fatalf("class-specific safety check missing: %v", preflight.SafetyChecks) - } - - joinedVerify := strings.Join(preflight.VerificationSteps, " | ") - if !strings.Contains(joinedVerify, "Persist unified action audit lifecycle") { - t.Fatalf("default verification step missing: %v", preflight.VerificationSteps) - } - if !strings.Contains(joinedVerify, "systemctl is-active") { - t.Fatalf("class-specific verification step missing: %v", preflight.VerificationSteps) - } -} diff --git a/internal/ai/tools/control_docker_helpers_test.go b/internal/ai/tools/control_docker_helpers_test.go deleted file mode 100644 index 46a3602a00..0000000000 --- a/internal/ai/tools/control_docker_helpers_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package tools - -import ( - "testing" - - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestResolveDockerContainer(t *testing.T) { - t.Run("NoStateProvider", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{}) - _, _, err := exec.resolveDockerContainer("web", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "read state not available") - }) - - t.Run("HostMatch", func(t *testing.T) { - state := models.StateSnapshot{ - DockerHosts: []models.DockerHost{ - { - ID: "host1", - Hostname: "dock1", - Containers: []models.DockerContainer{ - {ID: "abc123", Name: "web"}, - }, - }, - }, - } - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}}) - - container, host, err := exec.resolveDockerContainer("web", "dock1") - require.NoError(t, err) - assert.Equal(t, "web", container.Name) - assert.Equal(t, "host1", host.ID) - }) - - t.Run("HostNotFound", func(t *testing.T) { - state := models.StateSnapshot{ - DockerHosts: []models.DockerHost{ - { - ID: "host1", - Hostname: "dock1", - Containers: []models.DockerContainer{ - {ID: "abc123", Name: "web"}, - }, - }, - }, - } - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}}) - - _, _, err := exec.resolveDockerContainer("web", "missing") - require.Error(t, err) - assert.Contains(t, err.Error(), "not found on host") - }) - - t.Run("MultipleHosts", func(t *testing.T) { - state := models.StateSnapshot{ - DockerHosts: []models.DockerHost{ - { - ID: "host1", - DisplayName: "Dock One", - Containers: []models.DockerContainer{ - {ID: "abcdef", Name: "web"}, - }, - }, - { - ID: "host2", - DisplayName: "Dock Two", - Containers: []models.DockerContainer{ - {ID: "abc999", Name: "web"}, - }, - }, - }, - } - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}}) - - _, _, err := exec.resolveDockerContainer("web", "") - require.Error(t, err) - assert.Contains(t, err.Error(), "multiple Docker hosts") - assert.Contains(t, err.Error(), "Dock One") - }) - - t.Run("IDPrefixMatch", func(t *testing.T) { - state := models.StateSnapshot{ - DockerHosts: []models.DockerHost{ - { - ID: "host1", - Hostname: "dock1", - Containers: []models.DockerContainer{ - {ID: "abcdef", Name: "web"}, - }, - }, - }, - } - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: state}}) - - container, _, err := exec.resolveDockerContainer("abc", "") - require.NoError(t, err) - assert.Equal(t, "abcdef", container.ID) - }) - - t.Run("PreservesAgentID", func(t *testing.T) { - state := models.StateSnapshot{ - DockerHosts: []models.DockerHost{ - { - ID: "host1", - AgentID: "agent1", - Hostname: "dock1", - Containers: []models.DockerContainer{ - {ID: "abc123", Name: "web"}, - }, - }, - }, - } - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "agent1", Hostname: "node1"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - AgentServer: agentSrv, - }) - - _, host, err := exec.resolveDockerContainer("web", "dock1") - require.NoError(t, err) - assert.Equal(t, "agent1", host.AgentID) - assert.Equal(t, "node1", exec.getAgentHostnameForDockerHost(host)) - }) -} - -func TestGetAgentHostnameForDockerHost(t *testing.T) { - t.Run("NoAgentServer", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{}) - host := &models.DockerHost{Hostname: "dock1"} - assert.Equal(t, "dock1", exec.getAgentHostnameForDockerHost(host)) - }) - - t.Run("AgentIDMatch", func(t *testing.T) { - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "agent1", Hostname: "node1"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) - host := &models.DockerHost{AgentID: "agent1", Hostname: "dock1"} - assert.Equal(t, "node1", exec.getAgentHostnameForDockerHost(host)) - }) - - t.Run("FallbackHostname", func(t *testing.T) { - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "agent2", Hostname: "node2"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) - host := &models.DockerHost{AgentID: "agent1", Hostname: "dock1"} - assert.Equal(t, "dock1", exec.getAgentHostnameForDockerHost(host)) - }) -} diff --git a/internal/ai/tools/control_helpers_test.go b/internal/ai/tools/control_helpers_test.go deleted file mode 100644 index 5d5f2befac..0000000000 --- a/internal/ai/tools/control_helpers_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package tools - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCreateApprovalRecord(t *testing.T) { - approval.SetStore(nil) - assert.Empty(t, createApprovalRecord("ls", "agent", "h1", "host1", "ctx")) - - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - - approvalID := createApprovalRecord("ls", "agent", "h1", "host1", "ctx") - require.NotEmpty(t, approvalID) - - req, ok := store.GetApproval(approvalID) - require.True(t, ok) - assert.Equal(t, "ls", req.Command) - assert.Equal(t, "agent", req.TargetType) - assert.Equal(t, "h1", req.TargetID) - assert.Equal(t, "host1", req.TargetName) - assert.Equal(t, "ctx", req.Context) - require.NotNil(t, req.Plan) - assert.Equal(t, approvalID, req.Plan.RequestID) - assert.Equal(t, "ctx", req.Plan.Message) - require.NotNil(t, req.ContextConfidence) - assert.Equal(t, approval.ContextConfidenceVerified, req.ContextConfidence.Level) - require.NotNil(t, req.Preflight) - assert.Contains(t, req.Preflight.Target, "host1") - assert.Contains(t, req.Preflight.DryRunSummary, "dry run") -} - -func TestIsPreApproved(t *testing.T) { - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - - assert.False(t, isPreApproved(map[string]interface{}{})) - assert.False(t, isPreApproved(agentcapabilities.WithApprovalArgument(nil, "missing"))) - - req := &approval.ApprovalRequest{ - ID: "app-1", - Command: "ls", - } - require.NoError(t, store.CreateApproval(req)) - - assert.False(t, isPreApproved(agentcapabilities.WithApprovalArgument(nil, "app-1"))) - - _, err = store.Approve("app-1", "tester") - require.NoError(t, err) - assert.True(t, isPreApproved(agentcapabilities.WithApprovalArgument(nil, "app-1"))) -} - -func TestConsumeApprovalWithValidation_RejectsCrossOrg(t *testing.T) { - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - - req := &approval.ApprovalRequest{ - ID: "app-1", - OrgID: "org-a", - Command: "ls", - TargetType: "agent", - TargetID: "h1", - } - require.NoError(t, store.CreateApproval(req)) - _, err = store.Approve("app-1", "tester") - require.NoError(t, err) - - ok := consumeApprovalWithValidation(agentcapabilities.WithApprovalArgument(nil, "app-1"), "org-b", "ls", "agent", "h1") - assert.False(t, ok) - - stored, found := store.GetApproval("app-1") - require.True(t, found) - assert.False(t, stored.Consumed) -} - -func TestConsumeApprovalWithValidation_AllowsMatchingOrg(t *testing.T) { - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - - req := &approval.ApprovalRequest{ - ID: "app-2", - OrgID: "org-a", - Command: "ls", - TargetType: "agent", - TargetID: "h1", - } - require.NoError(t, store.CreateApproval(req)) - _, err = store.Approve("app-2", "tester") - require.NoError(t, err) - - ok := consumeApprovalWithValidation(agentcapabilities.WithApprovalArgument(nil, "app-2"), "org-a", "ls", "agent", "h1") - assert.True(t, ok) - - stored, found := store.GetApproval("app-2") - require.True(t, found) - assert.False(t, stored.Consumed, "validation must not consume before the agentexec authorization boundary") -} - -func TestFormattingHelpers(t *testing.T) { - t.Run("formatApprovalNeeded", func(t *testing.T) { - payload := decodePayload(t, formatApprovalNeeded("ls", "ok", "id-1"), "APPROVAL_REQUIRED: ") - assert.Equal(t, "approval_required", payload["type"]) - assert.Equal(t, "ls", payload["command"]) - assert.Equal(t, "ok", payload["reason"]) - assert.Equal(t, "id-1", payload["approval_id"]) - }) - - t.Run("formatPolicyBlocked", func(t *testing.T) { - payload := decodePayload(t, formatPolicyBlocked("rm -rf /", "blocked"), "POLICY_BLOCKED: ") - assert.Equal(t, "policy_blocked", payload["type"]) - assert.Equal(t, "rm -rf /", payload["command"]) - assert.Equal(t, "blocked", payload["reason"]) - }) - - t.Run("formatTargetHostRequired", func(t *testing.T) { - agents := []agentexec.ConnectedAgent{ - {Hostname: "node-1"}, - {AgentID: "agent-2"}, - {Hostname: "node-3"}, - {Hostname: "node-4"}, - {Hostname: "node-5"}, - {Hostname: "node-6"}, - {Hostname: "node-7"}, - } - msg := formatTargetHostRequired(agents) - assert.Contains(t, msg, "Available: node-1, agent-2, node-3, node-4, node-5, node-6") - assert.Contains(t, msg, "(+1 more)") - }) - - t.Run("formatControlApprovalNeeded", func(t *testing.T) { - payload := decodePayload(t, formatControlApprovalNeeded("vm1", 101, "start", "qm start 101", "id-2"), "APPROVAL_REQUIRED: ") - assert.Equal(t, "approval_required", payload["type"]) - assert.Equal(t, "vm1", payload["guest_name"]) - assert.Equal(t, float64(101), payload["guest_vmid"]) - assert.Equal(t, "start", payload["action"]) - assert.Equal(t, "qm start 101", payload["command"]) - assert.Equal(t, "id-2", payload["approval_id"]) - }) - - t.Run("formatDockerApprovalNeeded", func(t *testing.T) { - payload := decodePayload(t, formatDockerApprovalNeeded("web", "node-1", "restart", "docker restart web", "id-3"), "APPROVAL_REQUIRED: ") - assert.Equal(t, "approval_required", payload["type"]) - assert.Equal(t, "web", payload["container_name"]) - assert.Equal(t, "node-1", payload["docker_host"]) - assert.Equal(t, "restart", payload["action"]) - assert.Equal(t, "docker restart web", payload["command"]) - assert.Equal(t, "id-3", payload["approval_id"]) - }) - - t.Run("formatAppContainerApprovalNeeded", func(t *testing.T) { - payload := decodePayload(t, formatAppContainerApprovalNeeded("Nextcloud", "truenas-main", "restart", "truenas app restart Nextcloud on truenas-main", "id-4"), "APPROVAL_REQUIRED: ") - assert.Equal(t, "approval_required", payload["type"]) - assert.Equal(t, "Nextcloud", payload["resource_name"]) - assert.Equal(t, "truenas-main", payload["resource_host"]) - assert.Equal(t, "app-container", payload["resource_type"]) - assert.Equal(t, "restart", payload["action"]) - assert.Equal(t, "truenas app restart Nextcloud on truenas-main", payload["command"]) - assert.Equal(t, "id-4", payload["approval_id"]) - }) -} - -func decodePayload(t *testing.T, value, prefix string) map[string]interface{} { - t.Helper() - require.True(t, strings.HasPrefix(value, prefix)) - - var payload map[string]interface{} - raw := strings.TrimPrefix(value, prefix) - err := json.Unmarshal([]byte(raw), &payload) - require.NoError(t, err) - return payload -} diff --git a/internal/ai/tools/control_run_command_test.go b/internal/ai/tools/control_run_command_test.go deleted file mode 100644 index a6816a6407..0000000000 --- a/internal/ai/tools/control_run_command_test.go +++ /dev/null @@ -1,505 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "strings" - "testing" - "time" - - "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" - "github.com/rcourtman/pulse-go-rewrite/internal/models" - "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -func mustParseJSONMap(t *testing.T, text string) map[string]interface{} { - t.Helper() - var out map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(text), &out)) - return out -} - -func mustParseApprovalPayload(t *testing.T, text string) map[string]interface{} { - t.Helper() - require.True(t, strings.HasPrefix(text, "APPROVAL_REQUIRED: ")) - return mustParseJSONMap(t, strings.TrimPrefix(text, "APPROVAL_REQUIRED: ")) -} - -func TestPulseToolExecutor_ExecuteRunCommand(t *testing.T) { - ctx := context.Background() - - t.Run("MissingCommand", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{}) - result, err := exec.executeRunCommand(ctx, map[string]interface{}{}) - assert.NoError(t, err) - assert.True(t, result.IsError) - 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() - - exec := NewPulseToolExecutor(ExecutorConfig{Policy: policy}) - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "rm -rf /", - }) - assert.NoError(t, err) - assert.False(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "POLICY_BLOCKED") - policy.AssertExpectations(t) - }) - - t.Run("TargetHostRequired", func(t *testing.T) { - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "a1", Hostname: "node1"}, - {AgentID: "a2", Hostname: "node2"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) - - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "ls", - }) - assert.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "Multiple agents are connected") - }) - - t.Run("ControlledRequiresApproval", func(t *testing.T) { - approval.SetStore(nil) - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "agent-1", Hostname: "tower"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentSrv, - ControlLevel: ControlLevelControlled, - }) - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "ls", - "target_host": "tower", - }) - assert.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "APPROVAL_REQUIRED") - }) - - t.Run("ControlledApprovalUsesResolvedRoutingTarget", func(t *testing.T) { - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - actionStore := unifiedresources.NewMemoryStore() - - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "agent-1", Hostname: "tower"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentSrv, - ControlLevel: ControlLevelControlled, - ActionAuditStore: actionStore, - }) - // Session context target must not influence command approval binding. - exec.SetContext("host", "session-target", false) - - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "uptime", - "target_host": "tower", - }) - require.NoError(t, err) - - payload := mustParseApprovalPayload(t, result.Content[0].Text) - approvalID, _ := payload["approval_id"].(string) - require.NotEmpty(t, approvalID) - - req, found := store.GetApproval(approvalID) - require.True(t, found) - assert.Equal(t, "agent", req.TargetType) - assert.Equal(t, "agent-1", req.TargetID) - assert.Equal(t, "tower", req.TargetName) - require.NotNil(t, req.Plan) - assert.Equal(t, approvalID, req.Plan.RequestID) - assert.True(t, req.Plan.RequiresApproval) - assert.Equal(t, unifiedresources.ApprovalAdmin, req.Plan.ApprovalPolicy) - require.NotNil(t, req.ContextConfidence) - assert.Equal(t, approval.ContextConfidenceVerified, req.ContextConfidence.Level) - require.NotNil(t, req.Preflight) - assert.Contains(t, req.Preflight.Target, "tower") - assert.False(t, req.Preflight.DryRunAvailable) - preflight, ok := payload["preflight"].(map[string]interface{}) - require.True(t, ok, "approval payload should include preflight") - assert.Equal(t, false, preflight["dry_run_available"]) - assert.Contains(t, preflight["target"].(string), "tower") - - audits, err := actionStore.GetActionAudits("", time.Time{}, 10) - require.NoError(t, err) - require.Len(t, audits, 1) - assert.Equal(t, unifiedresources.ActionStatePending, audits[0].State) - assert.Equal(t, req.Plan.ActionID, audits[0].ID) - assert.Equal(t, "pulse_control", audits[0].Request.CapabilityName) - - events, err := actionStore.GetActionLifecycleEvents(req.Plan.ActionID, time.Time{}, 10) - require.NoError(t, err) - require.Len(t, events, 2) - assert.Equal(t, unifiedresources.ActionStatePending, events[0].State) - assert.Equal(t, unifiedresources.ActionStatePlanned, events[1].State) - }) - - t.Run("ControlledConsumesApprovedCommandWithResolvedRoutingTarget", func(t *testing.T) { - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - - // PlanHash is the approval-equivalent hash of the (command, target, - // reason) the operator approved. The broker's drift check refuses - // execution when the executing payload hashes to anything different. - // For this happy-path test, compute the hash with the exact inputs - // executeCommandWithAudit will see when executeRunCommand routes the - // approved "uptime" command to the resolved tower agent. - approvedHash := approvalPlanHash( - "action-1", - "approval-1", - "pulse_control", - "tower", - "uptime", - "agent", - "", - `run command "uptime" on tower`, - ) - req := &approval.ApprovalRequest{ - ID: "approval-1", - Command: "uptime", - TargetType: "agent", - TargetID: "agent-1", - Plan: &unifiedresources.ActionPlan{ - ActionID: "action-1", - RequestID: "approval-1", - Allowed: true, - RequiresApproval: true, - ApprovalPolicy: unifiedresources.ApprovalAdmin, - Message: "run command", - PlanHash: approvedHash, - }, - } - require.NoError(t, store.CreateApproval(req)) - _, err = store.Approve("approval-1", "tester") - require.NoError(t, err) - - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "agent-1", Hostname: "tower"}, - }} - agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ - {AgentID: "agent-1", Hostname: "tower"}, - }).Maybe() - agentSrv.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "uptime" && - payload.ApprovalID == "approval-1" && - payload.TargetType == "agent" && - payload.TargetID == "" - })).Return(&agentexec.CommandResultPayload{ - Stdout: "ok", - ExitCode: 0, - }, nil).Once() - - actionStore := unifiedresources.NewMemoryStore() - exec := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentSrv, - ControlLevel: ControlLevelControlled, - ActionAuditStore: actionStore, - }) - exec.SetContext("host", "different-session-target", false) - - result, err := exec.executeRunCommand(ctx, agentcapabilities.WithApprovalArgument(map[string]interface{}{ - "command": "uptime", - "target_host": "tower", - }, "approval-1")) - require.NoError(t, err) - - resp := mustParseJSONMap(t, result.Content[0].Text) - assert.Equal(t, true, resp["success"]) - assert.Equal(t, float64(0), resp["exit_code"]) - agentSrv.AssertExpectations(t) - - consumed, found := store.GetApproval("approval-1") - require.True(t, found) - 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) - require.Len(t, audits, 1) - assert.Equal(t, "action-1", audits[0].ID) - assert.Equal(t, unifiedresources.ActionStateCompleted, audits[0].State) - assert.Equal(t, approvedHash, audits[0].Plan.PlanHash) - }) - - t.Run("ExecuteSuccess", func(t *testing.T) { - store := unifiedresources.NewMemoryStore() - agentSrv := &mockAgentServer{} - agentSrv.On("GetConnectedAgents").Return([]agentexec.ConnectedAgent{ - {AgentID: "agent1", Hostname: "node1"}, - }).Twice() - agentSrv.On("ExecuteCommand", mock.Anything, "agent1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - // For direct agent targets, TargetID is empty - resolveTargetForCommand returns "" for agent type - return payload.Command == "uptime" && payload.TargetType == "agent" && payload.TargetID == "" - })).Return(&agentexec.CommandResultPayload{ - Stdout: "ok", - ExitCode: 0, - }, nil).Once() - - exec := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentSrv, - ActionAuditStore: store, - }) - exec.SetContext("host", "host1", false) - - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "uptime", - "run_on_host": true, - }) - assert.NoError(t, err) - resp := mustParseJSONMap(t, result.Content[0].Text) - assert.Equal(t, true, resp["success"]) - assert.Equal(t, float64(0), resp["exit_code"]) - assert.Contains(t, resp["output"].(string), "ok") - if v, ok := resp["verification"].(map[string]interface{}); ok { - assert.Equal(t, true, v["ok"]) - } - agentSrv.AssertExpectations(t) - - audits, err := store.GetActionAudits("", time.Time{}, 10) - require.NoError(t, err) - require.Len(t, audits, 1) - assert.Equal(t, "pulse_control", audits[0].Request.CapabilityName) - assert.Contains(t, audits[0].Plan.Message, "run command \"uptime\"") - assert.Empty(t, audits[0].Plan.ResourceVersion) - assert.Empty(t, audits[0].Plan.PolicyVersion) - assert.NotEmpty(t, audits[0].Plan.PlanHash) - - planJSON, err := json.Marshal(audits[0].Plan) - require.NoError(t, err) - _ = mustParseJSONMap(t, string(planJSON)) - - events, err := store.GetActionLifecycleEvents(audits[0].ID, time.Time{}, 10) - require.NoError(t, err) - require.Len(t, events, 3) - assert.Equal(t, unifiedresources.ActionStatePlanned, events[2].State) - assert.Equal(t, unifiedresources.ActionStateExecuting, events[1].State) - assert.Equal(t, unifiedresources.ActionStateCompleted, events[0].State) - }) -} - -func TestPulseToolExecutor_RunCommandLXCRouting(t *testing.T) { - ctx := context.Background() - - t.Run("LXCCommandRoutedCorrectly", func(t *testing.T) { - // Test that commands targeting LXCs are routed with correct target type/ID - // The agent handles sh -c wrapping, so tool just sends raw command - agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "pve-node"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - // Tool sends raw command, agent will wrap in sh -c - return cmd.TargetType == "container" && - cmd.TargetID == "108" && - cmd.Command == "grep pattern /var/log/*.log" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "matched line", - }, nil) - - state := models.StateSnapshot{ - Containers: []models.Container{ - {VMID: 108, Name: "jellyfin", Node: "pve-node"}, - }, - } - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "grep pattern /var/log/*.log", - "target_host": "jellyfin", - }) - require.NoError(t, err) - resp := mustParseJSONMap(t, result.Content[0].Text) - assert.Equal(t, true, resp["success"]) - assert.Equal(t, "jellyfin", resp["target_host"]) - mockAgent.AssertExpectations(t) - }) - - t.Run("VMCommandRoutedCorrectly", func(t *testing.T) { - // Test that commands targeting VMs are routed with correct target type/ID - agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "pve-node"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "vm" && - cmd.TargetID == "100" && - cmd.Command == "ls /tmp/*.txt" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "result", - }, nil) - - state := models.StateSnapshot{ - VMs: []models.VM{ - {VMID: 100, Name: "test-vm", Node: "pve-node"}, - }, - } - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "ls /tmp/*.txt", - "target_host": "test-vm", - }) - require.NoError(t, err) - resp := mustParseJSONMap(t, result.Content[0].Text) - assert.Equal(t, true, resp["success"]) - assert.Equal(t, "test-vm", resp["target_host"]) - mockAgent.AssertExpectations(t) - }) - - t.Run("DirectHostRoutedCorrectly", func(t *testing.T) { - // Direct host commands are canonicalized to target type "agent" - agents := []agentexec.ConnectedAgent{{AgentID: "agent", Hostname: "tower"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "agent" && - cmd.Command == "ls /tmp/*.txt" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "files", - }, nil) - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: models.StateSnapshot{}}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeRunCommand(ctx, map[string]interface{}{ - "command": "ls /tmp/*.txt", - "target_host": "tower", - }) - require.NoError(t, err) - resp := mustParseJSONMap(t, result.Content[0].Text) - assert.Equal(t, true, resp["success"]) - assert.Equal(t, "tower", resp["target_host"]) - mockAgent.AssertExpectations(t) - }) -} - -func TestPulseToolExecutor_FindAgentForCommand(t *testing.T) { - t.Run("NoAgentServer", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{}) - assert.Empty(t, exec.findAgentForCommand(false, "")) - }) - - t.Run("NoAgents", func(t *testing.T) { - agentSrv := &mockAgentServer{} - exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) - assert.Empty(t, exec.findAgentForCommand(false, "")) - }) - - t.Run("TargetHostMatches", func(t *testing.T) { - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "a1", Hostname: "node1"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) - assert.Equal(t, "a1", exec.findAgentForCommand(false, "a1")) - }) - - t.Run("MultipleAgentsNoTarget", func(t *testing.T) { - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "a1", Hostname: "node1"}, - {AgentID: "a2", Hostname: "node2"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) - assert.Empty(t, exec.findAgentForCommand(false, "")) - }) - - t.Run("SingleAgentNoTarget", func(t *testing.T) { - agentSrv := &mockAgentServer{agents: []agentexec.ConnectedAgent{ - {AgentID: "a1", Hostname: "node1"}, - }} - exec := NewPulseToolExecutor(ExecutorConfig{AgentServer: agentSrv}) - assert.Equal(t, "a1", exec.findAgentForCommand(false, "")) - }) -} diff --git a/internal/ai/tools/data_types.go b/internal/ai/tools/data_types.go index 5897911a1d..1458782f08 100644 --- a/internal/ai/tools/data_types.go +++ b/internal/ai/tools/data_types.go @@ -1356,18 +1356,6 @@ type DockerCheckUpdatesResponse struct { Command DockerCommandStatus `json:"command"` } -// DockerUpdateContainerResponse is returned by pulse_update_docker_container -type DockerUpdateContainerResponse struct { - Success bool `json:"success"` - TargetID string `json:"target_id"` - ContainerID string `json:"container_id"` - ContainerName string `json:"container_name"` - CommandID string `json:"command_id"` - Message string `json:"message"` - Command DockerCommandStatus `json:"command"` - Verification map[string]interface{} `json:"verification,omitempty"` -} - // ========== Kubernetes Types ========== // KubernetesClustersResponse is returned by pulse_get_kubernetes_clusters diff --git a/internal/ai/tools/data_types_branchcov0719late_test.go b/internal/ai/tools/data_types_branchcov0719late_test.go index bd67972b51..350ff72e2e 100644 --- a/internal/ai/tools/data_types_branchcov0719late_test.go +++ b/internal/ai/tools/data_types_branchcov0719late_test.go @@ -170,32 +170,6 @@ func TestDataTypesBranchcov0719latePVEClusterStatusNormalize(t *testing.T) { }) } -// TestDataTypesBranchcov0719lateErrExecutionContextUnavailableError covers -// ErrExecutionContextUnavailable.Error(). It returns Message verbatim. -func TestDataTypesBranchcov0719lateErrExecutionContextUnavailableError(t *testing.T) { - t.Run("ReturnsMessageVerbatim", func(t *testing.T) { - const msg = "write would execute on the host node instead of inside the system-container" - err := &ErrExecutionContextUnavailable{ - TargetHost: "homepage-docker", - ResolvedKind: "system-container", - ResolvedNode: "pve-node", - Transport: "direct", - Message: msg, - } - assert.Equal(t, msg, err.Error()) - }) - - t.Run("EmptyMessageYieldsEmptyString", func(t *testing.T) { - err := &ErrExecutionContextUnavailable{} - assert.Equal(t, "", err.Error()) - }) - - t.Run("SatisfiesErrorInterface", func(t *testing.T) { - var err error = &ErrExecutionContextUnavailable{Message: "boom"} - assert.Equal(t, "boom", err.Error()) - }) -} - // TestDataTypesBranchcov0719lateValidateCurrentResourceAvailable covers // PulseToolExecutor.ValidateCurrentResourceAvailable across its nil/empty // error arms and its single-resource OK arm. diff --git a/internal/ai/tools/docker_updates_test.go b/internal/ai/tools/docker_updates_test.go index 7b704866d3..36e3674544 100644 --- a/internal/ai/tools/docker_updates_test.go +++ b/internal/ai/tools/docker_updates_test.go @@ -148,187 +148,3 @@ func TestExecuteCheckDockerUpdates_DoesNotRetryNonTransientError(t *testing.T) { assert.NotContains(t, result.Content[0].Text, "after 3 attempts") updatesProvider.AssertNumberOfCalls(t, "TriggerUpdateCheck", 1) } - -func TestExecuteUpdateDockerContainer_RetriesTransientError(t *testing.T) { - origSleep := dockerUpdateQueueSleepFn - dockerUpdateQueueSleepFn = func(context.Context, time.Duration) error { return nil } - t.Cleanup(func() { dockerUpdateQueueSleepFn = origSleep }) - origVerifySleep := dockerUpdateVerifySleepFn - dockerUpdateVerifySleepFn = func(context.Context, time.Duration) error { return nil } - t.Cleanup(func() { dockerUpdateVerifySleepFn = origVerifySleep }) - - state := models.StateSnapshot{ - DockerHosts: []models.DockerHost{ - { - ID: "host1", - Hostname: "dock1", - DisplayName: "Dock One", - Containers: []models.DockerContainer{ - {ID: "c1", Name: "/nginx"}, - }, - }, - }, - } - - updatesProvider := &mockUpdatesProvider{} - updatesProvider.On("IsUpdateActionsEnabled").Return(true).Once() - updatesProvider.On("UpdateContainer", "host1", "c1", "nginx").Return(DockerCommandStatus{}, errors.New("i/o timeout")).Once() - updatesProvider.On("UpdateContainer", "host1", "c1", "nginx").Return(DockerCommandStatus{ - ID: "cmd-update", - Type: "update", - Status: "queued", - }, nil).Once() - updatesProvider.On("GetCommandStatus", "cmd-update").Return(DockerCommandStatus{ - ID: "cmd-update", - Type: "update", - Status: "completed", - Message: "Container nginx updated successfully", - }, true) - - exec := NewPulseToolExecutor(ExecutorConfig{ - UpdatesProvider: updatesProvider, - StateProvider: &mockStateProvider{state: state}, - ControlLevel: ControlLevelAutonomous, - }) - - result, err := exec.executeUpdateDockerContainer(context.Background(), map[string]interface{}{ - "host": "dock1", - "container": "c1", - }) - require.NoError(t, err) - require.False(t, result.IsError) - - var resp DockerUpdateContainerResponse - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.Equal(t, "cmd-update", resp.CommandID) - updatesProvider.AssertExpectations(t) -} - -func newDockerUpdateExecutor(t *testing.T, updatesProvider *mockUpdatesProvider) *PulseToolExecutor { - t.Helper() - - origVerifySleep := dockerUpdateVerifySleepFn - dockerUpdateVerifySleepFn = func(context.Context, time.Duration) error { return nil } - t.Cleanup(func() { dockerUpdateVerifySleepFn = origVerifySleep }) - - state := models.StateSnapshot{ - DockerHosts: []models.DockerHost{ - { - ID: "host1", - Hostname: "dock1", - DisplayName: "Dock One", - Containers: []models.DockerContainer{ - {ID: "c1", Name: "/nginx"}, - }, - }, - }, - } - - return NewPulseToolExecutor(ExecutorConfig{ - UpdatesProvider: updatesProvider, - StateProvider: &mockStateProvider{state: state}, - ControlLevel: ControlLevelAutonomous, - }) -} - -func TestExecuteUpdateDockerContainer_VerifiedSuccess(t *testing.T) { - updatesProvider := &mockUpdatesProvider{} - updatesProvider.On("IsUpdateActionsEnabled").Return(true).Once() - updatesProvider.On("UpdateContainer", "host1", "c1", "nginx").Return(DockerCommandStatus{ - ID: "cmd-update", - Type: "update_container", - Status: "queued", - }, nil).Once() - // The command is dispatched and in progress first, then completes. - updatesProvider.On("GetCommandStatus", "cmd-update").Return(DockerCommandStatus{ - ID: "cmd-update", Status: "dispatched", - }, true).Once() - updatesProvider.On("GetCommandStatus", "cmd-update").Return(DockerCommandStatus{ - ID: "cmd-update", Status: "in_progress", Message: "Pulling image nginx...", - }, true).Once() - updatesProvider.On("GetCommandStatus", "cmd-update").Return(DockerCommandStatus{ - ID: "cmd-update", Status: "completed", Message: "Container nginx updated successfully", - }, true).Once() - - exec := newDockerUpdateExecutor(t, updatesProvider) - result, err := exec.executeUpdateDockerContainer(context.Background(), map[string]interface{}{ - "host": "dock1", - "container": "c1", - }) - require.NoError(t, err) - require.False(t, result.IsError) - - var resp DockerUpdateContainerResponse - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp.Success) - assert.Contains(t, resp.Message, "Update verified") - require.NotNil(t, resp.Verification) - assert.Equal(t, true, resp.Verification["confirmed"]) - assert.Equal(t, "verified", resp.Verification["outcome"]) - assert.Equal(t, "command_status", resp.Verification["method"]) - updatesProvider.AssertExpectations(t) -} - -func TestExecuteUpdateDockerContainer_VerifiedFailure(t *testing.T) { - updatesProvider := &mockUpdatesProvider{} - updatesProvider.On("IsUpdateActionsEnabled").Return(true).Once() - updatesProvider.On("UpdateContainer", "host1", "c1", "nginx").Return(DockerCommandStatus{ - ID: "cmd-update", - Type: "update_container", - Status: "queued", - }, nil).Once() - updatesProvider.On("GetCommandStatus", "cmd-update").Return(DockerCommandStatus{ - ID: "cmd-update", - Status: "failed", - FailureReason: "New container crashed immediately (exit code 1)", - }, true).Once() - - exec := newDockerUpdateExecutor(t, updatesProvider) - result, err := exec.executeUpdateDockerContainer(context.Background(), map[string]interface{}{ - "host": "dock1", - "container": "c1", - }) - require.NoError(t, err) - assert.True(t, result.IsError) - - var resp DockerUpdateContainerResponse - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.False(t, resp.Success) - assert.Contains(t, resp.Message, "failed") - assert.Contains(t, resp.Message, "crashed immediately") - require.NotNil(t, resp.Verification) - assert.Equal(t, false, resp.Verification["confirmed"]) - assert.Equal(t, "failed", resp.Verification["outcome"]) - updatesProvider.AssertExpectations(t) -} - -func TestExecuteUpdateDockerContainer_InconclusiveAfterWindow(t *testing.T) { - updatesProvider := &mockUpdatesProvider{} - updatesProvider.On("IsUpdateActionsEnabled").Return(true).Once() - updatesProvider.On("UpdateContainer", "host1", "c1", "nginx").Return(DockerCommandStatus{ - ID: "cmd-update", - Type: "update_container", - Status: "queued", - }, nil).Once() - // The update never reaches a terminal state within the window. - updatesProvider.On("GetCommandStatus", "cmd-update").Return(DockerCommandStatus{ - ID: "cmd-update", Status: "in_progress", Message: "Pulling image nginx...", - }, true) - - exec := newDockerUpdateExecutor(t, updatesProvider) - result, err := exec.executeUpdateDockerContainer(context.Background(), map[string]interface{}{ - "host": "dock1", - "container": "c1", - }) - require.NoError(t, err) - require.False(t, result.IsError) - - var resp DockerUpdateContainerResponse - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp.Success) // queueing succeeded; completion is explicitly unverified - assert.Contains(t, resp.Message, "NOT yet verified") - require.NotNil(t, resp.Verification) - assert.Equal(t, false, resp.Verification["confirmed"]) - assert.Equal(t, "inconclusive", resp.Verification["outcome"]) - updatesProvider.AssertNumberOfCalls(t, "GetCommandStatus", dockerUpdateVerifyMaxAttempts) -} diff --git a/internal/ai/tools/execution_profile.go b/internal/ai/tools/execution_profile.go index d6066a13ab..e29821e153 100644 --- a/internal/ai/tools/execution_profile.go +++ b/internal/ai/tools/execution_profile.go @@ -70,8 +70,8 @@ func patrolDetectionPulseStateAllowlist() map[string]bool { // ApplyExecutionProfile applies the profile's policy to this executor // instance (normally a request-scoped clone). Both Patrol profiles deny -// infrastructure mutations, clear any inherited autonomous mode, and mark -// the executor non-interactive; they differ only in pulse-state policy. +// infrastructure mutations and mark the executor non-interactive; they +// differ only in pulse-state policy. // Unknown profiles are rejected outright (panic on programmer error) // rather than falling through to the interactive default's permissions. func (e *PulseToolExecutor) ApplyExecutionProfile(profile ExecutionProfile) { @@ -81,11 +81,9 @@ func (e *PulseToolExecutor) ApplyExecutionProfile(profile ExecutionProfile) { e.executionProfile = profile switch profile { case ProfilePatrolDetection: - e.isAutonomous = false e.denyInfrastructureMutations = true e.pulseStateAllowlist = patrolDetectionPulseStateAllowlist() case ProfilePatrolInvestigation: - e.isAutonomous = false e.denyInfrastructureMutations = true e.pulseStateAllowlist = map[string]bool{agentcapabilities.PatrolProposeActionToolName: true} default: diff --git a/internal/ai/tools/executor.go b/internal/ai/tools/executor.go index 0ea4785066..88fd7a9665 100644 --- a/internal/ai/tools/executor.go +++ b/internal/ai/tools/executor.go @@ -654,10 +654,9 @@ type PulseToolExecutor struct { controlLevel ControlLevel // Current execution context - targetType string - targetID string - isAutonomous bool - orgID string + targetType string + targetID string + orgID string // hasExecuteAuthority is request-local transport authority. Control // settings describe operator policy; they never grant a chat-only token // permission to execute infrastructure mutations. @@ -667,9 +666,7 @@ type PulseToolExecutor struct { // restriction for non-interactive read-only workloads (e.g. Patrol // investigations): every infrastructure-mutating invocation is // blocked by the registry before its handler runs, regardless of - // control level or autonomy. Core-owned and never serialized; it is - // deliberately separate from isAutonomous, which only suppresses - // interactive questions and grants no mutation authority. + // control level. Core-owned and never serialized. denyInfrastructureMutations bool // pulseStateAllowlist restricts pulse-state mutations to the named // tools. Nil and an empty map both deny all. @@ -833,7 +830,6 @@ func (e *PulseToolExecutor) Clone() *PulseToolExecutor { controlLevel: e.controlLevel, targetType: e.targetType, targetID: e.targetID, - isAutonomous: e.isAutonomous, orgID: e.orgID, hasExecuteAuthority: e.hasExecuteAuthority, executeAuthorityBound: e.executeAuthorityBound, @@ -898,16 +894,9 @@ func (e *PulseToolExecutor) hasCanonicalReadState() bool { } // SetContext sets the current execution context -func (e *PulseToolExecutor) SetContext(targetType, targetID string, autonomous bool) { +func (e *PulseToolExecutor) SetContext(targetType, targetID string) { e.targetType = targetType e.targetID = targetID - e.isAutonomous = autonomous -} - -// SetAutonomousMode updates only the execution mode, preserving any -// session-scoped target context already attached to this executor clone. -func (e *PulseToolExecutor) SetAutonomousMode(enabled bool) { - e.isAutonomous = enabled } // SetOrgID sets the org scope used when creating approval records. @@ -1053,15 +1042,13 @@ func (e *PulseToolExecutor) SetTypedActionPlanner(planner TypedActionPlanner) { e.typedActionPlanner = planner } -// SetOnActionCompleted installs a fire-and-forget callback that runs -// after every terminal-state action audit is persisted (Completed or -// Failed, including refused-before-dispatch failures with stable -// `plan_drift:` / `resource_remediation_locked:` error prefixes). -// Pass nil to disable. Used by the API layer to bridge action -// completion into the agent SSE stream without coupling the tools -// package to the api package. The callback runs on its own -// goroutine; consumers must not assume immediate or in-order -// delivery relative to subsequent dispatches. +// SetOnActionCompleted installs the executor-side seam the API layer +// attaches the agent SSE bridge to. Nothing in the tools package +// dispatches the callback any more: the Assistant plans typed actions +// into Pulse Actions instead of executing them, and the dispatcher +// that used to fire it was removed with the retired execution lanes. +// action.completed events come from API-owned execution. Pass nil to +// disable. func (e *PulseToolExecutor) SetOnActionCompleted(cb func(unifiedresources.ActionAuditRecord)) { if e == nil { return diff --git a/internal/ai/tools/executor_clone_test.go b/internal/ai/tools/executor_clone_test.go index dbe459c02d..79a233ae8c 100644 --- a/internal/ai/tools/executor_clone_test.go +++ b/internal/ai/tools/executor_clone_test.go @@ -8,7 +8,7 @@ func TestPulseToolExecutorCloneIsolatesSessionState(t *testing.T) { resolved := &mockResolvedContext{} original := NewPulseToolExecutor(ExecutorConfig{}) - original.SetContext("node", "node-1", false) + original.SetContext("node", "node-1") original.SetOrgID("tenant-a") original.SetResolvedContext(resolved) original.SetPatrolFindingCreator(creator) @@ -30,16 +30,16 @@ func TestPulseToolExecutorCloneIsolatesSessionState(t *testing.T) { if clone.GetPatrolObserverProposer() != proposer { t.Fatal("Clone() should retain patrol observer proposer availability for the new run") } - if clone.targetType != original.targetType || clone.targetID != original.targetID || clone.isAutonomous != original.isAutonomous { - t.Fatalf("Clone() lost base execution context: got %q/%q/%v", clone.targetType, clone.targetID, clone.isAutonomous) + if clone.targetType != original.targetType || clone.targetID != original.targetID { + t.Fatalf("Clone() lost base execution context: got %q/%q", clone.targetType, clone.targetID) } if clone.orgID != original.orgID { t.Fatalf("Clone() orgID = %q, want %q", clone.orgID, original.orgID) } - clone.SetContext("vm", "vm-201", true) - if original.targetType != "node" || original.targetID != "node-1" || original.isAutonomous { - t.Fatalf("original context mutated after clone update: got %q/%q/%v", original.targetType, original.targetID, original.isAutonomous) + clone.SetContext("vm", "vm-201") + if original.targetType != "node" || original.targetID != "node-1" { + t.Fatalf("original context mutated after clone update: got %q/%q", original.targetType, original.targetID) } } diff --git a/internal/ai/tools/executor_setters_test.go b/internal/ai/tools/executor_setters_test.go index b78ff3b46d..2e5dd8c46b 100644 --- a/internal/ai/tools/executor_setters_test.go +++ b/internal/ai/tools/executor_setters_test.go @@ -36,15 +36,9 @@ func (s *stubAgentProfileManager) GetAgentScope(ctx context.Context, agentID str func TestPulseToolExecutor_Setters(t *testing.T) { exec := NewPulseToolExecutor(ExecutorConfig{}) - exec.SetContext("vm", "101", true) + exec.SetContext("vm", "101") assert.Equal(t, "vm", exec.targetType) assert.Equal(t, "101", exec.targetID) - assert.True(t, exec.isAutonomous) - - exec.SetAutonomousMode(false) - assert.Equal(t, "vm", exec.targetType) - assert.Equal(t, "101", exec.targetID) - assert.False(t, exec.isAutonomous) exec.SetControlLevel(ControlLevelControlled) assert.Equal(t, ControlLevelControlled, exec.controlLevel) diff --git a/internal/ai/tools/file_docker_test.go b/internal/ai/tools/file_docker_test.go index 8e99fe6398..95774757f4 100644 --- a/internal/ai/tools/file_docker_test.go +++ b/internal/ai/tools/file_docker_test.go @@ -2,9 +2,6 @@ package tools import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" "encoding/json" "fmt" "strings" @@ -18,37 +15,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestExecuteFileEditDockerContainerValidation(t *testing.T) { - ctx := context.Background() - - t.Run("InvalidDockerContainerName", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeFileEdit(ctx, map[string]interface{}{ - "action": "read", - "path": "/config/test.json", - "target_host": "tower", - "container": "my container", // space is invalid - }) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "invalid character") - }) - - t.Run("ValidDockerContainerName", func(t *testing.T) { - // This should pass validation but fail on agent lookup - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeFileEdit(ctx, map[string]interface{}{ - "action": "read", - "path": "/config/test.json", - "target_host": "tower", - "container": "my-container_v1.2", - }) - require.NoError(t, err) - // Should fail with "no agent" not "invalid character" - assert.NotContains(t, result.Content[0].Text, "invalid character") - }) -} - func TestExecuteFileReadDocker(t *testing.T) { ctx := context.Background() @@ -182,349 +148,6 @@ func TestExecuteFileReadFailureStatus(t *testing.T) { } } -func TestExecuteFileWriteDocker(t *testing.T) { - ctx := context.Background() - - t.Run("WriteToDockerContainer", func(t *testing.T) { - content := `{"new": "config"}` - encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) - expectedWriteCmd := fmt.Sprintf( - "docker exec %s sh -c %s", - shellEscape("nginx"), - shellEscape(fmt.Sprintf("echo %s | base64 -d > %s", shellEscape(encodedContent), shellEscape("/etc/nginx/nginx.conf"))), - ) - expected := sha256.Sum256([]byte(content)) - expectedHex := hex.EncodeToString(expected[:]) - - agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == expectedWriteCmd - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "", - }, nil) - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return strings.Contains(cmd.Command, "docker exec") && - strings.Contains(cmd.Command, "nginx") && - strings.Contains(cmd.Command, "sha256sum") && - strings.Contains(cmd.Command, "/etc/nginx/nginx.conf") - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: expectedHex + " /etc/nginx/nginx.conf\n", - }, nil) - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: models.StateSnapshot{}}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeFileWrite(ctx, "/etc/nginx/nginx.conf", content, "tower", "nginx", map[string]interface{}{}) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp["success"].(bool)) - assert.Equal(t, "write", resp["action"]) - assert.Equal(t, "nginx", resp["container"]) - assert.Equal(t, float64(len(content)), resp["bytes_written"]) - if v, ok := resp["verification"].(map[string]interface{}); ok { - assert.True(t, v["ok"].(bool)) - } - mockAgent.AssertExpectations(t) - }) - - t.Run("WriteControlledRequiresApproval", func(t *testing.T) { - agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: models.StateSnapshot{}}, - AgentServer: mockAgent, - ControlLevel: ControlLevelControlled, - }) - result, err := exec.executeFileWrite(ctx, "/config/test.json", "test", "tower", "mycontainer", map[string]interface{}{}) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "APPROVAL_REQUIRED") - assert.Contains(t, result.Content[0].Text, "container: mycontainer") - }) -} - -func TestExecuteFileAppendDocker(t *testing.T) { - ctx := context.Background() - - t.Run("AppendToDockerContainer", func(t *testing.T) { - content := "\nnew line" - encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) - expectedAppendCmd := fmt.Sprintf( - "docker exec %s sh -c %s", - shellEscape("logcontainer"), - shellEscape(fmt.Sprintf("echo %s | base64 -d >> %s", shellEscape(encodedContent), shellEscape("/var/log/app.log"))), - ) - expected := sha256.Sum256([]byte(content)) - expectedHex := hex.EncodeToString(expected[:]) - - agents := []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "tower"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == expectedAppendCmd - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "", - }, nil) - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return strings.Contains(cmd.Command, "docker exec") && - strings.Contains(cmd.Command, "logcontainer") && - strings.Contains(cmd.Command, "tail -c") && - (strings.Contains(cmd.Command, "sha256sum") || strings.Contains(cmd.Command, "shasum")) - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: expectedHex + " -\n", - }, nil) - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: models.StateSnapshot{}}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeFileAppend(ctx, "/var/log/app.log", content, "tower", "logcontainer", map[string]interface{}{}) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp["success"].(bool)) - assert.Equal(t, "append", resp["action"]) - assert.Equal(t, "logcontainer", resp["container"]) - if v, ok := resp["verification"].(map[string]interface{}); ok { - assert.True(t, v["ok"].(bool)) - } - mockAgent.AssertExpectations(t) - }) -} - -func TestExecuteFileWriteLXCVMTargets(t *testing.T) { - ctx := context.Background() - - t.Run("WriteToLXCRoutedCorrectly", func(t *testing.T) { - // Test that file writes to LXC are routed with correct target type/ID - // Agent handles sh -c wrapping, so tool sends raw pipeline command - content := "test content" - encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) - expected := sha256.Sum256([]byte(content)) - expectedHex := hex.EncodeToString(expected[:]) - - agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "pve-node"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - // Tool sends raw pipeline, agent wraps in sh -c for LXC - return cmd.TargetType == "container" && - cmd.TargetID == "141" && - strings.Contains(cmd.Command, encodedContent) && - strings.Contains(cmd.Command, "| base64 -d >") && - !strings.Contains(cmd.Command, "docker exec") - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "", - }, nil) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "container" && - cmd.TargetID == "141" && - strings.Contains(cmd.Command, "sha256sum") && - strings.Contains(cmd.Command, "/opt/test/config.yaml") && - !strings.Contains(cmd.Command, "docker exec") - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: expectedHex + " /opt/test/config.yaml\n", - }, nil) - - state := models.StateSnapshot{ - Containers: []models.Container{ - {VMID: 141, Name: "homepage-docker", Node: "pve-node"}, - }, - } - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeFileWrite(ctx, "/opt/test/config.yaml", content, "homepage-docker", "", map[string]interface{}{}) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp["success"].(bool)) - assert.Equal(t, "write", resp["action"]) - assert.Nil(t, resp["container"]) // No Docker container - if v, ok := resp["verification"].(map[string]interface{}); ok { - assert.True(t, v["ok"].(bool)) - } - mockAgent.AssertExpectations(t) - }) - - t.Run("WriteToVMRoutedCorrectly", func(t *testing.T) { - // Test that file writes to VMs are routed with correct target type/ID - content := "vm config" - encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) - expected := sha256.Sum256([]byte(content)) - expectedHex := hex.EncodeToString(expected[:]) - - agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "pve-node"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "vm" && - cmd.TargetID == "100" && - strings.Contains(cmd.Command, encodedContent) - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "", - }, nil) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "vm" && - cmd.TargetID == "100" && - strings.Contains(cmd.Command, "sha256sum") && - strings.Contains(cmd.Command, "/etc/test.conf") && - !strings.Contains(cmd.Command, "docker exec") - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: expectedHex + " /etc/test.conf\n", - }, nil) - - state := models.StateSnapshot{ - VMs: []models.VM{ - {VMID: 100, Name: "test-vm", Node: "pve-node"}, - }, - } - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeFileWrite(ctx, "/etc/test.conf", content, "test-vm", "", map[string]interface{}{}) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp["success"].(bool)) - if v, ok := resp["verification"].(map[string]interface{}); ok { - assert.True(t, v["ok"].(bool)) - } - mockAgent.AssertExpectations(t) - }) - - t.Run("WriteToDirectHost", func(t *testing.T) { - // Direct host writes use raw pipeline command - content := "host config" - encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) - expected := sha256.Sum256([]byte(content)) - expectedHex := hex.EncodeToString(expected[:]) - - agents := []agentexec.ConnectedAgent{{AgentID: "agent", Hostname: "tower"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "agent" && - strings.Contains(cmd.Command, encodedContent) && - strings.Contains(cmd.Command, "| base64 -d >") - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "", - }, nil) - mockAgent.On("ExecuteCommand", mock.Anything, "agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "agent" && - strings.Contains(cmd.Command, "sha256sum") && - strings.Contains(cmd.Command, "/tmp/test.txt") && - !strings.Contains(cmd.Command, "docker exec") - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: expectedHex + " /tmp/test.txt\n", - }, nil) - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: models.StateSnapshot{}}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeFileWrite(ctx, "/tmp/test.txt", content, "tower", "", map[string]interface{}{}) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp["success"].(bool)) - if v, ok := resp["verification"].(map[string]interface{}); ok { - assert.True(t, v["ok"].(bool)) - } - mockAgent.AssertExpectations(t) - }) - - t.Run("AppendToLXCRoutedCorrectly", func(t *testing.T) { - // Append operations to LXC are routed with correct target type/ID - content := "\nnew line" - encodedContent := base64.StdEncoding.EncodeToString([]byte(content)) - expected := sha256.Sum256([]byte(content)) - expectedHex := hex.EncodeToString(expected[:]) - - agents := []agentexec.ConnectedAgent{{AgentID: "proxmox-agent", Hostname: "pve-node"}} - mockAgent := &mockAgentServer{} - mockAgent.On("GetConnectedAgents").Return(agents) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "container" && - cmd.TargetID == "141" && - strings.Contains(cmd.Command, encodedContent) && - strings.Contains(cmd.Command, ">>") // append uses >> - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "", - }, nil) - mockAgent.On("ExecuteCommand", mock.Anything, "proxmox-agent", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.TargetType == "container" && - cmd.TargetID == "141" && - strings.Contains(cmd.Command, "tail -c") && - strings.Contains(cmd.Command, "/var/log/app.log") && - (strings.Contains(cmd.Command, "sha256sum") || strings.Contains(cmd.Command, "shasum")) && - !strings.Contains(cmd.Command, "docker exec") - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: expectedHex + " -\n", - }, nil) - - state := models.StateSnapshot{ - Containers: []models.Container{ - {VMID: 141, Name: "homepage-docker", Node: "pve-node"}, - }, - } - - exec := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - AgentServer: mockAgent, - ControlLevel: ControlLevelAutonomous, - ActionAuditStore: unifiedresources.NewMemoryStore(), - }) - result, err := exec.executeFileAppend(ctx, "/var/log/app.log", content, "homepage-docker", "", map[string]interface{}{}) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.True(t, resp["success"].(bool)) - assert.Equal(t, "append", resp["action"]) - if v, ok := resp["verification"].(map[string]interface{}); ok { - assert.True(t, v["ok"].(bool)) - } - mockAgent.AssertExpectations(t) - }) -} - func TestExecuteFileEditDockerNestedRouting(t *testing.T) { ctx := context.Background() @@ -571,13 +194,11 @@ func TestExecuteFileEditDockerNestedRouting(t *testing.T) { }) } -// Missing access is a failed operation, independent of whether monitoring knows -// the target. Exercise the actual tool handlers so writes cannot report success. -func TestCommandToolsPreserveMonitoringWhenAccessUnavailable(t *testing.T) { +func TestReadToolsPreserveMonitoringWhenAccessUnavailable(t *testing.T) { t.Setenv("PULSE_STRICT_RESOLUTION", "false") for _, target := range []string{"known-vm", "unknown-target"} { for _, noServer := range []bool{false, true} { - for _, operation := range []string{"file read", "file write", "file append", "read exec", "legacy command"} { + for _, operation := range []string{"file read", "read exec"} { t.Run(fmt.Sprintf("%s/noServer=%t/%s", target, noServer, operation), func(t *testing.T) { server := &mockAgentServer{} config := ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{ @@ -593,14 +214,8 @@ func TestCommandToolsPreserveMonitoringWhenAccessUnavailable(t *testing.T) { switch operation { case "file read": result, err = executor.executeFileRead(ctx, "/proc/meminfo", target, "") - case "file write": - result, err = executor.executeFileWrite(ctx, "/tmp/access-proof", "value", target, "", nil) - case "file append": - result, err = executor.executeFileAppend(ctx, "/tmp/access-proof", "value", target, "", nil) case "read exec": result, err = executor.executeReadExec(ctx, map[string]interface{}{"command": "uptime", "target_host": target}) - case "legacy command": - result, err = executor.executeRunCommand(ctx, map[string]interface{}{"command": "uptime", "target_host": target}) } require.NoError(t, err) require.True(t, result.IsError) diff --git a/internal/ai/tools/infrastructure_helpers_test.go b/internal/ai/tools/infrastructure_helpers_test.go deleted file mode 100644 index 2f876fc994..0000000000 --- a/internal/ai/tools/infrastructure_helpers_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package tools - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFormatDockerUpdateApprovalNeeded(t *testing.T) { - value := formatDockerUpdateApprovalNeeded("web", "node-1", "id-1") - require.True(t, strings.HasPrefix(value, "APPROVAL_REQUIRED: ")) - - raw := strings.TrimPrefix(value, "APPROVAL_REQUIRED: ") - var payload map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(raw), &payload)) - - assert.Equal(t, "approval_required", payload["type"]) - assert.Equal(t, "id-1", payload["approval_id"]) - assert.Equal(t, "web", payload["container_name"]) - assert.Equal(t, "node-1", payload["docker_host"]) - assert.Equal(t, "update", payload["action"]) -} - -func TestTrimLeadingSlash(t *testing.T) { - assert.Equal(t, "name", trimLeadingSlash("/name")) - assert.Equal(t, "name", trimLeadingSlash("name")) - assert.Equal(t, "", trimLeadingSlash("")) -} diff --git a/internal/ai/tools/invocation_policy_test.go b/internal/ai/tools/invocation_policy_test.go index bc02e09d91..004aaaaacd 100644 --- a/internal/ai/tools/invocation_policy_test.go +++ b/internal/ai/tools/invocation_policy_test.go @@ -91,7 +91,6 @@ func TestAutonomousDenyMutationsCannotMutateDocker(t *testing.T) { updates := &mockUpdatesProvider{} exec := NewPulseToolExecutor(ExecutorConfig{UpdatesProvider: updates}) exec.SetControlLevel(ControlLevelAutonomous) - exec.SetAutonomousMode(true) exec.SetDenyInfrastructureMutations(true) text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{ @@ -125,14 +124,13 @@ func TestFabricatedEnumValuesFailClosedAtRuntime(t *testing.T) { func TestRetiredMutationProfileMatrixDeniesBeforeHandlers(t *testing.T) { profiles := []struct { - name string - level ControlLevel - profile ExecutionProfile - autonomous bool + name string + level ControlLevel + profile ExecutionProfile }{ {name: "interactive-read-only", level: ControlLevelReadOnly, profile: ProfileInteractiveAssistant}, {name: "interactive-controlled", level: ControlLevelControlled, profile: ProfileInteractiveAssistant}, - {name: "interactive-autonomous", level: ControlLevelAutonomous, profile: ProfileInteractiveAssistant, autonomous: true}, + {name: "interactive-autonomous", level: ControlLevelAutonomous, profile: ProfileInteractiveAssistant}, {name: "patrol-detection", level: ControlLevelAutonomous, profile: ProfilePatrolDetection}, {name: "patrol-investigation", level: ControlLevelAutonomous, profile: ProfilePatrolInvestigation}, } @@ -152,7 +150,6 @@ func TestRetiredMutationProfileMatrixDeniesBeforeHandlers(t *testing.T) { t.Run(posture.name, func(t *testing.T) { exec := newInvocationPolicyExecutor(t) exec.SetControlLevel(posture.level) - exec.SetAutonomousMode(posture.autonomous) exec.ApplyExecutionProfile(posture.profile) for _, call := range calls { text := executeBlockedText(t, exec, call.tool, call.args) @@ -423,14 +420,9 @@ func TestRegistrationAuthorityIsSplitAndAppendOnly(t *testing.T) { func TestPatrolDetectionProfileEnforcesAllowlistedPulseState(t *testing.T) { exec := newInvocationPolicyExecutor(t) exec.SetControlLevel(ControlLevelAutonomous) - exec.SetAutonomousMode(true) exec.ApplyExecutionProfile(ProfilePatrolDetection) - // Applying a Patrol profile clears inherited autonomy and denies - // infrastructure outright. - if exec.isAutonomous { - t.Fatal("detection profile must clear inherited autonomous mode") - } + // Applying a Patrol profile denies infrastructure outright. text := executeBlockedText(t, exec, "pulse_docker", map[string]interface{}{ "action": "update", "container": "nginx", "host": "tower", }) @@ -475,13 +467,8 @@ func TestPatrolDetectionProfileEnforcesAllowlistedPulseState(t *testing.T) { func TestPatrolInvestigationProfileIsStructurallyReadOnly(t *testing.T) { exec := newInvocationPolicyExecutor(t) exec.SetControlLevel(ControlLevelAutonomous) - exec.SetAutonomousMode(true) exec.ApplyExecutionProfile(ProfilePatrolInvestigation) - if exec.isAutonomous { - t.Fatal("investigation profile must clear inherited autonomous mode") - } - // No infrastructure mutations. text := executeBlockedText(t, exec, "pulse_kubernetes", map[string]interface{}{"type": "scale"}) assert.Contains(t, text, "retired and denied") diff --git a/internal/ai/tools/kubernetes_control_test.go b/internal/ai/tools/kubernetes_control_test.go index e8442bba06..d7cfbd2a59 100644 --- a/internal/ai/tools/kubernetes_control_test.go +++ b/internal/ai/tools/kubernetes_control_test.go @@ -2,13 +2,9 @@ package tools import ( "context" - "encoding/json" "testing" - "time" - "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/stretchr/testify/assert" @@ -159,559 +155,6 @@ func TestFindAgentForKubernetesCluster(t *testing.T) { }) } -func TestExecuteKubernetesScale(t *testing.T) { - ctx := context.Background() - - t.Run("MissingCluster", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{}) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "cluster is required") - }) - - t.Run("MissingDeployment", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "test", - }) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "deployment is required") - }) - - t.Run("MissingReplicas", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "test", - "deployment": "nginx", - }) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "replicas is required") - }) - - t.Run("InvalidNamespace", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "test", - "deployment": "nginx", - "replicas": 3, - "namespace": "Invalid_NS", - }) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "invalid namespace") - }) - - t.Run("ReadOnlyMode", func(t *testing.T) { - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.ControlLevel = ControlLevelReadOnly - }) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "deployment": "nginx", - "replicas": 3, - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "not available in read-only mode") - }) - - t.Run("ControlledRequiresApproval", func(t *testing.T) { - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1", DisplayName: "Cluster One"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.ControlLevel = ControlLevelControlled - }) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "deployment": "nginx", - "replicas": 3, - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "APPROVAL_REQUIRED") - assert.Contains(t, result.Content[0].Text, "scale") - }) - - t.Run("ExecuteSuccessVerified", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' scale deployment 'nginx' --replicas=3" && - cmd.TargetType == "agent" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "deployment.apps/nginx scaled", - }, nil) - // Read-after-write: the scale handler re-reads the deployment state. - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' get deployment 'nginx' -o 'jsonpath={.spec.replicas} {.status.readyReplicas}'" && - cmd.TargetType == "agent" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "3 3", - }, nil) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "deployment": "nginx", - "replicas": 3, - }) - require.NoError(t, err) - require.False(t, result.IsError) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - assert.Equal(t, true, resp["success"]) - assert.Equal(t, "nginx", resp["deployment"]) - verification, ok := resp["verification"].(map[string]interface{}) - require.True(t, ok, "expected verification in response") - assert.Equal(t, true, verification["confirmed"]) - assert.Equal(t, "kubectl_get", verification["method"]) - observed, ok := verification["observed"].(map[string]interface{}) - require.True(t, ok, "expected observed state in verification") - assert.Equal(t, float64(3), observed["replicas"]) - assert.Equal(t, float64(3), observed["ready_replicas"]) - mockAgent.AssertExpectations(t) - }) - - t.Run("ExecuteSuccessVerificationMismatch", func(t *testing.T) { - origSleep := kubernetesVerifySleepFn - kubernetesVerifySleepFn = func(context.Context, time.Duration) error { return nil } - t.Cleanup(func() { kubernetesVerifySleepFn = origSleep }) - - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' scale deployment 'nginx' --replicas=3" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "deployment.apps/nginx scaled", - }, nil).Once() - // The re-read keeps observing a different desired replica count. - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' get deployment 'nginx' -o 'jsonpath={.spec.replicas} {.status.readyReplicas}'" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "1 1", - }, nil).Times(kubernetesVerifyMaxAttempts) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "deployment": "nginx", - "replicas": 3, - }) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - verification, ok := resp["verification"].(map[string]interface{}) - require.True(t, ok, "expected verification in response") - assert.Equal(t, false, verification["confirmed"]) - observed, ok := verification["observed"].(map[string]interface{}) - require.True(t, ok, "expected observed state in verification") - assert.Equal(t, float64(1), observed["replicas"]) - mockAgent.AssertExpectations(t) - }) - - t.Run("ExecuteSuccessVerificationReadError", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' scale deployment 'nginx' --replicas=3" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "deployment.apps/nginx scaled", - }, nil).Once() - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' get deployment 'nginx' -o 'jsonpath={.spec.replicas} {.status.readyReplicas}'" - })).Return((*agentexec.CommandResultPayload)(nil), assert.AnError).Once() - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "deployment": "nginx", - "replicas": 3, - }) - require.NoError(t, err) - - var resp map[string]interface{} - require.NoError(t, json.Unmarshal([]byte(result.Content[0].Text), &resp)) - // The scale itself succeeded; verification must report unconfirmed, not error out. - assert.Equal(t, true, resp["success"]) - verification, ok := resp["verification"].(map[string]interface{}) - require.True(t, ok, "expected verification in response") - assert.Equal(t, false, verification["confirmed"]) - assert.NotEmpty(t, verification["note"]) - mockAgent.AssertExpectations(t) - }) - - t.Run("ExecuteFailureNoVerification", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' scale deployment 'nginx' --replicas=3" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 1, - Stderr: "Error from server (NotFound): deployments.apps \"nginx\" not found", - }, nil).Once() - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesScale(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "deployment": "nginx", - "replicas": 3, - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "kubectl command failed") - mockAgent.AssertExpectations(t) - }) -} - -func TestExecuteKubernetesRestart(t *testing.T) { - ctx := context.Background() - - t.Run("MissingDeployment", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeKubernetesRestart(ctx, map[string]interface{}{ - "cluster": "test", - }) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "deployment is required") - }) - - t.Run("ExecuteSuccess", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' rollout restart deployment/'nginx'" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "deployment.apps/nginx restarted", - }, nil) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesRestart(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "deployment": "nginx", - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "Successfully initiated rollout restart") - mockAgent.AssertExpectations(t) - }) -} - -func TestExecuteKubernetesDeletePod(t *testing.T) { - ctx := context.Background() - - t.Run("MissingPod", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeKubernetesDeletePod(ctx, map[string]interface{}{ - "cluster": "test", - }) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "pod is required") - }) - - t.Run("ExecuteSuccess", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' delete pod 'nginx-abc123'" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "pod \"nginx-abc123\" deleted", - }, nil) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesDeletePod(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "pod": "nginx-abc123", - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "Successfully deleted pod") - mockAgent.AssertExpectations(t) - }) -} - -func TestExecuteKubernetesExec(t *testing.T) { - ctx := context.Background() - - t.Run("MissingCommand", func(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{StateProvider: &mockStateProvider{state: models.StateSnapshot{}}}) - result, err := exec.executeKubernetesExec(ctx, map[string]interface{}{ - "cluster": "test", - "pod": "nginx", - }) - require.NoError(t, err) - assert.True(t, result.IsError) - assert.Contains(t, result.Content[0].Text, "command is required") - }) - - t.Run("ExecuteWithoutContainer", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' exec 'nginx-pod' -- sh -c 'cat /etc/nginx/nginx.conf'" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "server { listen 80; }", - }, nil) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesExec(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "pod": "nginx-pod", - "command": "cat /etc/nginx/nginx.conf", - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "Command executed") - assert.Contains(t, result.Content[0].Text, "server { listen 80; }") - mockAgent.AssertExpectations(t) - }) - - t.Run("ExecuteWithContainer", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'kube-system' exec 'coredns-pod' -c 'coredns' -- sh -c 'cat /etc/coredns/Corefile'" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: ".:53 { forward . /etc/resolv.conf }", - }, nil) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesExec(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "namespace": "kube-system", - "pod": "coredns-pod", - "container": "coredns", - "command": "cat /etc/coredns/Corefile", - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "Command executed") - mockAgent.AssertExpectations(t) - }) - - t.Run("ExecEscapesMetacharacters", func(t *testing.T) { - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == "kubectl -n 'default' exec 'nginx-pod' -- sh -c 'cat /etc/nginx/nginx.conf; id'" - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "ok", - }, nil) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelAutonomous - }) - result, err := exec.executeKubernetesExec(ctx, map[string]interface{}{ - "cluster": "cluster-1", - "pod": "nginx-pod", - "command": "cat /etc/nginx/nginx.conf; id", - }) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "Command executed") - mockAgent.AssertExpectations(t) - }) - - t.Run("MismatchedApprovedIDDoesNotBypass", func(t *testing.T) { - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - - req := &approval.ApprovalRequest{ - ID: "approval-1", - Command: "kubectl -n default exec nginx-pod -- whoami", - TargetType: "kubernetes", - TargetID: "nginx-pod", - } - require.NoError(t, store.CreateApproval(req)) - _, err = store.Approve("approval-1", "tester") - require.NoError(t, err) - - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelControlled - }) - - result, err := exec.executeKubernetesExec(ctx, agentcapabilities.WithApprovalArgument(map[string]interface{}{ - "cluster": "cluster-1", - "pod": "nginx-pod", - "command": "cat /etc/nginx/nginx.conf", - }, "approval-1")) - require.NoError(t, err) - assert.Contains(t, result.Content[0].Text, "APPROVAL_REQUIRED") - mockAgent.AssertNotCalled(t, "ExecuteCommand", mock.Anything, mock.Anything, mock.Anything) - }) - - t.Run("MatchingApprovedIDCarriesAuthorizationToAgentBoundary", func(t *testing.T) { - store, err := approval.NewStore(approval.StoreConfig{ - DataDir: t.TempDir(), - DisablePersistence: true, - }) - require.NoError(t, err) - approval.SetStore(store) - defer approval.SetStore(nil) - - kubeCmd := buildKubectlExecCommand("default", "nginx-pod", "", "cat /etc/nginx/nginx.conf") - req := &approval.ApprovalRequest{ - ID: "approval-2", - Command: kubeCmd, - TargetType: "kubernetes", - TargetID: "c1:default:pod:nginx-pod", - } - require.NoError(t, store.CreateApproval(req)) - _, err = store.Approve("approval-2", "tester") - require.NoError(t, err) - - mockAgent := &mockAgentServer{ - agents: []agentexec.ConnectedAgent{{AgentID: "agent-1", Hostname: "k8s-host"}}, - } - mockAgent.On("ExecuteCommand", mock.Anything, "agent-1", mock.MatchedBy(func(cmd agentexec.ExecuteCommandPayload) bool { - return cmd.Command == kubeCmd - })).Return(&agentexec.CommandResultPayload{ - ExitCode: 0, - Stdout: "ok", - }, nil) - - state := models.StateSnapshot{ - KubernetesClusters: []models.KubernetesCluster{ - {ID: "c1", Name: "cluster-1", AgentID: "agent-1"}, - }, - } - exec := newConfiguredKubernetesExecutor(state, func(cfg *ExecutorConfig) { - cfg.AgentServer = mockAgent - cfg.ControlLevel = ControlLevelControlled - }) - - 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.NotContains(t, result.Content[0].Text, "APPROVAL_REQUIRED") - mockAgent.AssertExpectations(t) - - // 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.NotContains(t, result.Content[0].Text, "APPROVAL_REQUIRED") - }) -} - func TestExecuteKubernetesLogs(t *testing.T) { ctx := context.Background() @@ -815,13 +258,3 @@ func TestExecuteKubernetesLogs(t *testing.T) { mockAgent.AssertExpectations(t) }) } - -func TestFormatKubernetesApprovalNeeded(t *testing.T) { - result := formatKubernetesApprovalNeeded("scale", "nginx", "default", "production", "kubectl scale...", "approval-123") - assert.Contains(t, result, "APPROVAL_REQUIRED") - assert.Contains(t, result, "scale") - assert.Contains(t, result, "nginx") - assert.Contains(t, result, "default") - assert.Contains(t, result, "production") - assert.Contains(t, result, "approval-123") -} diff --git a/internal/ai/tools/purehelpers_branchcov0722am_test.go b/internal/ai/tools/purehelpers_branchcov0722am_test.go index 73cbecae80..4236a76d61 100644 --- a/internal/ai/tools/purehelpers_branchcov0722am_test.go +++ b/internal/ai/tools/purehelpers_branchcov0722am_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" - "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/stretchr/testify/assert" ) @@ -20,45 +19,6 @@ import ( // action_audit.go as approval.RequesterPulseAssistant ("pulse_assistant"); the // default-arm cases assert that literal so a constant change would surface here. -// TestBranchcov0722R2DockerRuntimeCommand covers every branch arm of -// dockerRuntimeCommand: the nil-host guard, both recognised switch cases -// ("podman", "docker"), the surrounding-whitespace + mixed-case normalisation -// path (TrimSpace + ToLower), an unrecognised runtime falling through to the -// default, and an empty runtime falling through to the default. Every arm is -// asserted against the exact command string it returns. -func TestBranchcov0722R2DockerRuntimeCommand(t *testing.T) { - tests := []struct { - name string - host *models.DockerHost - want string - }{ - // --- nil-host guard: default command returned. --- - {name: "nil_host_returns_docker", host: nil, want: "docker"}, - - // --- recognised switch arms (exact, already-canonical input). --- - {name: "podman_runtime_returns_podman", host: &models.DockerHost{Runtime: "podman"}, want: "podman"}, - {name: "docker_runtime_returns_docker", host: &models.DockerHost{Runtime: "docker"}, want: "docker"}, - - // --- normalisation: TrimSpace + ToLower route mixed input to a case. --- - {name: "podman_with_whitespace_and_mixed_case", host: &models.DockerHost{Runtime: " PoDmAn "}, want: "podman"}, - {name: "docker_with_whitespace_and_upper_case", host: &models.DockerHost{Runtime: "\tDOCKER\n"}, want: "docker"}, - - // --- default arm: unrecognised runtime falls through to "docker". --- - {name: "unrecognised_runtime_falls_through", host: &models.DockerHost{Runtime: "containerd"}, want: "docker"}, - - // --- default arm: empty runtime falls through to "docker". --- - {name: "empty_runtime_falls_through", host: &models.DockerHost{Runtime: ""}, want: "docker"}, - {name: "whitespace_only_runtime_falls_through", host: &models.DockerHost{Runtime: " "}, want: "docker"}, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got := dockerRuntimeCommand(tc.host) - assert.Equal(t, tc.want, got, "dockerRuntimeCommand mismatch") - }) - } -} - // TestBranchcov0722R2ApprovalDecisionActor covers every branch arm of // approvalDecisionActor. The function implements a three-step precedence chain: // 1. req.DecidedBy (trimmed, if req != nil and non-empty) wins; diff --git a/internal/ai/tools/shared_test.go b/internal/ai/tools/shared_test.go index 1f4870e130..b8b170cfc3 100644 --- a/internal/ai/tools/shared_test.go +++ b/internal/ai/tools/shared_test.go @@ -28,15 +28,6 @@ func (m *mockStateProvider) ReadSnapshot() models.StateSnapshot { return args.Get(0).(models.StateSnapshot) } -type mockCommandPolicy struct { - mock.Mock -} - -func (m *mockCommandPolicy) Evaluate(command string) agentexec.PolicyDecision { - args := m.Called(command) - return args.Get(0).(agentexec.PolicyDecision) -} - type mockAgentServer struct { mock.Mock agents []agentexec.ConnectedAgent diff --git a/internal/ai/tools/strict_resolution_test.go b/internal/ai/tools/strict_resolution_test.go index 10c97afa61..73d22c80d7 100644 --- a/internal/ai/tools/strict_resolution_test.go +++ b/internal/ai/tools/strict_resolution_test.go @@ -1033,135 +1033,6 @@ func TestRoutingValidation_ExplicitGetShouldMarkAccess(t *testing.T) { t.Log("✓ Explicit get marks access - routing validation will block host ops") } -// TestWriteExecutionContext_BlocksNodeFallbackForLXC verifies that writes to an LXC -// are blocked when the routing would execute on the Proxmox node instead of inside the LXC. -// -// This catches the scenario where: -// 1. target_host="homepage-docker" (an LXC on pve-node) -// 2. An agent registered as "homepage-docker" matches directly -// 3. Command would run on pve-node's filesystem, not inside the LXC -func TestWriteExecutionContext_BlocksNodeFallbackForLXC(t *testing.T) { - // Create executor with state that knows homepage-docker is an LXC - state := models.StateSnapshot{ - Nodes: []models.Node{{Name: "pve-node"}}, - Containers: []models.Container{{ - VMID: 141, - Name: "homepage-docker", - Node: "pve-node", - Status: "running", - }}, - } - - executor := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - }) - - // Simulate: routing resolved as "direct" (agent hostname match) - // This means the command would run directly on the agent, which is on the node - routing := CommandRoutingResult{ - AgentID: "agent-pve-node", - TargetType: "agent", // Direct agent match -> "agent" type - TargetID: "", // No VMID - AgentHostname: "pve-node", // Agent is on pve-node - ResolvedKind: "agent", // Resolved as agent (direct match) - ResolvedNode: "", // No node info (direct match doesn't resolve) - Transport: "direct", // Direct execution - } - - // validateWriteExecutionContext should block this - err := executor.validateWriteExecutionContext("homepage-docker", routing) - if err == nil { - t.Fatal("Expected EXECUTION_CONTEXT_UNAVAILABLE error for LXC with direct transport") - } - - // Verify error structure - response := err.ToToolResponse() - if response.OK { - t.Error("Expected OK=false") - } - if response.Error.Code != "EXECUTION_CONTEXT_UNAVAILABLE" { - t.Errorf("Expected EXECUTION_CONTEXT_UNAVAILABLE, got %s", response.Error.Code) - } - if response.Error.Details["resolved_kind"] != "system-container" { - t.Errorf("Expected resolved_kind=system-container, got %v", response.Error.Details["resolved_kind"]) - } - if response.Error.Details["auto_recoverable"] != nil { - t.Error("Expected auto_recoverable to be absent") - } - if response.Error.Details["policy_boundary"] == nil { - t.Error("Expected policy_boundary") - } - - t.Log("✓ Write to LXC blocked when routing would execute on node (no pct exec)") -} - -// TestWriteExecutionContext_AllowsProperLXCRouting verifies that writes to an LXC -// are allowed when the routing correctly uses pct_exec. -func TestWriteExecutionContext_AllowsProperLXCRouting(t *testing.T) { - state := models.StateSnapshot{ - Nodes: []models.Node{{Name: "pve-node"}}, - Containers: []models.Container{{ - VMID: 141, - Name: "homepage-docker", - Node: "pve-node", - Status: "running", - }}, - } - - executor := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - }) - - // Simulate: routing correctly resolved as LXC with pct_exec - routing := CommandRoutingResult{ - AgentID: "agent-pve-node", - TargetType: "container", - TargetID: "141", - AgentHostname: "pve-node", - ResolvedKind: "system-container", - ResolvedNode: "pve-node", - Transport: "pct_exec", - } - - // validateWriteExecutionContext should allow this - err := executor.validateWriteExecutionContext("homepage-docker", routing) - if err != nil { - t.Fatalf("Expected no error for proper container routing, got: %s", err.Message) - } - - t.Log("✓ Write to system container allowed when routing uses pct_exec") -} - -// TestWriteExecutionContext_AllowsHostWrites verifies that writes directly to a host -// (not a child resource) are allowed normally. -func TestWriteExecutionContext_AllowsHostWrites(t *testing.T) { - state := models.StateSnapshot{ - Nodes: []models.Node{{Name: "pve-node"}}, - Hosts: []models.Host{{Hostname: "pve-node"}}, - } - - executor := NewPulseToolExecutor(ExecutorConfig{ - StateProvider: &mockStateProvider{state: state}, - }) - - // Simulate: writing to pve-node directly (it's a host, not LXC/VM) - routing := CommandRoutingResult{ - AgentID: "agent-pve-node", - TargetType: "agent", - AgentHostname: "pve-node", - ResolvedKind: "node", - ResolvedNode: "pve-node", - Transport: "direct", - } - - err := executor.validateWriteExecutionContext("pve-node", routing) - if err != nil { - t.Fatalf("Expected no error for host write, got: %s", err.Message) - } - - t.Log("✓ Write to Proxmox host allowed (target is the node itself)") -} - // TestCommandRoutingResult_ProvenanceFields verifies the execution provenance // structure is populated correctly for debugging. func TestCommandRoutingResult_ProvenanceFields(t *testing.T) { diff --git a/internal/ai/tools/tools_control.go b/internal/ai/tools/tools_control.go index af349f326b..d16abbae78 100644 --- a/internal/ai/tools/tools_control.go +++ b/internal/ai/tools/tools_control.go @@ -2,21 +2,13 @@ package tools import ( "context" - "errors" "fmt" "net/url" - "strconv" "strings" - "time" - "github.com/google/uuid" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" - "github.com/rcourtman/pulse-go-rewrite/internal/ai/safety" - "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" - "github.com/rs/zerolog/log" ) // registerControlTools registers the pulse_control tool @@ -134,146 +126,6 @@ func (e *PulseToolExecutor) executeControlResource(ctx context.Context, args map }), nil } -func (e *PulseToolExecutor) executeNativeAppContainerControl(ctx context.Context, resource ResolvedResourceInfo, action string, approvalID string) (CallToolResult, error) { - if resource == nil { - return NewErrorResult(fmt.Errorf("resolved resource is nil")), nil - } - if e.appContainerActionProvider == nil { - return NewErrorResult(fmt.Errorf("native app-container control provider is not available")), nil - } - - validActions := map[string]bool{"start": true, "stop": true, "restart": true} - if !validActions[action] { - return NewErrorResult(fmt.Errorf("invalid app-container action: %s. Use start, stop, or restart", action)), nil - } - if e.controlLevel == ControlLevelReadOnly { - return NewTextResult("Resource control actions are not available in read-only mode."), nil - } - - resourceName := resolvedResourceDisplayName(resource) - resourceHost := strings.TrimSpace(resource.GetTargetHost()) - command := fmt.Sprintf("truenas app %s %s", action, resourceName) - if resourceHost != "" { - command = fmt.Sprintf("%s on %s", command, resourceHost) - } - approvalTargetType := "app-container" - approvalTargetID := strings.TrimSpace(resource.GetResourceID()) - if approvalTargetID == "" { - approvalTargetID = strings.TrimSpace(resource.GetProviderUID()) - } - - preApproved := consumeApprovalWithValidation(agentcapabilities.WithApprovalArgument(nil, approvalID), e.orgID, command, approvalTargetType, approvalTargetID) - decision := agentexec.PolicyAllow - if e.policy != nil { - decision = e.policy.Evaluate(command) - if decision == agentexec.PolicyBlock { - return NewTextResult(formatPolicyBlocked(command, "This action is blocked by security policy")), nil - } - } - requiresApproval := !e.isAutonomous && (e.controlLevel == ControlLevelControlled || decision == agentexec.PolicyRequireApproval) - - if !preApproved && decision == agentexec.PolicyRequireApproval && !e.isAutonomous { - approvalID = e.createApprovalRecord(command, approvalTargetType, approvalTargetID, resourceName, fmt.Sprintf("%s app-container %s", action, resourceName)) - return NewTextResult(formatAppContainerApprovalNeeded(resourceName, resourceHost, action, command, approvalID)), nil - } - if !preApproved && e.controlLevel == ControlLevelControlled { - approvalID = e.createApprovalRecord(command, approvalTargetType, approvalTargetID, resourceName, fmt.Sprintf("%s app-container %s", action, resourceName)) - return NewTextResult(formatAppContainerApprovalNeeded(resourceName, resourceHost, action, command, approvalID)), nil - } - - var actionResult *AppContainerActionResult - executionResult, err := e.executeNativeActionWithAudit( - ctx, - "pulse_control", - strings.TrimSpace(resource.GetResourceID()), - approvalID, - requiresApproval, - map[string]any{ - "action": action, - "kind": resource.GetKind(), - "providerUid": resource.GetProviderUID(), - "host": resourceHost, - "platform": "truenas", - "approvalId": approvalID, - "requestedBy": "pulse_control", - }, - "pulse_control", - fmt.Sprintf("%s app-container %s", action, resourceName), - func(ctx context.Context) (*unifiedresources.ExecutionResult, error) { - result, err := e.appContainerActionProvider.ExecuteAction(ctx, AppContainerActionRequest{ - OrgID: e.orgID, - ResourceID: strings.TrimSpace(resource.GetResourceID()), - ProviderUID: strings.TrimSpace(resource.GetProviderUID()), - Name: resourceName, - Host: resourceHost, - Platform: "truenas", - Action: action, - }) - if err != nil { - return nil, err - } - actionResult = result - return &unifiedresources.ExecutionResult{ - Success: true, - Output: strings.TrimSpace(result.Output), - }, nil - }, - ) - if err != nil { - return NewErrorResult(err), nil - } - if actionResult == nil { - actionResult = &AppContainerActionResult{ - ResourceID: strings.TrimSpace(resource.GetResourceID()), - ProviderUID: strings.TrimSpace(resource.GetProviderUID()), - Name: resourceName, - Host: resourceHost, - Platform: "truenas", - Action: action, - Output: strings.TrimSpace(executionResult.Output), - } - } - if strings.TrimSpace(actionResult.Output) == "" { - actionResult.Output = strings.TrimSpace(executionResult.Output) - } - - response := map[string]interface{}{ - "success": executionResult.Success, - "type": "resource", - "resource_id": actionResult.ResourceID, - "resource": actionResult.Name, - "resource_type": resource.GetKind(), - "provider_uid": actionResult.ProviderUID, - "platform": actionResult.Platform, - "host": actionResult.Host, - "action": actionResult.Action, - "status": actionResult.Status, - "output": actionResult.Output, - "verification": map[string]interface{}{ - "ok": executionResult.Success, - "method": "provider_refresh", - "observed_status": actionResult.Status, - }, - } - return NewJSONResultWithIsError(response, !executionResult.Success), nil -} - -func resolvedResourceControlIdentity(resource ResolvedResourceInfo, fallback string) string { - if resource == nil { - return strings.TrimSpace(fallback) - } - if providerUID := strings.TrimSpace(resource.GetProviderUID()); providerUID != "" { - return providerUID - } - for _, alias := range resource.GetAliases() { - alias = strings.TrimSpace(alias) - if alias != "" { - return alias - } - } - return strings.TrimSpace(fallback) -} - func resolvedResourceDisplayName(resource ResolvedResourceInfo) string { if resource == nil { return "" @@ -290,401 +142,6 @@ func resolvedResourceDisplayName(resource ResolvedResourceInfo) string { return strings.TrimSpace(resource.GetResourceID()) } -func (e *PulseToolExecutor) executeRunCommand(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - 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 - } - - // Validate resource is in resolved context - // Uses command risk classification: read-only commands bypass strict mode - // With PULSE_STRICT_RESOLUTION=true, write commands are blocked on undiscovered resources - if targetHost != "" { - validation := e.validateResolvedResourceForExec(targetHost, command, true) - if validation.IsBlocked() { - // Hard validation failure - return consistent error envelope - return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil - } - if validation.ErrorMsg != "" { - // Soft validation - log warning but allow operation - log.Warn(). - Str("target", targetHost). - Str("command", command). - 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 - routingResult := e.validateRoutingContext(targetHost) - if routingResult.IsBlocked() { - return NewToolResponseResult(routingResult.RoutingError.ToToolResponse()), nil - } - } - - // Note: Control level read_only check is now centralized in registry.Execute() - - // Check security policy (skip block check - blocks cannot be pre-approved) - decision := agentexec.PolicyAllow - if e.policy != nil { - decision = e.policy.Evaluate(command) - if decision == agentexec.PolicyBlock { - return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil - } - } - - requiresApproval := !e.isAutonomous && (e.controlLevel == ControlLevelControlled || decision == agentexec.PolicyRequireApproval) - - if targetHost == "" && e.agentServer != nil { - agents := e.agentServer.GetConnectedAgents() - if len(agents) > 1 { - return NewTextResult(formatTargetHostRequired(agents)), nil - } - } - - // Resolve topology even when no command connection is available. - routing := e.resolveTargetForCommandFull(targetHost) - if routing.AgentID == "" { - return unavailableCommandConnection(targetHost, routing), nil - } - - approvalTargetType, approvalTargetID, approvalTargetName := approvalTargetForCommand(targetHost, routing) - - // Check if this is a pre-approved execution with command hash validation. - // Final single-use consumption is owned by agentexec immediately before - // grant signing and WebSocket dispatch. - preApproved := consumeApprovalWithValidation(args, e.orgID, command, approvalTargetType, approvalTargetID) - - 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 !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 - } - - log.Debug(). - Str("target_host", targetHost). - Str("agent_id", routing.AgentID). - Str("agent_host", routing.AgentHostname). - Str("resolved_kind", routing.ResolvedKind). - Str("resolved_node", routing.ResolvedNode). - Str("transport", routing.Transport). - Str("target_type", routing.TargetType). - Str("target_id", routing.TargetID). - Msg("[pulse_control] Routing command execution") - - result, err := e.executeCommandWithAudit( - ctx, - "pulse_control", - func() string { - if auditResourceID != "" { - return auditResourceID - } - if targetHost != "" { - return targetHost - } - return routing.AgentHostname - }(), - approvalID, - requiresApproval, - routing.AgentID, - agentexec.ExecuteCommandPayload{ - Command: command, - TargetType: routing.TargetType, - TargetID: routing.TargetID, - }, - "pulse_control", - fmt.Sprintf("run command %q on %s", command, func() string { - if strings.TrimSpace(targetHost) != "" { - return strings.TrimSpace(targetHost) - } - return strings.TrimSpace(routing.AgentHostname) - }()), - ) - if err != nil { - return NewErrorResult(err), nil - } - - output := result.Stdout - if result.Stderr != "" { - output += "\n" + result.Stderr - } - if redacted, n := safety.RedactSensitiveText(output); n > 0 { - output = redacted + fmt.Sprintf("\n\n[redacted %d sensitive value(s)]", n) - } - - success := result.ExitCode == 0 - response := map[string]interface{}{ - "success": success, - "type": "command", - "command": command, - "target_host": targetHost, - "exit_code": result.ExitCode, - "output": output, - "execution": buildExecutionProvenance(targetHost, routing), - "verification": map[string]interface{}{"ok": success, "method": "exit_code", "exit_code": result.ExitCode}, - } - return NewJSONResultWithIsError(response, !success), nil -} - -// approvalTargetForCommand derives stable approval binding fields from resolved routing. -// This ensures replay protection hashes match the actual execution target. -func approvalTargetForCommand(targetHost string, routing CommandRoutingResult) (targetType, targetID, targetName string) { - targetType = routing.TargetType - targetID = strings.TrimSpace(routing.TargetID) - - if targetType == "" { - targetType = "agent" - } - - if targetType == "agent" { - // Agent-level executions do not carry routing.TargetID in ExecuteCommand payload. - // Use agent ID to bind approvals to a specific connected agent. - targetID = strings.TrimSpace(routing.AgentID) - if targetID == "" { - targetID = strings.TrimSpace(targetHost) - } - } else if targetID == "" { - // Defensive fallback for unexpected missing IDs. - targetID = strings.TrimSpace(targetHost) - } - - targetName = strings.TrimSpace(targetHost) - if targetName == "" { - targetName = strings.TrimSpace(routing.AgentHostname) - } - if targetName == "" { - targetName = strings.TrimSpace(routing.AgentID) - } - - return targetType, targetID, targetName -} - -func (e *PulseToolExecutor) executeControlGuest(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - guestID, _ := args["guest_id"].(string) - action, _ := args["action"].(string) - force, _ := args["force"].(bool) - approvalID := agentcapabilities.ApprovalArgument(args) - - if guestID == "" { - return NewErrorResult(fmt.Errorf("guest_id is required")), nil - } - if action == "" { - return NewErrorResult(fmt.Errorf("action is required")), nil - } - - validActions := map[string]bool{"start": true, "stop": true, "shutdown": true, "restart": true, "delete": true} - if !validActions[action] { - return NewErrorResult(fmt.Errorf("invalid action: %s. Use start, stop, shutdown, restart, or delete", action)), nil - } - - // Validate resource is in resolved context - // With PULSE_STRICT_RESOLUTION=true, this blocks execution on undiscovered resources - validation := e.validateResolvedResource(guestID, action, true) - if validation.IsBlocked() { - // Hard validation failure - return consistent error envelope - return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil - } - if validation.ErrorMsg != "" { - if validation.Resource != nil { - return NewErrorResult(errors.New(validation.ErrorMsg)), nil - } - // Soft validation - log warning but allow operation - log.Warn(). - Str("guest_id", guestID). - Str("action", action). - Str("validation_error", validation.ErrorMsg). - Msg("[ControlGuest] Guest not in resolved context - may indicate model hallucination") - } - - // Note: Control level read_only check is now centralized in registry.Execute() - - guest, err := e.resolveGuest(guestID) - if err != nil { - return NewErrorResult(fmt.Errorf("could not find guest '%s': %v", guestID, err)), nil - } - - // Build the command - cmdTool := "pct" - if guest.Type == "vm" { - cmdTool = "qm" - } - - // For delete action, verify guest is stopped first - if action == "delete" && guest.Status != "stopped" { - return NewErrorResult(fmt.Errorf("cannot delete %s (VMID %d) - it is currently %s; stop it first, then try deleting again", guest.Name, guest.VMID, guest.Status)), nil - } - - var command string - switch action { - case "start": - command = fmt.Sprintf("%s start %d", cmdTool, guest.VMID) - case "stop": - command = fmt.Sprintf("%s stop %d", cmdTool, guest.VMID) - case "shutdown": - command = fmt.Sprintf("%s shutdown %d", cmdTool, guest.VMID) - case "restart": - command = fmt.Sprintf("%s reboot %d", cmdTool, guest.VMID) - case "delete": - // Delete uses 'destroy' subcommand with --purge to also remove associated storage - command = fmt.Sprintf("%s destroy %d --purge", cmdTool, guest.VMID) - } - - if force && action == "stop" { - command = fmt.Sprintf("%s stop %d --skiplock", cmdTool, guest.VMID) - } - - approvalTargetID := fmt.Sprintf("%s:%d", guest.Node, guest.VMID) - - // Check if this is a pre-approved execution (agentic loop re-executing after user approval). - // Use consumeApprovalWithValidation to enforce command-bound, single-use approvals. - preApproved := consumeApprovalWithValidation(args, e.orgID, command, guest.Type, approvalTargetID) - - // Check security policy (skip if pre-approved) - if !preApproved && e.policy != nil { - decision := e.policy.Evaluate(command) - if decision == agentexec.PolicyBlock { - return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil - } - if decision == agentexec.PolicyRequireApproval && !e.isAutonomous { - // Use guest.Node (the Proxmox host) as targetName so approval execution can find the correct agent - approvalID := e.createApprovalRecord(command, guest.Type, approvalTargetID, guest.Node, fmt.Sprintf("%s guest %s", action, guest.Name)) - return NewTextResult(formatControlApprovalNeeded(guest.Name, guest.VMID, action, command, approvalID)), nil - } - } - - // Check control level - this must be outside policy check since policy may be nil (skip if pre-approved) - if !preApproved && e.controlLevel == ControlLevelControlled { - // Use guest.Node (the Proxmox host) as targetName so approval execution can find the correct agent - approvalID := e.createApprovalRecord(command, guest.Type, approvalTargetID, guest.Node, fmt.Sprintf("%s guest %s", action, guest.Name)) - return NewTextResult(formatControlApprovalNeeded(guest.Name, guest.VMID, action, command, approvalID)), nil - } - - requiresApproval := !e.isAutonomous && e.controlLevel == ControlLevelControlled - - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } - - agentID := e.findAgentForNode(guest.Node) - if agentID == "" { - return NewErrorResult(fmt.Errorf("no agent available on node '%s'; install Pulse Unified Agent on the node to enable control", guest.Node)), nil - } - - result, err := e.executeCommandWithAudit( - ctx, - "pulse_control", - fmt.Sprintf("%s:%d", guest.Node, guest.VMID), - approvalID, - requiresApproval, - agentID, - agentexec.ExecuteCommandPayload{ - Command: command, - TargetType: "agent", - TargetID: "", - }, - "pulse_control", - fmt.Sprintf("%s guest %s", action, guest.Name), - ) - if err != nil { - return NewErrorResult(err), nil - } - - output := result.Stdout - if result.Stderr != "" { - output += "\n" + result.Stderr - } - - // Detect idempotent success: the guest is already in the desired state. - // Proxmox returns exit code 255 with "not running" for stop/shutdown on a stopped guest, - // or "already running" for start on a running guest. These aren't failures — the desired - // state is already achieved. - outputLower := strings.ToLower(output) - alreadyDone := false - switch action { - case "stop", "shutdown": - alreadyDone = strings.Contains(outputLower, "not running") - case "start": - alreadyDone = strings.Contains(outputLower, "already running") - } - if alreadyDone { - result.ExitCode = 0 - output = fmt.Sprintf("%s\n(idempotent: desired state already set)", output) - } - - if redacted, n := safety.RedactSensitiveText(output); n > 0 { - output = redacted + fmt.Sprintf("\n\n[redacted %d sensitive value(s)]", n) - } - - verify := e.verifyGuestAction(ctx, agentID, cmdTool, guest.VMID, action) - verify["ok"] = result.ExitCode == 0 - success := result.ExitCode == 0 - response := map[string]interface{}{ - "success": success, - "type": "guest", - "guest": guest.Name, - "guest_id": fmt.Sprintf("%d", guest.VMID), - "guest_type": guest.Type, - "node": guest.Node, - "action": action, - "command": command, - "exit_code": result.ExitCode, - "output": output, - "verification": verify, - } - return NewJSONResultWithIsError(response, !success), nil -} - -func (e *PulseToolExecutor) verifyGuestAction(ctx context.Context, agentID, cmdTool string, vmID int, action string) map[string]interface{} { - expect := "" - switch action { - case "start", "restart": - expect = "running" - case "stop", "shutdown": - expect = "stopped" - case "delete": - expect = "deleted" - } - - statusCmd := fmt.Sprintf("%s status %d", cmdTool, vmID) - res, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{ - Command: statusCmd, - TargetType: "agent", - }) - if err != nil { - return map[string]interface{}{"confirmed": false, "method": "status", "command": statusCmd, "note": err.Error()} - } - out := strings.TrimSpace(res.Stdout + "\n" + res.Stderr) - outLower := strings.ToLower(out) - - // Delete verification: status should fail with does-not-exist semantics. - if action == "delete" { - confirmed := res.ExitCode != 0 && (strings.Contains(outLower, "does not exist") || strings.Contains(outLower, "no such") || strings.Contains(outLower, "not found")) - return map[string]interface{}{"confirmed": confirmed, "method": "status", "command": statusCmd, "expected": expect, "raw": out} - } - - observed := "" - if strings.Contains(outLower, "status: running") { - observed = "running" - } else if strings.Contains(outLower, "status: stopped") { - observed = "stopped" - } - confirmed := res.ExitCode == 0 && observed != "" && observed == expect - return map[string]interface{}{"confirmed": confirmed, "method": "status", "command": statusCmd, "expected": expect, "observed": observed, "raw": out} -} - // Helper methods for control tools // CommandRoutingResult contains full routing information for command execution. @@ -892,22 +349,6 @@ func unavailableCommandConnection(target string, routing CommandRoutingResult) C }) } -// resolveTargetForCommand resolves a target_host to the correct agent and routing info. -// Uses the authoritative resolveResourceLocation function. -// Returns: agentID, targetType ("agent", "container", or "vm"), targetID (vmid for LXC/VM) -// -// CRITICAL ORDERING: Same as resolveTargetForCommandFull — topology first, agent fallback second. -func (e *PulseToolExecutor) resolveTargetForCommand(targetHost string) (agentID string, targetType string, targetID string) { - // Delegate to the full resolver and extract the triple - r := e.resolveTargetForCommandFull(targetHost) - return r.AgentID, r.TargetType, r.TargetID -} - -func (e *PulseToolExecutor) findAgentForCommand(runOnHost bool, targetHost string) string { - agentID, _, _ := e.resolveTargetForCommand(targetHost) - return agentID -} - func (e *PulseToolExecutor) readStateForControl() (unifiedresources.ReadState, error) { if rs := e.getReadState(); rs != nil { return rs, nil @@ -915,246 +356,6 @@ func (e *PulseToolExecutor) readStateForControl() (unifiedresources.ReadState, e return nil, fmt.Errorf("read state not available") } -func (e *PulseToolExecutor) resolveGuest(guestID string) (*GuestInfo, error) { - rs, err := e.readStateForControl() - if err != nil { - return nil, err - } - - vmID, convErr := strconv.Atoi(guestID) - for _, vm := range rs.VMs() { - if (convErr == nil && vm.VMID() == vmID) || vm.Name() == guestID || vm.ID() == guestID { - return &GuestInfo{ - VMID: vm.VMID(), - Name: vm.Name(), - Node: vm.Node(), - Type: "vm", - Technology: "qemu", - Status: string(vm.Status()), - Instance: vm.Instance(), - }, nil - } - } - - for _, ct := range rs.Containers() { - if (convErr == nil && ct.VMID() == vmID) || ct.Name() == guestID || ct.ID() == guestID { - return &GuestInfo{ - VMID: ct.VMID(), - Name: ct.Name(), - Node: ct.Node(), - Type: "system-container", - Technology: "lxc", - Status: string(ct.Status()), - Instance: ct.Instance(), - }, nil - } - } - - return nil, fmt.Errorf("no VM or container found with ID or name '%s'", guestID) -} - -func (e *PulseToolExecutor) resolveDockerContainer(containerName, hostName string) (*models.DockerContainer, *models.DockerHost, error) { - rs, err := e.readStateForControl() - if err != nil { - return nil, nil, fmt.Errorf("read state not available: %w", err) - } - - containersByHost := make(map[string][]*unifiedresources.DockerContainerView) - for _, container := range rs.DockerContainers() { - if container == nil { - continue - } - parentID := strings.TrimSpace(container.ParentID()) - if parentID == "" { - continue - } - containersByHost[parentID] = append(containersByHost[parentID], container) - } - - type dockerMatch struct { - host models.DockerHost - container models.DockerContainer - } - matches := []dockerMatch{} - - for _, hostView := range rs.DockerHosts() { - if hostView == nil { - continue - } - if hostName != "" && !matchesDockerHostFilter(hostView, hostName) { - continue - } - - hostModel := dockerHostModelFromView(hostView) - for _, containerView := range containersByHost[hostView.ID()] { - container := dockerContainerModelFromView(containerView) - if container.Name == containerName || - container.ID == containerName || - strings.HasPrefix(container.ID, containerName) { - matches = append(matches, dockerMatch{host: hostModel, container: container}) - } - } - } - - if hostName != "" { - if len(matches) == 0 { - return nil, nil, fmt.Errorf("container '%s' not found on host '%s'", containerName, hostName) - } - match := matches[0] - return &match.container, &match.host, nil - } - - if len(matches) == 0 { - return nil, nil, fmt.Errorf("container '%s' not found on any Docker host", containerName) - } - if len(matches) > 1 { - hostNames := make([]string, 0, len(matches)) - seen := make(map[string]bool) - for _, match := range matches { - name := strings.TrimSpace(match.host.DisplayName) - if name == "" { - name = strings.TrimSpace(match.host.Hostname) - } - if name == "" { - name = strings.TrimSpace(match.host.ID) - } - if name == "" || seen[name] { - continue - } - hostNames = append(hostNames, name) - seen[name] = true - } - if len(hostNames) == 0 { - return nil, nil, fmt.Errorf("container '%s' exists on multiple Docker hosts; specify host", containerName) - } - return nil, nil, fmt.Errorf("container '%s' exists on multiple Docker hosts: %s. Specify host", containerName, strings.Join(hostNames, ", ")) - } - - match := matches[0] - return &match.container, &match.host, nil -} - -func matchesDockerHostFilter(host *unifiedresources.DockerHostView, filter string) bool { - if host == nil { - return false - } - if host.Hostname() == filter || host.Name() == filter { - return true - } - if host.ID() == filter || host.HostSourceID() == filter { - return true - } - return false -} - -func dockerHostModelFromView(host *unifiedresources.DockerHostView) models.DockerHost { - if host == nil { - return models.DockerHost{} - } - - hostID := strings.TrimSpace(host.HostSourceID()) - if hostID == "" { - hostID = strings.TrimSpace(host.ID()) - } - - displayName := strings.TrimSpace(host.Name()) - hostname := strings.TrimSpace(host.Hostname()) - if hostname == "" { - hostname = displayName - } - - return models.DockerHost{ - ID: hostID, - AgentID: strings.TrimSpace(host.AgentID()), - Hostname: hostname, - DisplayName: displayName, - Runtime: strings.TrimSpace(host.Runtime()), - Status: string(host.Status()), - LastSeen: host.LastSeen(), - Command: host.Command(), - Security: host.Security(), - } -} - -func dockerContainerModelFromView(container *unifiedresources.DockerContainerView) models.DockerContainer { - if container == nil { - return models.DockerContainer{} - } - - containerID := strings.TrimSpace(container.ContainerID()) - if containerID == "" { - containerID = strings.TrimSpace(container.ID()) - } - - state := strings.TrimSpace(container.ContainerState()) - if state == "" { - state = string(container.Status()) - } - - ports := container.Ports() - portModels := make([]models.DockerContainerPort, 0, len(ports)) - for _, p := range ports { - portModels = append(portModels, models.DockerContainerPort{ - PrivatePort: p.PrivatePort, - PublicPort: p.PublicPort, - Protocol: p.Protocol, - IP: p.IP, - }) - } - - networks := container.Networks() - networkModels := make([]models.DockerContainerNetworkLink, 0, len(networks)) - for _, n := range networks { - networkModels = append(networkModels, models.DockerContainerNetworkLink{ - Name: n.Name, - IPv4: n.IPv4, - IPv6: n.IPv6, - }) - } - - mounts := container.Mounts() - mountModels := make([]models.DockerContainerMount, 0, len(mounts)) - for _, m := range mounts { - mountModels = append(mountModels, models.DockerContainerMount{ - Type: m.Type, - Source: m.Source, - Destination: m.Destination, - Mode: m.Mode, - RW: m.RW, - }) - } - - var updateStatus *models.DockerContainerUpdateStatus - if status := container.UpdateStatus(); status != nil { - updateStatus = &models.DockerContainerUpdateStatus{ - UpdateAvailable: status.UpdateAvailable, - CurrentDigest: status.CurrentDigest, - LatestDigest: status.LatestDigest, - Error: status.Error, - } - } - - return models.DockerContainer{ - ID: containerID, - Name: container.Name(), - Image: container.Image(), - State: state, - Health: container.Health(), - CPUPercent: container.CPUPercent(), - MemoryUsage: container.MemoryUsed(), - MemoryLimit: container.MemoryTotal(), - MemoryPercent: container.MemoryPercent(), - UptimeSeconds: container.UptimeSeconds(), - RestartCount: container.RestartCount(), - ExitCode: container.ExitCode(), - Ports: portModels, - Labels: container.Labels(), - Networks: networkModels, - Mounts: mountModels, - UpdateStatus: updateStatus, - } -} - func (e *PulseToolExecutor) findAgentForNode(nodeName string) string { if e.agentServer == nil { return "" @@ -1202,830 +403,4 @@ func (e *PulseToolExecutor) findAgentForNode(nodeName string) string { return "" } -func (e *PulseToolExecutor) findAgentForDockerHost(dockerHost *models.DockerHost) string { - if e.agentServer == nil { - return "" - } - - agents := e.agentServer.GetConnectedAgents() - - // First try to match by AgentID (most reliable) - if dockerHost.AgentID != "" { - for _, agent := range agents { - if agent.AgentID == dockerHost.AgentID { - return agent.AgentID - } - } - } - - // Fall back to hostname match - for _, agent := range agents { - if unifiedresources.HostnamesEquivalent(agent.Hostname, dockerHost.Hostname) { - return agent.AgentID - } - } - - return "" -} - -// getAgentHostnameForDockerHost finds the agent hostname for a Docker host (for approval records) -func (e *PulseToolExecutor) getAgentHostnameForDockerHost(dockerHost *models.DockerHost) string { - if e.agentServer == nil { - return dockerHost.Hostname // fallback - } - - agents := e.agentServer.GetConnectedAgents() - - // Try to match by AgentID first - if dockerHost.AgentID != "" { - for _, agent := range agents { - if agent.AgentID == dockerHost.AgentID { - return agent.Hostname - } - } - } - - // Fall back to the docker host's hostname - return dockerHost.Hostname -} - -// resolveDockerHostRoutingFull resolves a Docker host to the correct agent and routing info -// with full provenance metadata. If the Docker host is actually a system container or VM, -// it routes through the node agent with the correct TargetType and TargetID so commands -// are executed inside the guest. -func (e *PulseToolExecutor) resolveDockerHostRoutingFull(dockerHost *models.DockerHost) CommandRoutingResult { - result := CommandRoutingResult{ - TargetType: "agent", - Transport: "direct", - } - - if e.agentServer == nil { - return result - } - - // STEP 1: Check topology — is the Docker host actually a system container or VM? - if rs, err := e.readStateForControl(); err == nil { - // Check system containers - for _, ct := range rs.Containers() { - if ct.Name() == dockerHost.Hostname { - result.ResolvedKind = "system-container" - result.ResolvedNode = ct.Node() - result.TargetType = "container" - result.TargetID = fmt.Sprintf("%d", ct.VMID()) - result.Transport = "pct_exec" - nodeAgentID := e.findAgentForNode(ct.Node()) - if nodeAgentID != "" { - result.AgentID = nodeAgentID - result.AgentHostname = ct.Node() - log.Debug(). - Str("docker_host", dockerHost.Hostname). - Str("node", ct.Node()). - Int("vmid", ct.VMID()). - Str("agent", nodeAgentID). - Str("transport", result.Transport). - Msg("Resolved Docker host as LXC, routing through Proxmox agent") - } - return result - } - } - - // Check VMs - for _, vm := range rs.VMs() { - if vm.Name() == dockerHost.Hostname { - result.ResolvedKind = "vm" - result.ResolvedNode = vm.Node() - result.TargetType = "vm" - result.TargetID = fmt.Sprintf("%d", vm.VMID()) - result.Transport = "qm_guest_exec" - nodeAgentID := e.findAgentForNode(vm.Node()) - if nodeAgentID != "" { - result.AgentID = nodeAgentID - result.AgentHostname = vm.Node() - log.Debug(). - Str("docker_host", dockerHost.Hostname). - Str("node", vm.Node()). - Int("vmid", vm.VMID()). - Str("agent", nodeAgentID). - Str("transport", result.Transport). - Msg("Resolved Docker host as VM, routing through Proxmox agent") - } - return result - } - } - } - - // STEP 2: Docker host is not an LXC/VM — use direct agent routing - agentID := e.findAgentForDockerHost(dockerHost) - result.AgentID = agentID - result.ResolvedKind = "docker-host" - if agentID != "" { - // Try to get agent hostname - agents := e.agentServer.GetConnectedAgents() - for _, a := range agents { - if a.AgentID == agentID { - result.AgentHostname = a.Hostname - break - } - } - } - return result -} - -// createApprovalRecord creates an approval record in the store and returns the approval ID. -// Returns empty string if store is not available (approvals will still work, just without persistence). -func createApprovalRecord(command, targetType, targetID, targetName, context string) string { - return createApprovalRecordForOrg("", command, targetType, targetID, targetName, context) -} - -func createApprovalRecordForOrg(orgID, command, targetType, targetID, targetName, context string) string { - return createApprovalRecordForOrgWithExecutor(nil, orgID, command, targetType, targetID, targetName, context) -} - -func (e *PulseToolExecutor) createApprovalRecord(command, targetType, targetID, targetName, context string) string { - orgID := "" - if e != nil { - orgID = e.orgID - } - return createApprovalRecordForOrgWithExecutor(e, orgID, command, targetType, targetID, targetName, context) -} - -func createApprovalRecordForOrgWithExecutor(e *PulseToolExecutor, orgID, command, targetType, targetID, targetName, context string) string { - store := approval.GetStore() - if store == nil { - log.Debug().Msg("approval store not available, approval will not be persisted") - return "" - } - - approvalID := uuid.NewString() - now := time.Now().UTC() - req := &approval.ApprovalRequest{ - ID: approvalID, - OrgID: strings.TrimSpace(orgID), - Command: command, - TargetType: targetType, - TargetID: targetID, - TargetName: targetName, - Context: context, - } - AttachApprovalActionPlan(req, now) - - if err := store.CreateApproval(req); err != nil { - log.Warn().Err(err).Msg("failed to create approval record") - return "" - } - if req.Plan != nil { - req.Plan.ExpiresAt = req.ExpiresAt.UTC() - if req.Plan.Message == "" { - req.Plan.Message = strings.TrimSpace(req.Context) - } - } - - log.Debug().Str("approval_id", req.ID).Str("command", command).Msg("created approval record") - if e != nil { - e.recordPendingApprovalAction(req) - } - return req.ID -} - -func approvalBlastRadius(targetType, command string) []string { - targetType = strings.ToLower(strings.TrimSpace(targetType)) - commandLower := strings.ToLower(strings.TrimSpace(command)) - switch { - case strings.Contains(commandLower, "delete") || strings.HasPrefix(commandLower, "rm ") || strings.Contains(commandLower, " rm "): - return []string{"destructive target change"} - case strings.Contains(commandLower, "restart") || strings.Contains(commandLower, "reboot") || strings.Contains(commandLower, "shutdown"): - return []string{"service interruption on target"} - case targetType == "file": - return []string{"file contents on target"} - case targetType == "kubernetes": - return []string{"kubernetes workload state"} - case targetType == "docker": - return []string{"container runtime state"} - default: - return []string{"target resource state"} - } -} - -func approvalRollbackAvailable(targetType, command string) bool { - targetType = strings.ToLower(strings.TrimSpace(targetType)) - commandLower := strings.ToLower(strings.TrimSpace(command)) - if targetType == "file" || strings.Contains(commandLower, "delete") || strings.HasPrefix(commandLower, "rm ") || strings.Contains(commandLower, " rm ") { - return false - } - return strings.Contains(commandLower, "restart") || strings.Contains(commandLower, "start") || strings.Contains(commandLower, "stop") || targetType == "docker" -} - -func approvalContextConfidence(req *approval.ApprovalRequest) *approval.ContextConfidence { - if req == nil { - return nil - } - targetType := strings.TrimSpace(req.TargetType) - targetID := strings.TrimSpace(req.TargetID) - targetName := strings.TrimSpace(req.TargetName) - evidence := make([]string, 0, 3) - if targetType != "" { - evidence = append(evidence, fmt.Sprintf("Target type resolved as %s.", targetType)) - } - if targetID != "" { - evidence = append(evidence, fmt.Sprintf("Target identifier bound to %s.", targetID)) - } - if targetName != "" { - evidence = append(evidence, fmt.Sprintf("Display target resolved as %s.", targetName)) - } - - level := approval.ContextConfidenceUnknown - summary := "Pulse Assistant could not bind this action to a resolved target." - switch { - case targetType != "" && targetID != "": - level = approval.ContextConfidenceVerified - summary = "Target was resolved to a concrete resource before approval." - case targetType != "" && targetName != "": - level = approval.ContextConfidencePartial - summary = "Target type and display name were resolved before approval." - case targetType != "" || targetName != "": - level = approval.ContextConfidenceInferred - summary = "Target context was inferred from the requested action." - } - - return &approval.ContextConfidence{ - Level: level, - Summary: summary, - Evidence: evidence, - } -} - -func approvalPreflight(req *approval.ApprovalRequest) *approval.ActionPreflight { - if req == nil { - return nil - } - target := approvalPreflightTarget(req) - intendedChange := strings.TrimSpace(req.Context) - if intendedChange == "" { - intendedChange = strings.TrimSpace(req.Command) - } - dryRunAvailable := approvalDryRunAvailable(req.TargetType, req.Command) - dryRunSummary := "No provider-supported dry run is available for this action; Pulse will hold execution until approval and validate the approval binding before dispatch." - if dryRunAvailable { - dryRunSummary = "Provider dry-run semantics are available for this action class before execution." - } - - // Default safety / verification copy applies to every action. Per-command - // class additions enrich it with concrete operational context the - // operator needs before approving (what "restart this service" actually - // touches, how Pulse will read back success). - safetyChecks := []string{ - "Approval is scoped to the current organization.", - "Command hash must match before execution.", - "Approval can be consumed only once.", - "Target type and identifier must match the planned action.", - } - verificationSteps := []string{ - "Persist unified action audit lifecycle.", - "Dispatch only after approval is granted.", - "Capture command result or execution error.", - "Require Assistant read-after-write verification before final response.", - } - if extraSafety, extraVerify := approvalCommandClassPreflightAdditions(req.TargetType, req.Command); len(extraSafety) > 0 || len(extraVerify) > 0 { - safetyChecks = append(safetyChecks, extraSafety...) - verificationSteps = append(verificationSteps, extraVerify...) - } - - return &approval.ActionPreflight{ - Target: target, - CurrentState: fmt.Sprintf("Resolved approval target: %s.", target), - IntendedChange: intendedChange, - DryRunAvailable: dryRunAvailable, - DryRunSummary: dryRunSummary, - SafetyChecks: safetyChecks, - VerificationSteps: verificationSteps, - GeneratedAt: time.Now().UTC(), - } -} - -// VerificationCommandForCommand derives the read-after-write check Pulse -// will run after a successful dispatch. The check is keyed on the same -// command class as approvalCommandClassPreflightAdditions, so the -// verification narrative the operator saw at approval time and the -// verification actually executed stay coherent. Returns "", false for -// classes without a derivable check; the broker must skip verification -// rather than fabricate one. -// -// Tool-layer-verified classes are intentionally excluded here: -// - Container classes (container-restart, container-stop) are verified -// by pulse_docker via per-container `docker inspect` at the tool -// layer. -// - Proxmox VM/CT classes (proxmox-vm-*, proxmox-ct-*) are verified by -// pulse_control via `verifyGuestAction` (which dispatches `qm status` -// or `pct status` already) at the tool layer. -// -// Adding a parallel broker-level dispatch for these would double-run the -// same check. The preflight copy authored by -// approvalCommandClassPreflightAdditions still names what the -// tool-layer verification will read so the operator-facing narrative -// stays accurate. -func VerificationCommandForCommand(targetType, command string) (string, bool) { - class := classifyApprovalCommand(targetType, command) - switch class { - case "service-restart", "service-start", "service-reload", "service-stop": - unit := extractServiceUnitName(command) - if unit == "" { - return "", false - } - return "systemctl is-active " + shellQuoteSingle(unit), true - } - return "", false -} - -// extractServiceUnitName parses the unit name out of a systemctl/service -// command. Supports the common shapes: -// -// "systemctl restart nginx" -> "nginx" -// "systemctl reload my.service" -> "my.service" -// "service restart redis" -> "redis" -// -// Returns empty string when the command does not parse cleanly so the -// caller can decline to verify rather than guess at the unit. -func extractServiceUnitName(command string) string { - fields := strings.Fields(strings.TrimSpace(command)) - if len(fields) < 3 { - return "" - } - first := strings.ToLower(fields[0]) - if first != "systemctl" && first != "service" { - return "" - } - // Service action verbs (restart/start/stop/reload) live at index 1 for - // "systemctl restart nginx" and at index 2 for "service restart nginx"; - // the canonical form we recognize puts the action at index 1 and the - // unit at index 2. - verb := strings.ToLower(fields[1]) - switch verb { - case "restart", "start", "stop", "reload": - return fields[2] - } - return "" -} - -// shellQuoteSingle wraps the value in single quotes and escapes any -// embedded single quotes for shell-safe inclusion in the verification -// command. The verification path runs through the same agent dispatch as -// the original command, so the same shell-escape contract applies. -func shellQuoteSingle(value string) string { - if value == "" { - return "''" - } - return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" -} - -// classifyApprovalCommand maps a (targetType, command) pair to a stable -// class string used by approvalCommandClassPreflightAdditions. Returns -// empty string for commands that do not match a known class — those keep -// the default preflight content rather than getting fabricated extras. -func classifyApprovalCommand(targetType, command string) string { - cmd := strings.ToLower(strings.TrimSpace(command)) - if cmd == "" { - return "" - } - switch { - case strings.Contains(cmd, "systemctl restart"), strings.Contains(cmd, "service restart"): - return "service-restart" - case strings.Contains(cmd, "systemctl stop"), strings.Contains(cmd, "service stop"): - return "service-stop" - case strings.Contains(cmd, "systemctl start"), strings.Contains(cmd, "service start"): - return "service-start" - case strings.Contains(cmd, "systemctl reload"): - return "service-reload" - case strings.Contains(cmd, "docker restart"), strings.Contains(cmd, "podman restart"): - return "container-restart" - case strings.Contains(cmd, "docker stop"), strings.Contains(cmd, "podman stop"): - return "container-stop" - case strings.HasPrefix(cmd, "kubectl rollout restart"): - return "k8s-rollout-restart" - // Proxmox VM lifecycle (qm) — Pulse's primary monitored platform. Each - // verb has distinct operational semantics (graceful shutdown vs hard - // stop vs reboot) so they map to distinct classes rather than being - // folded into the generic service-* buckets. - case strings.HasPrefix(cmd, "qm reboot ") || strings.HasPrefix(cmd, "qm restart "): - return "proxmox-vm-reboot" - case strings.HasPrefix(cmd, "qm stop "): - return "proxmox-vm-stop" - case strings.HasPrefix(cmd, "qm start "): - return "proxmox-vm-start" - case strings.HasPrefix(cmd, "qm shutdown "): - return "proxmox-vm-shutdown" - // Proxmox LXC container lifecycle (pct). Same per-verb split as qm - // because pct shutdown initiates an in-guest shutdown via lxc-attach - // while pct stop is an immediate halt. - case strings.HasPrefix(cmd, "pct reboot ") || strings.HasPrefix(cmd, "pct restart "): - return "proxmox-ct-reboot" - case strings.HasPrefix(cmd, "pct stop "): - return "proxmox-ct-stop" - case strings.HasPrefix(cmd, "pct start "): - return "proxmox-ct-start" - case strings.HasPrefix(cmd, "pct shutdown "): - return "proxmox-ct-shutdown" - } - return "" -} - -// approvalCommandClassPreflightAdditions returns hand-authored safety and -// verification additions for known command classes. The text is concrete -// operational context the operator wants before approving — what the -// command actually touches, how Pulse will read back success — rather -// than generic boilerplate. Unknown classes return empty slices so the -// default preflight is not padded with fabricated assertions. -func approvalCommandClassPreflightAdditions(targetType, command string) (safetyChecks []string, verificationSteps []string) { - switch classifyApprovalCommand(targetType, command) { - case "service-restart": - return []string{ - "Service will be briefly unavailable during the systemctl restart; no other unit dependencies are altered.", - "Restart applies only to the named unit on the named host; no fleet-wide propagation.", - }, []string{ - "Read back `systemctl is-active ` after dispatch.", - "Tail recent journal entries for the unit to confirm a clean restart and no crash loop.", - } - case "service-stop": - return []string{ - "Service will remain stopped after dispatch; restart requires a follow-up approved action.", - "Stopping the unit may impact dependent services; verify the dependency tree before approving.", - }, []string{ - "Read back `systemctl is-active ` and confirm `inactive`.", - "Check journal for any auto-restart triggered by Restart=always policy.", - } - case "service-start": - return []string{ - "Start applies only to the named unit; no other state changes.", - "Unit will run with the configuration currently on disk; uncommitted config drift will take effect.", - }, []string{ - "Read back `systemctl is-active ` and confirm `active`.", - "Tail journal for startup errors before the first request lands.", - } - case "service-reload": - return []string{ - "Reload reads new configuration without dropping in-flight requests when supported by the unit.", - "Units without reload semantics fall back to restart; check unit type before approving.", - }, []string{ - "Read back `systemctl status ` and confirm `Active: active (running)` post-reload.", - "Tail recent journal entries for configuration parse errors.", - } - case "container-restart": - return []string{ - "Container will be briefly unavailable during the restart; image and volume mounts are unchanged.", - "Restart applies only to the named container; no compose-stack-wide propagation.", - }, []string{ - "Read back container state via `docker inspect` (or podman) and confirm `Status: running`.", - "Tail recent container logs for crash patterns or startup errors.", - } - case "container-stop": - return []string{ - "Container will remain stopped after dispatch; image and volume mounts persist for next start.", - "Stopping a container may impact dependent containers in the same compose stack.", - }, []string{ - "Read back container state and confirm `Status: exited`.", - "Check for any restart policy that may auto-restart the container.", - } - case "k8s-rollout-restart": - return []string{ - "Pods are rolled in waves per the deployment strategy; service availability is preserved when readiness probes pass.", - "PodDisruptionBudget and HPA-driven scaling continue to apply during the rollout.", - }, []string{ - "Watch `kubectl rollout status` until the deployment converges.", - "Verify pod readiness with `kubectl get pods -l ` after rollout.", - } - case "proxmox-vm-reboot": - return []string{ - "`qm reboot` performs an ACPI shutdown followed by a start; in-guest workloads see a clean OS reboot.", - "VM RAM state is not preserved (no live migration); guests with non-persistent state will lose it.", - }, []string{ - "Read back `qm status ` and confirm `status: running`.", - "Verify the guest's monitoring agent reconnects (Pulse will detect and update the resource state).", - } - case "proxmox-vm-stop": - return []string{ - "`qm stop` is an immediate hard stop, NOT a graceful shutdown — guest filesystems may be left dirty.", - "Use `qm shutdown` instead if the workload needs to flush state cleanly; only approve `qm stop` when the guest is unresponsive.", - }, []string{ - "Read back `qm status ` and confirm `status: stopped`.", - "Verify no auto-start policy will immediately restart the VM.", - } - case "proxmox-vm-start": - return []string{ - "VM will boot from disk with the configuration currently on the host; uncommitted config drift will take effect.", - "Boot order, attached disks, and network bridges all apply as currently defined — verify before approving.", - }, []string{ - "Read back `qm status ` and confirm `status: running`.", - "Tail `journalctl -u qmeventd` for the start lifecycle event.", - } - case "proxmox-vm-shutdown": - return []string{ - "`qm shutdown` issues an ACPI shutdown to the guest and waits for the VM to power off cleanly.", - "Default timeout is 60s; an unresponsive guest will force a stop after the timeout, leaving filesystems in the same state as `qm stop`.", - }, []string{ - "Read back `qm status ` and confirm `status: stopped`.", - "Verify the shutdown was clean (exit code 0) rather than a forced stop after timeout.", - } - case "proxmox-ct-reboot": - return []string{ - "`pct reboot` performs an in-guest reboot; container processes restart while the LXC instance stays managed by Proxmox.", - "Mounted bind-mounts and shared volumes persist through the reboot; only in-memory state is lost.", - }, []string{ - "Read back `pct status ` and confirm `status: running`.", - "Verify the container's foreground service has come back up before treating the reboot as complete.", - } - case "proxmox-ct-stop": - return []string{ - "`pct stop` is an immediate hard stop — in-guest processes do not get a chance to flush state cleanly.", - "Use `pct shutdown` instead for graceful in-guest shutdown; reserve `pct stop` for unresponsive containers.", - }, []string{ - "Read back `pct status ` and confirm `status: stopped`.", - "Verify no on-boot auto-start policy will immediately restart the container.", - } - case "proxmox-ct-start": - return []string{ - "Container will start with the configuration currently on the host; uncommitted config drift takes effect.", - "Mounted volumes, network bridges, and resource limits all apply as currently defined.", - }, []string{ - "Read back `pct status ` and confirm `status: running`.", - "Verify the container's foreground service is reachable from the host network.", - } - case "proxmox-ct-shutdown": - return []string{ - "`pct shutdown` issues a graceful in-guest shutdown via lxc-attach and waits for the container to halt.", - "Default timeout falls back to a hard stop on unresponsive containers, leaving processes in the same state as `pct stop`.", - }, []string{ - "Read back `pct status ` and confirm `status: stopped`.", - "Verify the shutdown was clean rather than a forced stop after timeout.", - } - } - return nil, nil -} - -func approvalPreflightTarget(req *approval.ApprovalRequest) string { - if req == nil { - return "unknown target" - } - parts := make([]string, 0, 3) - if targetType := strings.TrimSpace(req.TargetType); targetType != "" { - parts = append(parts, targetType) - } - if targetName := strings.TrimSpace(req.TargetName); targetName != "" { - parts = append(parts, targetName) - } - if targetID := strings.TrimSpace(req.TargetID); targetID != "" { - parts = append(parts, targetID) - } - if len(parts) == 0 { - return "Pulse runtime" - } - return strings.Join(parts, " / ") -} - -func approvalDryRunAvailable(targetType, command string) bool { - targetType = strings.ToLower(strings.TrimSpace(targetType)) - commandLower := strings.ToLower(strings.TrimSpace(command)) - return targetType == "kubernetes" && strings.Contains(commandLower, "--dry-run") -} - -// isPreApproved checks if the args contain a valid, approved approval_id. -// This is used when the agentic loop re-executes a tool after user approval. -// DEPRECATED: Use consumeApprovalWithValidation instead for replay protection. -func isPreApproved(args map[string]interface{}) bool { - approvalID := agentcapabilities.ApprovalArgument(args) - if approvalID == "" { - return false - } - - store := approval.GetStore() - if store == nil { - return false - } - - req, found := store.GetApproval(approvalID) - if !found { - log.Debug().Str("approval_id", approvalID).Msg("pre-approval check: approval not found") - return false - } - - if req.Status == approval.StatusApproved { - log.Debug().Str("approval_id", approvalID).Msg("pre-approval check: approved, skipping approval flow") - return true - } - - log.Debug().Str("approval_id", approvalID).Str("status", string(req.Status)).Msg("pre-approval check: not approved") - return false -} - -// 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 == "" { - return false - } - - store := approval.GetStore() - if store == nil { - return false - } - - req, found := store.GetApproval(approvalID) - if !found { - log.Warn().Str("approval_id", approvalID).Msg("failed to find approval for pre-approved execution") - return false - } - - if !approval.BelongsToOrg(req, orgID) { - log.Warn(). - Str("approval_id", approvalID). - Str("request_org", approval.NormalizeOrgID(req.OrgID)). - Str("requested_org", approval.NormalizeOrgID(orgID)). - Msg("cross-org pre-approved execution rejected") - return false - } - - 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 -} - // Formatting helpers for control tools - -func enrichApprovalRequiredPayload(payload map[string]interface{}, approvalID string) map[string]interface{} { - if payload == nil { - payload = map[string]interface{}{} - } - req := approvalRequestForID(approvalID) - if req == nil { - return payload - } - if _, ok := payload["risk"]; !ok && req.RiskLevel != "" { - payload["risk"] = string(req.RiskLevel) - } - if _, ok := payload["description"]; !ok && strings.TrimSpace(req.Context) != "" { - payload["description"] = strings.TrimSpace(req.Context) - } - if strings.TrimSpace(req.TargetType) != "" { - payload["target_type"] = strings.TrimSpace(req.TargetType) - } - if strings.TrimSpace(req.TargetID) != "" { - payload["target_id"] = strings.TrimSpace(req.TargetID) - } - if strings.TrimSpace(req.TargetName) != "" { - payload["target_name"] = strings.TrimSpace(req.TargetName) - } - if req.Plan != nil { - payload["audit_id"] = strings.TrimSpace(req.Plan.ActionID) - payload["plan"] = map[string]interface{}{ - "action_id": strings.TrimSpace(req.Plan.ActionID), - "request_id": strings.TrimSpace(req.Plan.RequestID), - "summary": strings.TrimSpace(req.Plan.Message), - "requires_approval": req.Plan.RequiresApproval, - "approval_policy": string(req.Plan.ApprovalPolicy), - "blast_radius": strings.TrimSpace(strings.Join(req.Plan.PredictedBlastRadius, ", ")), - "rollback_available": req.Plan.RollbackAvailable, - "plan_hash": strings.TrimSpace(req.Plan.PlanHash), - "expires_at": req.Plan.ExpiresAt.UTC().Format(time.RFC3339), - } - } - if req.ContextConfidence != nil { - payload["context_confidence"] = map[string]interface{}{ - "level": string(req.ContextConfidence.Level), - "summary": strings.TrimSpace(req.ContextConfidence.Summary), - "evidence": append([]string(nil), req.ContextConfidence.Evidence...), - } - } - if req.Preflight != nil { - payload["preflight"] = map[string]interface{}{ - "target": strings.TrimSpace(req.Preflight.Target), - "current_state": strings.TrimSpace(req.Preflight.CurrentState), - "intended_change": strings.TrimSpace(req.Preflight.IntendedChange), - "dry_run_available": req.Preflight.DryRunAvailable, - "dry_run_summary": strings.TrimSpace(req.Preflight.DryRunSummary), - "safety_checks": append([]string(nil), req.Preflight.SafetyChecks...), - "verification_steps": append([]string(nil), req.Preflight.VerificationSteps...), - "generated_at": req.Preflight.GeneratedAt.UTC().Format(time.RFC3339), - } - } - return payload -} - -func formatApprovalNeeded(command, reason, approvalID string) string { - payload := map[string]interface{}{ - "approval_id": approvalID, - "command": command, - "reason": reason, - "how_to_approve": "Click the approval button in the chat to execute this command.", - } - payload = enrichApprovalRequiredPayload(payload, approvalID) - return agentcapabilities.FormatApprovalRequiredToolMarker(payload) -} - -func formatPolicyBlocked(command, reason string) string { - payload := map[string]interface{}{ - "command": command, - "reason": reason, - } - return agentcapabilities.FormatPolicyBlockedToolMarker(payload) -} - -func collectAgentHostnames(agents []agentexec.ConnectedAgent, max int) (all []string, truncated []string) { - all = make([]string, 0, len(agents)) - for _, agent := range agents { - name := strings.TrimSpace(agent.Hostname) - if name == "" { - name = strings.TrimSpace(agent.AgentID) - } - if name != "" { - all = append(all, name) - } - } - - truncated = all - if max >= 0 && len(all) > max { - truncated = all[:max] - } - - return all, truncated -} - -func formatTargetHostRequired(agents []agentexec.ConnectedAgent) string { - const maxItems = 6 - hostnames, list := collectAgentHostnames(agents, maxItems) - if len(hostnames) == 0 { - return "Multiple agents are connected. Please specify target_host." - } - message := fmt.Sprintf("Multiple agents are connected. Please specify target_host. Available: %s", strings.Join(list, ", ")) - if len(hostnames) > maxItems { - message = fmt.Sprintf("%s (+%d more)", message, len(hostnames)-maxItems) - } - return message -} - -// formatAvailableAgentHosts returns a hint listing connected agent hostnames. -func formatAvailableAgentHosts(agents []agentexec.ConnectedAgent) string { - const maxItems = 6 - hostnames, list := collectAgentHostnames(agents, maxItems) - if len(hostnames) == 0 { - return "No agents are currently connected." - } - msg := fmt.Sprintf("Available targets: %s", strings.Join(list, ", ")) - if len(hostnames) > maxItems { - msg = fmt.Sprintf("%s (+%d more)", msg, len(hostnames)-maxItems) - } - return msg -} - -func formatControlApprovalNeeded(name string, vmID int, action, command, approvalID string) string { - payload := map[string]interface{}{ - "approval_id": approvalID, - "guest_name": name, - "guest_vmid": vmID, - "action": action, - "command": command, - "how_to_approve": "Click the approval button in the chat to execute this action.", - } - payload = enrichApprovalRequiredPayload(payload, approvalID) - return agentcapabilities.FormatApprovalRequiredToolMarker(payload) -} - -func formatDockerApprovalNeeded(name, host, action, command, approvalID string) string { - payload := map[string]interface{}{ - "approval_id": approvalID, - "container_name": name, - "docker_host": host, - "action": action, - "command": command, - "how_to_approve": "Click the approval button in the chat to execute this action.", - } - payload = enrichApprovalRequiredPayload(payload, approvalID) - return agentcapabilities.FormatApprovalRequiredToolMarker(payload) -} - -func formatAppContainerApprovalNeeded(name, host, action, command, approvalID string) string { - payload := map[string]interface{}{ - "approval_id": approvalID, - "resource_name": name, - "resource_host": host, - "resource_type": "app-container", - "action": action, - "command": command, - "how_to_approve": "Click the approval button in the chat to execute this action.", - } - payload = enrichApprovalRequiredPayload(payload, approvalID) - return agentcapabilities.FormatApprovalRequiredToolMarker(payload) -} diff --git a/internal/ai/tools/tools_docker.go b/internal/ai/tools/tools_docker.go index e0667c05a6..f125548d8e 100644 --- a/internal/ai/tools/tools_docker.go +++ b/internal/ai/tools/tools_docker.go @@ -7,9 +7,6 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/ai/safety" - "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rs/zerolog/log" ) @@ -22,31 +19,9 @@ const ( // dockerUpdateQueueRetryMaxDelay caps exponential backoff for queue retries. dockerUpdateQueueRetryMaxDelay = 250 * time.Millisecond - - // dockerUpdateVerifyMaxAttempts bounds post-queue verification polling of the - // update command status. Combined with dockerUpdateVerifyPollInterval this - // gives the agent one report cycle (~30s) to pick the command up plus time - // for small-image updates to finish; slower updates report as inconclusive. - dockerUpdateVerifyMaxAttempts = 30 - - // dockerUpdateVerifyPollInterval is the delay between verification polls. - dockerUpdateVerifyPollInterval = 2 * time.Second ) -// Terminal docker host command statuses, mirroring the DockerCommandStatus* -// constants in internal/monitoring. Duplicated as literals because the tools -// package sits below monitoring in the wiring (monitoring satisfies the -// UpdatesCommandRunner interface defined here). -const ( - dockerCommandStatusCompleted = "completed" - dockerCommandStatusFailed = "failed" - dockerCommandStatusExpired = "expired" -) - -var ( - dockerUpdateQueueSleepFn = sleepWithContext - dockerUpdateVerifySleepFn = sleepWithContext -) +var dockerUpdateQueueSleepFn = sleepWithContext // registerDockerTools registers the pulse_docker tool func (e *PulseToolExecutor) registerDockerTools() { @@ -117,231 +92,8 @@ func (e *PulseToolExecutor) executeDocker(ctx context.Context, args map[string]i } } -// executeDockerControl handles start/stop/restart of Docker containers -func (e *PulseToolExecutor) executeDockerControl(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - containerName, _ := args["container"].(string) - hostName, _ := args["host"].(string) - operation, _ := args["operation"].(string) - approvalID := agentcapabilities.ApprovalArgument(args) - - if containerName == "" { - return NewErrorResult(fmt.Errorf("container name is required")), nil - } - if operation == "" { - return NewErrorResult(fmt.Errorf("operation is required (start, stop, restart)")), nil - } - - validOperations := map[string]bool{"start": true, "stop": true, "restart": true} - if !validOperations[operation] { - return NewErrorResult(fmt.Errorf("invalid operation: %s. Use start, stop, or restart", operation)), nil - } - - // Check if read-only mode - if e.controlLevel == ControlLevelReadOnly { - return NewTextResult("Docker control actions are not available in read-only mode."), nil - } - - container, dockerHost, err := e.resolveDockerContainer(containerName, hostName) - if err != nil { - return NewTextResult(fmt.Sprintf("Could not find Docker container '%s': %v", containerName, err)), nil - } - - runtimeCommand := dockerRuntimeCommand(dockerHost) - command := fmt.Sprintf("%s %s %s", runtimeCommand, operation, shellEscape(container.Name)) - approvalTargetID := fmt.Sprintf("%s:%s", dockerHost.ID, container.Name) - if dockerHost.ID == "" { - approvalTargetID = fmt.Sprintf("%s:%s", dockerHost.Hostname, container.Name) - } - - // Check if this is a pre-approved execution (validated + single-use). - preApproved := consumeApprovalWithValidation(args, e.orgID, command, "docker", approvalTargetID) - requiresApproval := !e.isAutonomous && (e.controlLevel == ControlLevelControlled || (e.policy != nil && e.policy.Evaluate(command) == agentexec.PolicyRequireApproval)) - - // Get the agent hostname for approval records - agentHostname := e.getAgentHostnameForDockerHost(dockerHost) - - // Skip approval checks if pre-approved - if !preApproved && e.policy != nil { - decision := e.policy.Evaluate(command) - if decision == agentexec.PolicyBlock { - return NewTextResult(formatPolicyBlocked(command, "This command is blocked by security policy")), nil - } - if decision == agentexec.PolicyRequireApproval && !e.isAutonomous { - approvalID := e.createApprovalRecord(command, "docker", approvalTargetID, agentHostname, fmt.Sprintf("%s Docker container %s", operation, container.Name)) - return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, operation, command, approvalID)), nil - } - } - - // Check control level - if !preApproved && e.controlLevel == ControlLevelControlled { - approvalID := e.createApprovalRecord(command, "docker", approvalTargetID, agentHostname, fmt.Sprintf("%s Docker container %s", operation, container.Name)) - return NewTextResult(formatDockerApprovalNeeded(container.Name, dockerHost.Hostname, operation, command, approvalID)), nil - } - - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } - - // Resolve the Docker host to the correct agent and routing info (with full provenance) - routing := e.resolveDockerHostRoutingFull(dockerHost) - if routing.AgentID == "" { - if routing.TargetType == "container" || routing.TargetType == "vm" { - return NewTextResult(fmt.Sprintf("Docker host '%s' is a %s but no agent is available on its Proxmox host. Install Pulse Unified Agent on the Proxmox node.", dockerHost.Hostname, routing.TargetType)), nil - } - return NewTextResult(fmt.Sprintf("No agent available on Docker host '%s'. Install Pulse Unified Agent on the host to enable control.", dockerHost.Hostname)), nil - } - - log.Debug(). - Str("docker_host", dockerHost.Hostname). - Str("agent_id", routing.AgentID). - Str("agent_host", routing.AgentHostname). - Str("resolved_kind", routing.ResolvedKind). - Str("resolved_node", routing.ResolvedNode). - Str("transport", routing.Transport). - Str("target_type", routing.TargetType). - Str("target_id", routing.TargetID). - Msg("[pulse_docker] Routing docker command execution") - - result, err := e.executeCommandWithAudit( - ctx, - "pulse_docker", - func() string { - if dockerHost.ID != "" { - return fmt.Sprintf("%s:%s", dockerHost.ID, container.Name) - } - return fmt.Sprintf("%s:%s", dockerHost.Hostname, container.Name) - }(), - approvalID, - requiresApproval, - routing.AgentID, - agentexec.ExecuteCommandPayload{ - Command: command, - TargetType: routing.TargetType, - TargetID: routing.TargetID, - }, - "pulse_docker", - fmt.Sprintf("%s docker container %s", operation, container.Name), - ) - if err != nil { - return NewErrorResult(err), nil - } - - output := result.Stdout - if result.Stderr != "" { - output += "\n" + result.Stderr - } - - if redacted, n := safety.RedactSensitiveText(output); n > 0 { - output = redacted + fmt.Sprintf("\n\n[redacted %d sensitive value(s)]", n) - } - - verify := e.verifyDockerContainerState(ctx, routing, runtimeCommand, container.ID, operation) - verify["ok"] = result.ExitCode == 0 - - response := map[string]interface{}{ - "success": result.ExitCode == 0, - "action": "control", - "operation": operation, - "container": container.Name, - "container_id": container.ID, - "host": dockerHost.Hostname, - "command": command, - "exit_code": result.ExitCode, - "output": output, - "verification": verify, - } - - return NewJSONResultWithIsError(response, result.ExitCode != 0), nil -} - -func dockerRuntimeCommand(dockerHost *models.DockerHost) string { - if dockerHost != nil { - switch strings.ToLower(strings.TrimSpace(dockerHost.Runtime)) { - case "podman": - return "podman" - case "docker": - return "docker" - } - } - return "docker" -} - // ========== Docker Updates Handler Implementations ========== -func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, routing CommandRoutingResult, runtimeCommand, containerID, operation string) map[string]interface{} { - expectRunning := false - switch operation { - case "start", "restart": - expectRunning = true - case "stop": - expectRunning = false - } - - runtimeCommand = strings.TrimSpace(runtimeCommand) - if runtimeCommand == "" { - runtimeCommand = "docker" - } - inspectCmd := fmt.Sprintf("%s inspect -f '{{.State.Status}} {{.State.Running}}' %s", runtimeCommand, shellEscape(containerID)) - - var lastOut string - var lastExit int - for attempt := 1; attempt <= 3; attempt++ { - res, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{ - Command: inspectCmd, - TargetType: routing.TargetType, - TargetID: routing.TargetID, - }) - if err != nil { - return map[string]interface{}{"confirmed": false, "method": runtimeCommand + "_inspect", "command": inspectCmd, "note": err.Error()} - } - lastExit = res.ExitCode - lastOut = strings.TrimSpace(res.Stdout + "\n" + res.Stderr) - - fields := strings.Fields(strings.ToLower(lastOut)) - observedRunning := false - observedStatus := "" - if len(fields) >= 1 { - observedStatus = fields[0] - } - if len(fields) >= 2 { - observedRunning = fields[1] == "true" - } - - if res.ExitCode == 0 && observedStatus != "" && observedRunning == expectRunning { - return map[string]interface{}{ - "confirmed": true, - "method": runtimeCommand + "_inspect", - "command": inspectCmd, - "expected": map[string]interface{}{"running": expectRunning}, - "observed": map[string]interface{}{"status": observedStatus, "running": observedRunning}, - } - } - - // Allow a brief settle window for restart/start/stop to propagate. - waitTimer := time.NewTimer(500 * time.Millisecond) - select { - case <-ctx.Done(): - if !waitTimer.Stop() { - select { - case <-waitTimer.C: - default: - } - } - return map[string]interface{}{"confirmed": false, "method": runtimeCommand + "_inspect", "command": inspectCmd, "note": "context canceled", "raw": lastOut, "exit_code": lastExit} - case <-waitTimer.C: - } - } - - return map[string]interface{}{ - "confirmed": false, - "method": runtimeCommand + "_inspect", - "command": inspectCmd, - "expected": map[string]interface{}{"running": expectRunning}, - "raw": lastOut, - "exit_code": lastExit, - } -} - func sleepWithContext(ctx context.Context, duration time.Duration) error { if ctx == nil { time.Sleep(duration) @@ -501,162 +253,6 @@ func (e *PulseToolExecutor) executeCheckDockerUpdates(ctx context.Context, args return NewJSONResult(response), nil } -func (e *PulseToolExecutor) executeUpdateDockerContainer(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - if e.updatesProvider == nil { - return NewTextResult("Docker update functionality not available. Ensure updates provider is configured."), nil - } - - containerArg, _ := args["container"].(string) - hostArg, _ := args["host"].(string) - - if containerArg == "" { - return NewErrorResult(fmt.Errorf("container is required")), nil - } - if hostArg == "" { - return NewErrorResult(fmt.Errorf("host is required")), nil - } - - // Check if update actions are enabled - if !e.updatesProvider.IsUpdateActionsEnabled() { - return NewTextResult("Docker container updates are disabled by server configuration. Set PULSE_DISABLE_DOCKER_UPDATE_ACTIONS=false or enable in Settings to allow updates."), nil - } - - // Resolve container and host - container, dockerHost, err := e.resolveDockerContainer(containerArg, hostArg) - if err != nil { - return NewTextResult(fmt.Sprintf("Could not find container '%s' on host '%s': %v", containerArg, hostArg, err)), nil - } - - containerName := trimContainerName(container.Name) - - // Controlled mode - require approval - if e.controlLevel == ControlLevelControlled { - command := fmt.Sprintf("docker update %s", containerName) - agentHostname := e.getAgentHostnameForDockerHost(dockerHost) - approvalID := e.createApprovalRecord(command, "docker", container.ID, agentHostname, fmt.Sprintf("Update container %s to latest image", containerName)) - return NewTextResult(formatDockerUpdateApprovalNeeded(containerName, dockerHost.Hostname, approvalID)), nil - } - - // Autonomous mode - execute directly - cmdStatus, err := e.queueDockerUpdateCommandWithRetry(ctx, "queue container update", func() (DockerCommandStatus, error) { - return e.updatesProvider.UpdateContainer(dockerHost.ID, container.ID, containerName) - }) - if err != nil { - return NewTextResult(fmt.Sprintf("Failed to queue update command: %v", err)), nil - } - - // The agent executes the update asynchronously: it pulls the image, - // recreates the container, verifies the replacement is running and healthy - // (rolling back otherwise), then acknowledges a terminal command status. - // Poll that status so we report verified success, verified failure, or an - // honest inconclusive - never unverified success. - verify := e.verifyDockerUpdateCommand(ctx, cmdStatus.ID, containerName) - outcome, _ := verify["outcome"].(string) - - var message string - switch outcome { - case "verified": - message = fmt.Sprintf("Update verified: container '%s' was updated, recreated, and is running again.", containerName) - case "failed": - reason := "" - if observed, ok := verify["observed"].(map[string]interface{}); ok { - if fr, ok := observed["failure_reason"].(string); ok && fr != "" { - reason = fr - } else if msg, ok := observed["message"].(string); ok { - reason = msg - } - } - if reason == "" { - reason = "the agent reported the update command as failed" - } - message = fmt.Sprintf("Update of container '%s' failed: %s", containerName, reason) - default: - message = fmt.Sprintf("Update command queued for container '%s', but it had not reached a terminal state within the verification window. Completion is NOT yet verified - re-check with pulse_docker action 'updates' or inspect the container before reporting success.", containerName) - } - - response := DockerUpdateContainerResponse{ - Success: outcome != "failed", - TargetID: dockerHost.ID, - ContainerID: container.ID, - ContainerName: containerName, - CommandID: cmdStatus.ID, - Message: message, - Command: cmdStatus, - Verification: verify, - } - - return NewJSONResultWithIsError(response, outcome == "failed"), nil -} - -// verifyDockerUpdateCommand polls the queued update command's status until it -// reaches a terminal state or the bounded verification window elapses. The -// returned map follows the same verification shape as -// verifyDockerContainerState / verifyGuestAction: confirmed only on observed -// terminal success, with an explicit outcome of "verified", "failed", or -// "inconclusive". -func (e *PulseToolExecutor) verifyDockerUpdateCommand(ctx context.Context, commandID, containerName string) map[string]interface{} { - verification := map[string]interface{}{ - "confirmed": false, - "method": "command_status", - "command_id": commandID, - } - - if commandID == "" { - verification["outcome"] = "inconclusive" - verification["note"] = "queue returned no command ID, so the update outcome cannot be tracked" - return verification - } - - var lastObserved map[string]interface{} - - for attempt := 1; attempt <= dockerUpdateVerifyMaxAttempts; attempt++ { - status, ok := e.updatesProvider.GetCommandStatus(commandID) - if ok { - lastObserved = map[string]interface{}{"status": status.Status} - if status.Message != "" { - lastObserved["message"] = status.Message - } - if status.FailureReason != "" { - lastObserved["failure_reason"] = status.FailureReason - } - - switch status.Status { - case dockerCommandStatusCompleted: - verification["confirmed"] = true - verification["outcome"] = "verified" - verification["observed"] = lastObserved - verification["note"] = fmt.Sprintf("agent confirmed container '%s' was recreated and is running", containerName) - return verification - case dockerCommandStatusFailed, dockerCommandStatusExpired: - verification["outcome"] = "failed" - verification["observed"] = lastObserved - return verification - } - } - - if attempt == dockerUpdateVerifyMaxAttempts { - break - } - if err := dockerUpdateVerifySleepFn(ctx, dockerUpdateVerifyPollInterval); err != nil { - verification["outcome"] = "inconclusive" - verification["note"] = "verification canceled before the update reached a terminal state" - if lastObserved != nil { - verification["observed"] = lastObserved - } - return verification - } - } - - verification["outcome"] = "inconclusive" - if lastObserved != nil { - verification["observed"] = lastObserved - verification["note"] = "update command did not reach a terminal state within the verification window; completion is unverified" - } else { - verification["note"] = "update command status was not trackable within the verification window; completion is unverified" - } - return verification -} - // Helper methods for Docker updates func (e *PulseToolExecutor) resolveDockerHostID(hostArg string) string { @@ -699,26 +295,6 @@ func (e *PulseToolExecutor) getDockerHostName(hostID string) string { return hostID } -func formatDockerUpdateApprovalNeeded(containerName, hostName, approvalID string) string { - payload := map[string]interface{}{ - "approval_id": approvalID, - "container_name": containerName, - "docker_host": hostName, - "action": "update", - "command": fmt.Sprintf("docker update %s (pull latest + recreate)", containerName), - "how_to_approve": "Click the approval button in the chat to execute this update.", - } - payload = enrichApprovalRequiredPayload(payload, approvalID) - return agentcapabilities.FormatApprovalRequiredToolMarker(payload) -} - -func trimLeadingSlash(name string) string { - if len(name) > 0 && name[0] == '/' { - return name[1:] - } - return name -} - // ========== Docker Swarm Handler Implementations ========== func (e *PulseToolExecutor) executeGetSwarmStatus(_ context.Context, args map[string]interface{}) (CallToolResult, error) { diff --git a/internal/ai/tools/tools_file.go b/internal/ai/tools/tools_file.go index 798b6256c9..888c45757b 100644 --- a/internal/ai/tools/tools_file.go +++ b/internal/ai/tools/tools_file.go @@ -2,16 +2,11 @@ package tools import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" "fmt" "strings" - "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/safety" - "github.com/rs/zerolog/log" ) // ExecutionProvenance tracks where a command actually executed. @@ -31,126 +26,6 @@ type ExecutionProvenance struct { Transport string `json:"transport"` // "direct", "pct_exec", "qm_guest_exec" } -// registerFileTools registers the file editing tool -func (e *PulseToolExecutor) registerFileTools() { - e.registry.registerBuiltin(RegisteredTool{ - Definition: Tool{ - Name: agentcapabilities.PulseFileEditToolName, - Description: `Edit files on remote hosts, containers, VMs, and Docker containers safely. - -Actions: -- append: Append content to the end of a file -- write: Write/overwrite a file with new content (creates if doesn't exist) - -To READ a file, use pulse_read with action="file" instead; this tool is write-only. - -This tool handles escaping automatically - just provide the content as-is. -Use this instead of shell commands for editing config files (YAML, JSON, etc.) - -Routing: target_host can be a node (pve-node), a container name (homepage-docker), or a VM name. Commands are automatically routed through the appropriate agent. - -Docker container support: Use container to access files INSIDE a Docker container. The target_host specifies where Docker runs. - -Examples: -- Write to host: action="write", path="/tmp/test.txt", content="hello", target_host="pve-node" -- Write to Docker: action="write", path="/tmp/test.txt", content="hello", target_host="tower", container="nginx"`, - InputSchema: InputSchema{ - Type: "object", - Properties: map[string]PropertySchema{ - "action": { - Type: "string", - Description: "File action: append or write", - Enum: []string{"append", "write"}, - }, - "path": { - Type: "string", - Description: "Absolute path to the file", - }, - "content": { - Type: "string", - Description: "Content to write or append (for append/write actions)", - }, - "target_host": { - Type: "string", - Description: "Hostname where the file exists (or where Docker is running)", - }, - "container": { - Type: "string", - Description: "Docker container name (for files inside containers)", - }, - }, - Required: []string{"action", "path", "target_host"}, - }, - }, - Handler: func(ctx context.Context, exec *PulseToolExecutor, args map[string]interface{}) (CallToolResult, error) { - return NewErrorResult(fmt.Errorf("model-authored file mutation is retired and denied")), nil - }, - RequireControl: true, - Governance: ToolGovernance{ - ActionMode: ToolActionWrite, - ApprovalPolicy: ToolApprovalActionPlan, - ApprovalSummary: "hidden in read-only mode; approval required in controlled mode", - Summary: "Changes files through the governed file-edit path (append/write only); file inspection routes through the read-only pulse_read tool.", - }, - }) -} - -// executeFileEdit handles file read/write operations -func (e *PulseToolExecutor) executeFileEdit(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - action, _ := args["action"].(string) - path, _ := args["path"].(string) - content, _ := args["content"].(string) - targetHost, _ := args["target_host"].(string) - dockerContainer, legacyContainerArg := resolveContainerArg(args) - - if path == "" { - return NewErrorResult(fmt.Errorf("path is required")), nil - } - if targetHost == "" { - return NewErrorResult(fmt.Errorf("target_host is required")), nil - } - if legacyContainerArg { - return NewErrorResult(fmt.Errorf("app_container is no longer supported; use app-container")), nil - } - - // Validate path - must be absolute - if !strings.HasPrefix(path, "/") { - return NewErrorResult(fmt.Errorf("path must be absolute (start with /)")), nil - } - - // Validate container if provided (simple alphanumeric + _ + -) - if dockerContainer != "" { - for _, c := range dockerContainer { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' || c == '.') { - return NewErrorResult(fmt.Errorf("invalid character '%c' in container name", c)), nil - } - } - } - - // Check control level (defense in depth; the registry blocks - // infrastructure mutations before this handler runs). - if e.controlLevel == ControlLevelReadOnly { - return NewTextResult("File editing is not available in read-only mode."), nil - } - - switch action { - case "read": - return NewErrorResult(fmt.Errorf("pulse_file_edit is write-only; read files with pulse_read action=\"file\"")), nil - case "append": - if content == "" { - return NewErrorResult(fmt.Errorf("content is required for append action")), nil - } - return e.executeFileAppend(ctx, path, content, targetHost, dockerContainer, args) - case "write": - if content == "" { - return NewErrorResult(fmt.Errorf("content is required for write action")), nil - } - return e.executeFileWrite(ctx, path, content, targetHost, dockerContainer, args) - default: - return NewErrorResult(fmt.Errorf("unknown action: %s. Use: append, write", action)), nil - } -} - // executeFileRead reads a file's contents func (e *PulseToolExecutor) executeFileRead(ctx context.Context, path, targetHost, dockerContainer string) (CallToolResult, error) { @@ -225,358 +100,6 @@ func (e *PulseToolExecutor) executeFileRead(ctx context.Context, path, targetHos return NewJSONResult(response), nil } -// fileMutationSpec captures how the append and write actions diverge inside -// the shared file-mutation pipeline. Approval command/detail strings must stay -// stable: pre-approval consumption matches on the exact command text. -type fileMutationSpec struct { - action string // response/validation verb: "append" / "write" - approvalCommand string // exact approval command text - approvalDetail string // human approval description - redirect string // shell redirect: ">>" / ">" - failureVerb string // error wrap: "append to file" / "write file" - auditDetail string // audit log description - verify func(ctx context.Context, routing CommandRoutingResult, path, dockerContainer, content string) map[string]interface{} -} - -// executeFileAppend appends content to a file -func (e *PulseToolExecutor) executeFileAppend(ctx context.Context, path, content, targetHost, dockerContainer string, args map[string]interface{}) (CallToolResult, error) { - return e.executeFileMutation(ctx, path, content, targetHost, dockerContainer, args, fileMutationSpec{ - action: "append", - approvalCommand: fmt.Sprintf("Append to file: %s", path), - approvalDetail: fmt.Sprintf("Append %d bytes to %s", len(content), path), - redirect: ">>", - failureVerb: "append to file", - auditDetail: fmt.Sprintf("append %d bytes to %s", len(content), path), - verify: e.verifyFileTailHash, - }) -} - -// executeFileWrite writes content to a file (overwrites) -func (e *PulseToolExecutor) executeFileWrite(ctx context.Context, path, content, targetHost, dockerContainer string, args map[string]interface{}) (CallToolResult, error) { - return e.executeFileMutation(ctx, path, content, targetHost, dockerContainer, args, fileMutationSpec{ - action: "write", - approvalCommand: fmt.Sprintf("Write file: %s", path), - approvalDetail: fmt.Sprintf("Write %d bytes to %s", len(content), path), - redirect: ">", - failureVerb: "write file", - auditDetail: fmt.Sprintf("write %d bytes to %s", len(content), path), - verify: e.verifyFileSHA256, - }) -} - -// executeFileMutation runs the shared append/write pipeline: sensitive-path -// blocking, routing + resolved-resource validation, approval gating, base64 -// transfer, audited execution, and post-write verification. -func (e *PulseToolExecutor) executeFileMutation(ctx context.Context, path, content, targetHost, dockerContainer string, args map[string]interface{}, spec fileMutationSpec) (CallToolResult, error) { - approvalID := agentcapabilities.ApprovalArgument(args) - - if blocked, reason := safety.IsSensitivePath(path); blocked { - return NewToolResponseResult(NewToolBlockedError( - "SENSITIVE_PATH", - fmt.Sprintf("Refusing to write sensitive path '%s' (%s).", path, reason), - map[string]interface{}{ - "path": path, - "reason": reason, - "policy_boundary": "Credential files cannot be modified through AI tools.", - }, - )), nil - } - - // Validate routing context - block if targeting a host node when child resources exist - // This prevents accidentally writing files to the host when user meant to write to an container/VM - routingResult := e.validateRoutingContext(targetHost) - if routingResult.IsBlocked() { - return NewToolResponseResult(routingResult.RoutingError.ToToolResponse()), nil - } - - // Validate resource is in resolved context (write operation) - // With PULSE_STRICT_RESOLUTION=true, this blocks execution on undiscovered resources - validation := e.validateResolvedResource(targetHost, spec.action, true) - if validation.IsBlocked() { - // Hard validation failure - return consistent error envelope - return NewToolResponseResult(validation.StrictError.ToToolResponse()), nil - } - // Soft validation warnings are logged inside validateResolvedResource - - // Use full routing resolution - includes provenance for debugging - routing := e.resolveTargetForCommandFull(targetHost) - if routing.AgentID == "" { - return unavailableCommandConnection(targetHost, routing), nil - } - - // INVARIANT: If the target resolves to a child resource (container/VM), writes MUST execute - // inside that context via pct_exec/qm_guest_exec. No silent node fallback. - if err := e.validateWriteExecutionContext(targetHost, routing); err != nil { - return NewToolResponseResult(err.ToToolResponse()), nil - } - - approvalTargetID := fmt.Sprintf("host=%s|container=%s|path=%s", targetHost, dockerContainer, path) - - // Check if pre-approved (validated + single-use). - preApproved := consumeApprovalWithValidation(args, e.orgID, spec.approvalCommand, "file", approvalTargetID) - requiresApproval := !e.isAutonomous && e.controlLevel == ControlLevelControlled - - // Skip approval checks if pre-approved or in autonomous mode - if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled { - target := targetHost - if dockerContainer != "" { - target = fmt.Sprintf("%s (container: %s)", targetHost, dockerContainer) - } - approvalID := e.createApprovalRecord( - spec.approvalCommand, - "file", - approvalTargetID, - target, - spec.approvalDetail, - ) - return NewTextResult(formatFileApprovalNeeded(path, target, spec.action, len(content), approvalID)), nil - } - - // Use base64 encoding to safely transfer content - encoded := base64.StdEncoding.EncodeToString([]byte(content)) - var command string - if dockerContainer != "" { - // Mutate inside Docker container - escape the full inner command to avoid nested quote breakage. - innerCommand := fmt.Sprintf("echo %s | base64 -d %s %s", shellEscape(encoded), spec.redirect, shellEscape(path)) - command = fmt.Sprintf("docker exec %s sh -c %s", shellEscape(dockerContainer), shellEscape(innerCommand)) - } else { - // For host/container/VM targets - agent handles sh -c wrapping for container/VM - command = fmt.Sprintf("echo %s | base64 -d %s %s", shellEscape(encoded), spec.redirect, shellEscape(path)) - } - - result, err := e.executeCommandWithAudit( - ctx, - "pulse_file_edit", - func() string { - if dockerContainer != "" { - return fmt.Sprintf("%s:%s:%s", targetHost, dockerContainer, path) - } - return fmt.Sprintf("%s:%s", targetHost, path) - }(), - approvalID, - requiresApproval, - routing.AgentID, - agentexec.ExecuteCommandPayload{ - Command: command, - TargetType: routing.TargetType, - TargetID: routing.TargetID, - }, - "pulse_file_edit", - spec.auditDetail, - ) - if err != nil { - return NewErrorResult(fmt.Errorf("failed to %s: %w", spec.failureVerb, err)), nil - } - - if result.ExitCode != 0 { - errMsg := result.Stderr - if errMsg == "" { - errMsg = result.Stdout - } - response := map[string]interface{}{ - "success": false, - "action": spec.action, - "path": path, - "host": targetHost, - "exit_code": result.ExitCode, - "error": errMsg, - } - setContainerResponseFields(response, dockerContainer) - return NewJSONResultWithIsError(response, true), nil - } - - verify := spec.verify(ctx, routing, path, dockerContainer, content) - - response := map[string]interface{}{ - "success": true, - "action": spec.action, - "path": path, - "host": targetHost, - "bytes_written": len(content), - "verification": verify, - } - setContainerResponseFields(response, dockerContainer) - // Include execution provenance for observability - response["execution"] = buildExecutionProvenance(targetHost, routing) - return NewJSONResult(response), nil -} - -func (e *PulseToolExecutor) verifyFileSHA256(ctx context.Context, routing CommandRoutingResult, path, dockerContainer, content string) map[string]interface{} { - expected := sha256.Sum256([]byte(content)) - expectedHex := hex.EncodeToString(expected[:]) - - // Use a portable command chain; not all systems have sha256sum. - hashCmd := fmt.Sprintf("sha256sum %s 2>/dev/null || shasum -a 256 %s 2>/dev/null || openssl dgst -sha256 %s 2>/dev/null", shellEscape(path), shellEscape(path), shellEscape(path)) - if dockerContainer != "" { - hashCmd = fmt.Sprintf("docker exec %s sh -c %s", shellEscape(dockerContainer), shellEscape(hashCmd)) - } - - res, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{ - Command: hashCmd, - TargetType: routing.TargetType, - TargetID: routing.TargetID, - }) - if err != nil { - return map[string]interface{}{ - "ok": true, - "method": "exit_code", - "note": fmt.Sprintf("sha256 verification unavailable: %v", err), - } - } - combined := strings.TrimSpace(res.Stdout + "\n" + res.Stderr) - actualHex := "" - if fields := strings.Fields(combined); len(fields) > 0 { - actualHex = fields[0] - } - if res.ExitCode != 0 || actualHex == "" { - return map[string]interface{}{ - "ok": true, - "method": "exit_code", - "note": "sha256 verification unavailable on target (missing tools or non-zero exit)", - } - } - ok := strings.EqualFold(actualHex, expectedHex) - return map[string]interface{}{ - "ok": ok, - "method": "sha256", - "expected": expectedHex, - "actual": actualHex, - } -} - -func (e *PulseToolExecutor) verifyFileTailHash(ctx context.Context, routing CommandRoutingResult, path, dockerContainer, appended string) map[string]interface{} { - // Verify that the file's trailing bytes match what we appended, without re-reading the full file. - n := len(appended) - if n <= 0 { - return map[string]interface{}{"ok": true, "method": "tail_sha256", "note": "no appended content"} - } - expected := sha256.Sum256([]byte(appended)) - expectedHex := hex.EncodeToString(expected[:]) - - tailCmd := fmt.Sprintf("tail -c %d %s 2>/dev/null | (sha256sum 2>/dev/null || shasum -a 256 2>/dev/null)", n, shellEscape(path)) - if dockerContainer != "" { - tailCmd = fmt.Sprintf("docker exec %s sh -c %s", shellEscape(dockerContainer), shellEscape(tailCmd)) - } - - res, err := e.agentServer.ExecuteCommand(ctx, routing.AgentID, agentexec.ExecuteCommandPayload{ - Command: tailCmd, - TargetType: routing.TargetType, - TargetID: routing.TargetID, - }) - if err != nil { - return map[string]interface{}{"ok": true, "method": "exit_code", "note": fmt.Sprintf("tail sha256 verification unavailable: %v", err)} - } - combined := strings.TrimSpace(res.Stdout + "\n" + res.Stderr) - actualHex := "" - if fields := strings.Fields(combined); len(fields) > 0 { - actualHex = fields[0] - } - if res.ExitCode != 0 || actualHex == "" { - return map[string]interface{}{"ok": true, "method": "exit_code", "note": "tail sha256 verification unavailable on target (missing tools or non-zero exit)"} - } - ok := strings.EqualFold(actualHex, expectedHex) - return map[string]interface{}{ - "ok": ok, - "method": "tail_sha256", - "expected": expectedHex, - "actual": actualHex, - } -} - -// ErrExecutionContextUnavailable is returned when a write operation targets a child resource -// (container/VM) but the execution cannot be properly routed into that resource context. -// This prevents silent fallback to node-level execution, which would write files on the -// host node instead of inside the container/VM. -type ErrExecutionContextUnavailable struct { - TargetHost string // What the model requested - ResolvedKind string // What the state says it is (system-container, vm) - ResolvedNode string // Which hypervisor node it's on - Transport string // What transport we got (should be pct_exec but might be "direct") - Message string -} - -func (e *ErrExecutionContextUnavailable) Error() string { - return e.Message -} - -func (e *ErrExecutionContextUnavailable) ToToolResponse() ToolResponse { - return NewToolBlockedError(ErrCodeExecutionContextUnavailable, e.Message, map[string]interface{}{ - "target_host": e.TargetHost, - "resolved_kind": e.ResolvedKind, - "resolved_node": e.ResolvedNode, - "transport": e.Transport, - "policy_boundary": "Cannot write files to this target because the execution context is not reachable through the governed container/VM transport.", - }) -} - -// validateWriteExecutionContext ensures write operations execute inside the correct context. -// -// INVARIANT: If state.ResolveResource says the target is an container/VM, writes MUST use -// pct_exec/qm_guest_exec to run inside that container. A "direct" transport on a child -// resource means we'd write to the host node's filesystem instead — which is always wrong. -// -// This catches the scenario where: -// 1. target_host="homepage-docker" (a system container) -// 2. An agent on the node matches "homepage-docker" as a direct hostname -// 3. Command runs on the node without pct exec → writes to node filesystem -func (e *PulseToolExecutor) validateWriteExecutionContext(targetHost string, routing CommandRoutingResult) *ErrExecutionContextUnavailable { - if !e.hasReadState() { - return nil // Can't validate without state - } - - loc := e.resolveResourceLocation(targetHost) - if !loc.Found { - return nil // Unknown resource, nothing to validate - } - - // Only validate for child resources (system containers / VMs) - isChildResource := loc.ResourceType == "system-container" || loc.ResourceType == "vm" - if !isChildResource { - return nil - } - - // For child resources, the routing MUST use pct_exec or qm_guest_exec - // If it resolved as "direct" (agent-level type), that means we'd execute on the node, not inside the container/VM - if routing.Transport == "direct" && routing.TargetType == "agent" { - log.Warn(). - Str("target_host", targetHost). - Str("resolved_kind", loc.ResourceType). - Str("resolved_node", loc.Node). - Str("agent_hostname", routing.AgentHostname). - Str("transport", routing.Transport). - Msg("[FileWrite] BLOCKED: Write would execute on node, not inside child resource. " + - "Agent matched target hostname directly, but state says target is container/VM.") - - return &ErrExecutionContextUnavailable{ - TargetHost: targetHost, - ResolvedKind: loc.ResourceType, - ResolvedNode: loc.Node, - Transport: routing.Transport, - Message: fmt.Sprintf( - "'%s' is a %s on node '%s', but the write would execute on the host node instead of inside the %s. "+ - "This happens when an agent matches the hostname directly instead of routing via pct exec. "+ - "The file would be written to the node's filesystem, not the %s's filesystem.", - targetHost, loc.ResourceType, loc.Node, loc.ResourceType, loc.ResourceType), - } - } - - // Also validate: if resolved as a guest but no agent found for the node - if routing.AgentID == "" { - return &ErrExecutionContextUnavailable{ - TargetHost: targetHost, - ResolvedKind: loc.ResourceType, - ResolvedNode: loc.Node, - Transport: "none", - Message: fmt.Sprintf( - "'%s' is a %s on node '%s', but no agent is available on that node. "+ - "Install the Pulse Unified Agent on '%s' to enable file operations inside the %s.", - targetHost, loc.ResourceType, loc.Node, loc.Node, loc.ResourceType), - } - } - - return nil -} - // buildExecutionProvenance creates provenance metadata for tool responses. // This makes it observable WHERE a command actually executed. func buildExecutionProvenance(targetHost string, routing CommandRoutingResult) map[string]interface{} { @@ -596,17 +119,3 @@ func shellEscape(s string) string { // Use single quotes and escape any existing single quotes return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } - -// formatFileApprovalNeeded formats an approval-required response for file operations -func formatFileApprovalNeeded(path, host, action string, size int, approvalID string) string { - payload := map[string]interface{}{ - "approval_id": approvalID, - "action": "file_" + action, - "path": path, - "host": host, - "size": size, - "message": fmt.Sprintf("File %s operation requires approval", action), - } - payload = enrichApprovalRequiredPayload(payload, approvalID) - return agentcapabilities.FormatApprovalRequiredToolMarker(payload) -} diff --git a/internal/ai/tools/tools_file_test.go b/internal/ai/tools/tools_file_test.go index 39a6173ed7..cfa6643daa 100644 --- a/internal/ai/tools/tools_file_test.go +++ b/internal/ai/tools/tools_file_test.go @@ -1,13 +1,8 @@ package tools import ( - "context" - "encoding/json" - "strings" "testing" - "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" - "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/stretchr/testify/assert" ) @@ -24,135 +19,3 @@ func TestFileTools_Registry(t *testing.T) { } assert.False(t, found, "retired model file mutation tool must not be registered") } - -func TestExecuteFileEdit_Validation(t *testing.T) { - exec := NewPulseToolExecutor(ExecutorConfig{}) - - tests := []struct { - name string - args map[string]interface{} - wantErr string - }{ - { - name: "Missing Path", - args: map[string]interface{}{"action": "read", "target_host": "h1"}, - wantErr: "path is required", - }, - { - name: "Missing TargetHost", - args: map[string]interface{}{"action": "read", "path": "/etc/config"}, - wantErr: "target_host is required", - }, - { - name: "Relative Path", - args: map[string]interface{}{"action": "read", "path": "etc/config", "target_host": "h1"}, - wantErr: "path must be absolute", - }, - { - name: "Invalid Docker Container", - args: map[string]interface{}{"action": "read", "path": "/f", "target_host": "h1", "container": "bad name!"}, - wantErr: "invalid character", - }, - { - name: "Legacy AppContainer Rejected", - args: map[string]interface{}{"action": "read", "path": "/f", "target_host": "h1", "app_container": "nginx"}, - wantErr: "app_container is no longer supported; use app-container", - }, - { - name: "Unknown Action", - args: map[string]interface{}{"action": "dance", "path": "/f", "target_host": "h1"}, - wantErr: "unknown action", - }, - { - name: "Append Missing Content", - args: map[string]interface{}{"action": "append", "path": "/f", "target_host": "h1"}, - wantErr: "content is required", - }, - { - name: "Write Missing Content", - args: map[string]interface{}{"action": "write", "path": "/f", "target_host": "h1"}, - wantErr: "content is required", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - res, err := exec.executeFileEdit(context.Background(), tc.args) - // Implementation returns error result (NewErrorResult), err is usually nil - assert.NoError(t, err) - assert.True(t, res.IsError) - assert.NotEmpty(t, res.Content) - assert.Contains(t, res.Content[0].Text, tc.wantErr) - }) - } -} - -func TestValidateWriteExecutionContext_Blocked(t *testing.T) { - // This tests the security invariant: don't write to host when target is LXC - - state := models.StateSnapshot{ - Containers: []models.Container{ - {VMID: 100, Name: "my-lxc", Node: "pve1", Status: "running"}, - }, - Nodes: []models.Node{ - {Name: "pve1"}, - }, - } - - stateProvider := &mockStateProvider{state: state} - agentServer := &mockAgentServer{} - agentServer.agents = []agentexec.ConnectedAgent{ - {AgentID: "agent-1", Hostname: "my-lxc"}, // Agent hostname matches LXC name! Risks confusion. - } - - exec := NewPulseToolExecutor(ExecutorConfig{ - AgentServer: agentServer, - StateProvider: stateProvider, - }) - - // We expect resolveTargetForCommandFull to map "my-lxc" to agent-1 - // But since agent-1 matches hostname "my-lxc" directly, it might be resolved as "direct". - // Wait, resolveTargetForCommandFull logic logic isn't mocked, it's real code in executor.go using state. - - // If ResolveResource says it's an LXC on pve1. - // And FindAgent says agent-1 (hostname=my-lxc) exists. - // The fallback logic in resolveTarget might pick agent-1 if it matches hostname directly? - - // Actually, `resolveTargetForCommandFull` calls `ResolveResource`. - // Then checks if agent exists for that resource. - - // If I want to trigger the BLOCK, I need: - // 1. ResolveResource returns LXC. - // 2. Routing returns "direct" transport on "agent" type. - - // This happens if `findAgent` returns an agent that is NOT the node agent? - // Or if the node agent is found via hostname match of the LXC name? - - // Let's manually invoke validateWriteExecutionContext to test logic in isolation. - - err := exec.validateWriteExecutionContext("my-lxc", CommandRoutingResult{ - Transport: "direct", - TargetType: "agent", // Agent matched directly as an agent target - AgentHostname: "my-lxc", - AgentID: "agent-1", - ResolvedKind: "system-container", // But resolving matched system container - ResolvedNode: "pve1", - }) - - assert.NotNil(t, err) - assert.Contains(t, err.Message, "write would execute on the host node instead of inside the system-container") -} - -func TestFormatFileApprovalNeeded_JSONSafe(t *testing.T) { - formatted := formatFileApprovalNeeded(`/tmp/"cfg".json`, `host"name`, "write", 42, "approval-1") - assert.True(t, strings.HasPrefix(formatted, "APPROVAL_REQUIRED: ")) - - raw := strings.TrimPrefix(formatted, "APPROVAL_REQUIRED: ") - var payload map[string]interface{} - assert.NoError(t, json.Unmarshal([]byte(raw), &payload)) - assert.Equal(t, "approval_required", payload["type"]) - assert.Equal(t, "file_write", payload["action"]) - assert.Equal(t, `/tmp/"cfg".json`, payload["path"]) - assert.Equal(t, `host"name`, payload["host"]) - assert.Equal(t, float64(42), payload["size"]) -} diff --git a/internal/ai/tools/tools_kubernetes.go b/internal/ai/tools/tools_kubernetes.go index 9cc64229e2..6885bc7a02 100644 --- a/internal/ai/tools/tools_kubernetes.go +++ b/internal/ai/tools/tools_kubernetes.go @@ -3,27 +3,13 @@ package tools import ( "context" "fmt" - "strconv" "strings" - "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) -const ( - // kubernetesVerifyMaxAttempts bounds read-after-write verification reads - // after a kubectl control action, mirroring verifyDockerContainerState. - kubernetesVerifyMaxAttempts = 3 - - // kubernetesVerifySettleDelay is the pause between verification attempts, - // letting the API server settle before re-reading. - kubernetesVerifySettleDelay = 500 * time.Millisecond -) - -var kubernetesVerifySleepFn = sleepWithContext - type kubernetesClusterTarget struct { ID string AgentID string @@ -498,446 +484,6 @@ func validateKubernetesResourceID(value string) error { return nil } -// buildKubectlExecCommand builds a kubectl exec command safely. -// It runs the command via "sh -c" inside the pod and shell-escapes all -// user-controlled values to prevent host shell injection. -func buildKubectlExecCommand(namespace, pod, container, command string) string { - base := fmt.Sprintf("kubectl -n %s exec %s", shellEscape(namespace), shellEscape(pod)) - if container != "" { - base += fmt.Sprintf(" -c %s", shellEscape(container)) - } - return base + fmt.Sprintf(" -- sh -c %s", shellEscape(command)) -} - -// executeKubernetesScale scales a deployment -func (e *PulseToolExecutor) executeKubernetesScale(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - clusterArg, _ := args["cluster"].(string) - namespace, _ := args["namespace"].(string) - deployment, _ := args["deployment"].(string) - replicas := intArg(args, "replicas", -1) - approvalID := agentcapabilities.ApprovalArgument(args) - - if clusterArg == "" { - return NewErrorResult(fmt.Errorf("cluster is required")), nil - } - if deployment == "" { - return NewErrorResult(fmt.Errorf("deployment is required")), nil - } - if replicas < 0 { - return NewErrorResult(fmt.Errorf("replicas is required and must be >= 0")), nil - } - if namespace == "" { - namespace = "default" - } - - // Validate identifiers - if err := validateKubernetesResourceID(namespace); err != nil { - return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil - } - if err := validateKubernetesResourceID(deployment); err != nil { - return NewErrorResult(fmt.Errorf("invalid deployment: %w", err)), nil - } - - // Check control level - if e.controlLevel == ControlLevelReadOnly { - return NewTextResult("Kubernetes control operations are not available in read-only mode."), nil - } - - agentID, cluster, err := e.findAgentForKubernetesCluster(clusterArg) - if err != nil { - return NewTextResult(err.Error()), nil - } - - // Build command - command := fmt.Sprintf("kubectl -n %s scale deployment %s --replicas=%d", shellEscape(namespace), shellEscape(deployment), replicas) - clusterScope := cluster.ID - if clusterScope == "" { - clusterScope = clusterArg - } - approvalTargetID := fmt.Sprintf("%s:%s:deployment:%s", clusterScope, namespace, deployment) - - // Check if pre-approved (validated + single-use). - preApproved := consumeApprovalWithValidation(args, e.orgID, command, "kubernetes", approvalTargetID) - requiresApproval := !e.isAutonomous && e.controlLevel == ControlLevelControlled - - // Request approval if needed - if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled { - displayName := cluster.DisplayName - approvalID := e.createApprovalRecord(command, "kubernetes", approvalTargetID, displayName, fmt.Sprintf("Scale deployment %s to %d replicas", deployment, replicas)) - return NewTextResult(formatKubernetesApprovalNeeded("scale", deployment, namespace, displayName, command, approvalID)), nil - } - - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } - - result, err := e.executeCommandWithAudit( - ctx, - "pulse_kubernetes", - fmt.Sprintf("%s:%s:deployment:%s", clusterScope, namespace, deployment), - approvalID, - requiresApproval, - agentID, - agentexec.ExecuteCommandPayload{ - Command: command, - TargetType: "agent", - TargetID: "", - }, - "pulse_kubernetes", - fmt.Sprintf("scale deployment %s to %d replicas", deployment, replicas), - ) - if err != nil { - return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil - } - - output := result.Stdout - if result.Stderr != "" { - output += "\n" + result.Stderr - } - - if result.ExitCode != 0 { - return NewTextResult(fmt.Sprintf("kubectl command failed (exit code %d):\n%s", result.ExitCode, output)), nil - } - - // Read-after-write: re-read the deployment's replica state through the - // same agent instead of asserting unverified success. - verify := e.verifyKubernetesScale(ctx, agentID, namespace, deployment, replicas) - verify["ok"] = true - - response := map[string]interface{}{ - "success": true, - "action": "scale", - "cluster": cluster.DisplayName, - "namespace": namespace, - "deployment": deployment, - "requested_replicas": replicas, - "command": command, - "exit_code": result.ExitCode, - "output": output, - "verification": verify, - } - return NewJSONResult(response), nil -} - -// verifyKubernetesScale re-reads the deployment's replica state after a scale -// command, mirroring the read-after-write idiom of verifyGuestAction and -// verifyDockerContainerState. Confirmed means the desired replica count is -// persisted on the deployment spec; readiness is reported observationally -// since pods may still be starting or terminating. -func (e *PulseToolExecutor) verifyKubernetesScale(ctx context.Context, agentID, namespace, deployment string, expectedReplicas int) map[string]interface{} { - verifyCmd := fmt.Sprintf("kubectl -n %s get deployment %s -o %s", - shellEscape(namespace), shellEscape(deployment), shellEscape("jsonpath={.spec.replicas} {.status.readyReplicas}")) - - var lastOut string - var lastExit int - for attempt := 1; attempt <= kubernetesVerifyMaxAttempts; attempt++ { - res, err := e.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{ - Command: verifyCmd, - TargetType: "agent", - }) - if err != nil { - return map[string]interface{}{"confirmed": false, "method": "kubectl_get", "command": verifyCmd, "note": err.Error()} - } - lastExit = res.ExitCode - lastOut = strings.TrimSpace(res.Stdout + "\n" + res.Stderr) - - if res.ExitCode == 0 { - fields := strings.Fields(lastOut) - if len(fields) >= 1 { - observedReplicas, parseErr := strconv.Atoi(fields[0]) - if parseErr == nil { - readyReplicas := 0 - if len(fields) >= 2 { - if ready, readyErr := strconv.Atoi(fields[1]); readyErr == nil { - readyReplicas = ready - } - } - observed := map[string]interface{}{"replicas": observedReplicas, "ready_replicas": readyReplicas} - if observedReplicas == expectedReplicas { - return map[string]interface{}{ - "confirmed": true, - "method": "kubectl_get", - "command": verifyCmd, - "expected": map[string]interface{}{"replicas": expectedReplicas}, - "observed": observed, - } - } - if attempt == kubernetesVerifyMaxAttempts { - return map[string]interface{}{ - "confirmed": false, - "method": "kubectl_get", - "command": verifyCmd, - "expected": map[string]interface{}{"replicas": expectedReplicas}, - "observed": observed, - } - } - } - } - } - - if attempt == kubernetesVerifyMaxAttempts { - break - } - if err := kubernetesVerifySleepFn(ctx, kubernetesVerifySettleDelay); err != nil { - return map[string]interface{}{"confirmed": false, "method": "kubectl_get", "command": verifyCmd, "note": "context canceled", "raw": lastOut, "exit_code": lastExit} - } - } - - return map[string]interface{}{ - "confirmed": false, - "method": "kubectl_get", - "command": verifyCmd, - "expected": map[string]interface{}{"replicas": expectedReplicas}, - "raw": lastOut, - "exit_code": lastExit, - } -} - -// kubernetesResourceAction captures how the namespaced kubectl actions -// (deployment restart, pod delete) diverge inside the shared -// approval/audit/execution pipeline. The kubectl command text doubles as the -// approval command, so command construction must stay stable per action. -type kubernetesResourceAction struct { - argKey string // resource argument name: "deployment" / "pod" - kindLabel string // approval target kind segment: "deployment" / "pod" - approvalAction string // formatKubernetesApprovalNeeded action: "restart" / "delete_pod" - command func(namespace, name string) string - approvalDetail func(name string) string - auditDetail func(name string) string - successMessage func(name, namespace, output string) string -} - -// executeKubernetesRestart restarts a deployment via rollout restart -func (e *PulseToolExecutor) executeKubernetesRestart(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - return e.executeKubernetesResourceAction(ctx, args, kubernetesResourceAction{ - argKey: "deployment", - kindLabel: "deployment", - approvalAction: "restart", - command: func(namespace, name string) string { - return fmt.Sprintf("kubectl -n %s rollout restart deployment/%s", shellEscape(namespace), shellEscape(name)) - }, - approvalDetail: func(name string) string { return fmt.Sprintf("Restart deployment %s", name) }, - auditDetail: func(name string) string { return fmt.Sprintf("restart deployment %s", name) }, - successMessage: func(name, namespace, output string) string { - return fmt.Sprintf("✓ Successfully initiated rollout restart for deployment '%s' in namespace '%s'. Action complete - pods will restart gradually.\n%s", name, namespace, output) - }, - }) -} - -// executeKubernetesDeletePod deletes a pod -func (e *PulseToolExecutor) executeKubernetesDeletePod(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - return e.executeKubernetesResourceAction(ctx, args, kubernetesResourceAction{ - argKey: "pod", - kindLabel: "pod", - approvalAction: "delete_pod", - command: func(namespace, name string) string { - return fmt.Sprintf("kubectl -n %s delete pod %s", shellEscape(namespace), shellEscape(name)) - }, - approvalDetail: func(name string) string { return fmt.Sprintf("Delete pod %s", name) }, - auditDetail: func(name string) string { return fmt.Sprintf("delete pod %s", name) }, - successMessage: func(name, namespace, output string) string { - return fmt.Sprintf("✓ Successfully deleted pod '%s' in namespace '%s'. If managed by a controller, a new pod will be created.\n%s", name, namespace, output) - }, - }) -} - -// executeKubernetesResourceAction runs the shared namespaced kubectl action -// pipeline: argument validation, control-level gating, cluster agent -// resolution, approval gating, and audited execution. -func (e *PulseToolExecutor) executeKubernetesResourceAction(ctx context.Context, args map[string]interface{}, spec kubernetesResourceAction) (CallToolResult, error) { - clusterArg, _ := args["cluster"].(string) - namespace, _ := args["namespace"].(string) - name, _ := args[spec.argKey].(string) - approvalID := agentcapabilities.ApprovalArgument(args) - - if clusterArg == "" { - return NewErrorResult(fmt.Errorf("cluster is required")), nil - } - if name == "" { - return NewErrorResult(fmt.Errorf("%s is required", spec.argKey)), nil - } - if namespace == "" { - namespace = "default" - } - - // Validate identifiers - if err := validateKubernetesResourceID(namespace); err != nil { - return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil - } - if err := validateKubernetesResourceID(name); err != nil { - return NewErrorResult(fmt.Errorf("invalid %s: %w", spec.argKey, err)), nil - } - - // Check control level - if e.controlLevel == ControlLevelReadOnly { - return NewTextResult("Kubernetes control operations are not available in read-only mode."), nil - } - - agentID, cluster, err := e.findAgentForKubernetesCluster(clusterArg) - if err != nil { - return NewTextResult(err.Error()), nil - } - - // Build command - command := spec.command(namespace, name) - clusterScope := cluster.ID - if clusterScope == "" { - clusterScope = clusterArg - } - approvalTargetID := fmt.Sprintf("%s:%s:%s:%s", clusterScope, namespace, spec.kindLabel, name) - - // Check if pre-approved (validated + single-use). - preApproved := consumeApprovalWithValidation(args, e.orgID, command, "kubernetes", approvalTargetID) - requiresApproval := !e.isAutonomous && e.controlLevel == ControlLevelControlled - - // Request approval if needed - if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled { - displayName := cluster.DisplayName - approvalID := e.createApprovalRecord(command, "kubernetes", approvalTargetID, displayName, spec.approvalDetail(name)) - return NewTextResult(formatKubernetesApprovalNeeded(spec.approvalAction, name, namespace, displayName, command, approvalID)), nil - } - - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } - - result, err := e.executeCommandWithAudit( - ctx, - "pulse_kubernetes", - approvalTargetID, - approvalID, - requiresApproval, - agentID, - agentexec.ExecuteCommandPayload{ - Command: command, - TargetType: "agent", - TargetID: "", - }, - "pulse_kubernetes", - spec.auditDetail(name), - ) - if err != nil { - return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil - } - - output := result.Stdout - if result.Stderr != "" { - output += "\n" + result.Stderr - } - - if result.ExitCode == 0 { - return NewTextResult(spec.successMessage(name, namespace, output)), nil - } - - return NewTextResult(fmt.Sprintf("kubectl command failed (exit code %d):\n%s", result.ExitCode, output)), nil -} - -// executeKubernetesExec executes a command inside a pod -func (e *PulseToolExecutor) executeKubernetesExec(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { - clusterArg, _ := args["cluster"].(string) - namespace, _ := args["namespace"].(string) - pod, _ := args["pod"].(string) - container, _ := args["container"].(string) - command, _ := args["command"].(string) - approvalID := agentcapabilities.ApprovalArgument(args) - - if clusterArg == "" { - return NewErrorResult(fmt.Errorf("cluster is required")), nil - } - if pod == "" { - return NewErrorResult(fmt.Errorf("pod is required")), nil - } - if command == "" { - return NewErrorResult(fmt.Errorf("command is required")), nil - } - if namespace == "" { - namespace = "default" - } - - // Validate identifiers - if err := validateKubernetesResourceID(namespace); err != nil { - return NewErrorResult(fmt.Errorf("invalid namespace: %w", err)), nil - } - if err := validateKubernetesResourceID(pod); err != nil { - return NewErrorResult(fmt.Errorf("invalid pod: %w", err)), nil - } - if container != "" { - if err := validateKubernetesResourceID(container); err != nil { - return NewErrorResult(fmt.Errorf("invalid container: %w", err)), nil - } - } - - // Check control level - if e.controlLevel == ControlLevelReadOnly { - return NewTextResult("Kubernetes control operations are not available in read-only mode."), nil - } - - agentID, cluster, err := e.findAgentForKubernetesCluster(clusterArg) - if err != nil { - return NewTextResult(err.Error()), nil - } - - // Build kubectl command safely to prevent shell metacharacter breakout on the host. - kubectlCmd := buildKubectlExecCommand(namespace, pod, container, command) - clusterScope := cluster.ID - if clusterScope == "" { - clusterScope = clusterArg - } - approvalTargetID := fmt.Sprintf("%s:%s:pod:%s", clusterScope, namespace, pod) - - // Check if pre-approved (validated + single-use). - preApproved := consumeApprovalWithValidation(args, e.orgID, kubectlCmd, "kubernetes", approvalTargetID) - requiresApproval := !e.isAutonomous && e.controlLevel == ControlLevelControlled - - // Request approval if needed - if !preApproved && !e.isAutonomous && e.controlLevel == ControlLevelControlled { - displayName := cluster.DisplayName - approvalID := e.createApprovalRecord(kubectlCmd, "kubernetes", approvalTargetID, displayName, fmt.Sprintf("Execute command in pod %s", pod)) - return NewTextResult(formatKubernetesApprovalNeeded("exec", pod, namespace, displayName, kubectlCmd, approvalID)), nil - } - - if e.agentServer == nil { - return NewErrorResult(fmt.Errorf("no agent server available")), nil - } - - result, err := e.executeCommandWithAudit( - ctx, - "pulse_kubernetes", - fmt.Sprintf("%s:%s:pod:%s", clusterScope, namespace, pod), - approvalID, - requiresApproval, - agentID, - agentexec.ExecuteCommandPayload{ - Command: kubectlCmd, - TargetType: "agent", - TargetID: "", - }, - "pulse_kubernetes", - fmt.Sprintf("execute command in pod %s", pod), - ) - if err != nil { - return NewErrorResult(fmt.Errorf("failed to execute kubectl: %w", err)), nil - } - - output := result.Stdout - if result.Stderr != "" { - output += "\n" + result.Stderr - } - - // Always show output explicitly to prevent LLM hallucination - if result.ExitCode == 0 { - if output == "" { - return NewTextResult(fmt.Sprintf("Command executed in pod '%s' (exit code 0).\n\nOutput:\n(no output)", pod)), nil - } - return NewTextResult(fmt.Sprintf("Command executed in pod '%s' (exit code 0).\n\nOutput:\n%s", pod, output)), nil - } - - if output == "" { - return NewTextResult(fmt.Sprintf("Command in pod '%s' exited with code %d.\n\nOutput:\n(no output)", pod, result.ExitCode)), nil - } - return NewTextResult(fmt.Sprintf("Command in pod '%s' exited with code %d.\n\nOutput:\n%s", pod, result.ExitCode, output)), nil -} - // executeKubernetesLogs retrieves pod logs func (e *PulseToolExecutor) executeKubernetesLogs(ctx context.Context, args map[string]interface{}) (CallToolResult, error) { clusterArg, _ := args["cluster"].(string) @@ -1010,18 +556,3 @@ func (e *PulseToolExecutor) executeKubernetesLogs(ctx context.Context, args map[ return NewTextResult(fmt.Sprintf("kubectl logs failed (exit code %d):\n%s", result.ExitCode, output)), nil } - -// formatKubernetesApprovalNeeded formats an approval-required response for Kubernetes operations -func formatKubernetesApprovalNeeded(action, resource, namespace, cluster, command, approvalID string) string { - payload := map[string]interface{}{ - "approval_id": approvalID, - "action": action, - "resource": resource, - "namespace": namespace, - "cluster": cluster, - "command": command, - "how_to_approve": "Click the approval button in the chat to execute this action.", - } - payload = enrichApprovalRequiredPayload(payload, approvalID) - return agentcapabilities.FormatApprovalRequiredToolMarker(payload) -} diff --git a/internal/ai/tools/tools_storage_test.go b/internal/ai/tools/tools_storage_test.go index 517c9a1a85..a06ad03714 100644 --- a/internal/ai/tools/tools_storage_test.go +++ b/internal/ai/tools/tools_storage_test.go @@ -3,7 +3,6 @@ package tools import ( "context" "encoding/json" - "strings" "testing" "time" @@ -458,28 +457,6 @@ func TestDockerUpdateTools(t *testing.T) { if checkResp.CommandID != "cmd1" || checkResp.TargetID != "host1" { t.Fatalf("unexpected check response: %+v", checkResp) } - - executor.controlLevel = ControlLevelAutonomous - result, _ = executor.executeUpdateDockerContainer(context.Background(), map[string]interface{}{ - "host": "Docker One", - "container": "c1", - }) - var updateResp DockerUpdateContainerResponse - if err := json.Unmarshal([]byte(result.Content[0].Text), &updateResp); err != nil { - t.Fatalf("decode update response: %v", err) - } - if updates.lastUpdateName != "nginx" || updateResp.CommandID != "cmd2" { - t.Fatalf("unexpected update response: %+v", updateResp) - } - - updates.enabled = false - result, _ = executor.executeUpdateDockerContainer(context.Background(), map[string]interface{}{ - "host": "Docker One", - "container": "c1", - }) - if !strings.Contains(result.Content[0].Text, "updates are disabled") { - t.Fatalf("unexpected disabled response: %s", result.Content[0].Text) - } } func TestDockerUpdatesResponseUsesCanonicalEmptyCollections(t *testing.T) { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 5b91d26210..9b570e2942 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -19162,12 +19162,10 @@ func TestContract_UpdateFunnelTelemetryStaysContentFree(t *testing.T) { } } -// TestContract_ExecutorPostCompletionCallback pins the seam the SSE -// bridge depends on. The executor must expose SetOnActionCompleted -// and the action_audit hot path must dispatch the callback for every -// terminal-state record (success, runtime fail, plan-drift refusal, -// remediation-lock refusal). Removing or in-lining the callback -// breaks the bridge silently. +// TestContract_ExecutorPostCompletionCallback pins the executor-side +// seam the API layer attaches the agent SSE bridge to. The Assistant +// executor no longer dispatches action audits itself (typed actions +// plan through Pulse Actions), so this pins only that the seam exists. func TestContract_ExecutorPostCompletionCallback(t *testing.T) { executor, err := os.ReadFile("../ai/tools/executor.go") if err != nil { @@ -19177,29 +19175,6 @@ func TestContract_ExecutorPostCompletionCallback(t *testing.T) { if !strings.Contains(executorSrc, "func (e *PulseToolExecutor) SetOnActionCompleted(cb func(unifiedresources.ActionAuditRecord))") { t.Error("executor must expose SetOnActionCompleted so the API layer can bridge action completion into the agent SSE stream") } - - audit, err := os.ReadFile("../ai/tools/action_audit.go") - if err != nil { - t.Fatalf("read action_audit.go: %v", err) - } - auditSrc := string(audit) - if !strings.Contains(auditSrc, "func (e *PulseToolExecutor) publishActionCompleted(record unifiedresources.ActionAuditRecord)") { - t.Error("action_audit.go must define publishActionCompleted helper so terminal-state writers route through one bridge point") - } - if !strings.Contains(auditSrc, "go cb(record)") { - t.Error("post-completion callback must run on its own goroutine to keep the dispatch hot path off any consumer's slowness") - } - // Pin the terminal persistence sites and the shared completion publisher. - terminalSites := []string{ - `persistFailedActionAudit(e.actionAuditStore, record, requestedBy, "plan drift refused")`, - `e.actionAuditStore.RecordActionExecutionResult(completed, event)`, - `persistFailedActionAudit(e.actionAuditStore, record, requestedBy, remediationLockLifecycleMessage(refusal))`, - } - for _, site := range terminalSites { - if !strings.Contains(auditSrc, site) { - t.Errorf("action_audit.go must call publishActionCompleted at every terminal-state site; missing block:\n%s", site) - } - } } // TestContract_AgentEventActionCompletedKindIsStable pins the @@ -22003,7 +21978,6 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T) `Name: agentcapabilities.PulseControlToolName`, `Name: agentcapabilities.PulseDiscoveryToolName`, `Name: agentcapabilities.PulseDockerToolName`, - `Name: agentcapabilities.PulseFileEditToolName`, `Name: agentcapabilities.PulseKubernetesToolName`, `Name: agentcapabilities.PulseKnowledgeToolName`, `Name: agentcapabilities.PulseMetricsToolName`, diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 55024997b0..41af9aa81d 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -322,7 +322,6 @@ func TestCanonicalActionPlanConstructionCannotBypassPolicyProvenancePlanner(t *t // Boundary-only compatibility conversions and retired command-shaped // audit history remain readable but are not canonical action producers. "../api/router_routes_ai_relay.go": true, - "../ai/tools/action_audit.go": true, // Graph-owned mock records are immutable presentation fixtures. They do // not admit, approve, or dispatch executable actions. "../mock/action_fixtures.go": true, @@ -2580,13 +2579,6 @@ func TestV6DirectHostAliasValidatorCoverage(t *testing.T) { `app_container is no longer supported; use app-container`, }, }, - { - path: filepath.Join(repoRoot, "internal", "ai", "tools", "tools_file_test.go"), - requiredSnippets: []string{ - `Legacy AppContainer Rejected`, - `app_container is no longer supported; use app-container`, - }, - }, { path: filepath.Join(repoRoot, "internal", "ai", "chat", "context_prefetch_additional_test.go"), requiredSnippets: []string{