From 5d53fa7a3d57108cc4268389eff73e0638259cc3 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:35:28 +0100 Subject: [PATCH] Remove the Assistant mutation handlers nothing calls pulse_control only plans typed resource actions into Pulse Actions (executeControlResource -> typedActionPlanner). The handlers that sat behind the retired model-authored command, file and container paths stayed in the tree with no production caller: run command, guest control, app-container control, Docker control and container update, Kubernetes scale/restart/delete-pod/exec, and file append/write. registerFileTools was never called either, so pulse_file_edit is not registered at all; calls to it are denied by the mutation registry before any tool lookup. A whole-program reachability run over cmd/... lists each of them as unreachable, and so is everything only they used: approval-record and preflight authoring, the command and native-action audit lanes with their remediation-lock gate and plan-hash helpers, the Docker host resolvers, and the file write verification. The executor's autonomous flag, whose only readers were those handlers, goes with them (the executor's SetAutonomousMode and the flag argument of SetContext). Delete them with their tests and the constants, types and test helpers they stranded. The contract prose that described the removed broker now says that plan freshness and the operator remediation lock are enforced by internal/actionlifecycle. Two source pins that named deleted code go (the publishActionCompleted dispatch sites and the pulse_file_edit declaration), the standards tests drop their expectations of an ActionPlan construction in action_audit.go and of the legacy app_container rejection in the deleted file-edit test, and the RA35 evidence entry for the deleted run-command test goes with them. Left alone on purpose: the saved autonomous control level and its entitlement and approval-admission checks, which stay as legacy compatibility, Service.SetAutonomousMode and the chat clamp, SetOnActionCompleted and its router wiring (no dispatcher is left, the contract prose says so), and the protected-guests plumbing. Rebuilt on current main: the first version had 213 commits of drift. Two conflicts, both on code this change deletes. tools_control.go takes the deletion over the Protected guests cleanup that landed inside executeRunCommand. action_audit_remediation_lock_test.go goes with the audit lanes, including the test added for the dispatch gate's read through a succession; that gate is deleted here, and TestLockMovedBySuccessionStillRefusesTheIDThePreviousGenerationLists in internal/actionlifecycle covers the same refusal on the live path. --- docs/release-control/v6/internal/status.json | 7 +- .../v6/internal/subsystems/ai-runtime.md | 150 +- .../v6/internal/subsystems/api-contracts.md | 28 +- .../internal/subsystems/unified-resources.md | 33 +- internal/ai/chat/service.go | 5 - .../chat/service_execute_additional_test.go | 2 +- internal/ai/tools/action_audit.go | 915 ---------- .../ai/tools/action_audit_execution_test.go | 1390 +------------- .../action_audit_remediation_lock_test.go | 405 ---- ...aitools_control_pure_branchcov0716_test.go | 178 -- internal/ai/tools/approval_preflight_test.go | 180 -- .../ai/tools/control_docker_helpers_test.go | 157 -- internal/ai/tools/control_helpers_test.go | 200 -- internal/ai/tools/control_run_command_test.go | 505 ----- internal/ai/tools/data_types.go | 12 - .../data_types_branchcov0719late_test.go | 26 - internal/ai/tools/docker_updates_test.go | 184 -- internal/ai/tools/execution_profile.go | 6 +- internal/ai/tools/executor.go | 37 +- internal/ai/tools/executor_clone_test.go | 12 +- internal/ai/tools/executor_setters_test.go | 8 +- internal/ai/tools/file_docker_test.go | 389 +--- .../ai/tools/infrastructure_helpers_test.go | 31 - internal/ai/tools/invocation_policy_test.go | 23 +- internal/ai/tools/kubernetes_control_test.go | 567 ------ .../tools/purehelpers_branchcov0722am_test.go | 40 - internal/ai/tools/shared_test.go | 9 - internal/ai/tools/strict_resolution_test.go | 129 -- internal/ai/tools/tools_control.go | 1625 ----------------- internal/ai/tools/tools_docker.go | 426 +---- internal/ai/tools/tools_file.go | 491 ----- internal/ai/tools/tools_file_test.go | 137 -- internal/ai/tools/tools_kubernetes.go | 469 ----- internal/ai/tools/tools_storage_test.go | 23 - internal/api/contract_test.go | 34 +- .../unifiedresources/code_standards_test.go | 8 - 36 files changed, 138 insertions(+), 8703 deletions(-) delete mode 100644 internal/ai/tools/action_audit_remediation_lock_test.go delete mode 100644 internal/ai/tools/aitools_control_pure_branchcov0716_test.go delete mode 100644 internal/ai/tools/approval_preflight_test.go delete mode 100644 internal/ai/tools/control_docker_helpers_test.go delete mode 100644 internal/ai/tools/control_helpers_test.go delete mode 100644 internal/ai/tools/control_run_command_test.go delete mode 100644 internal/ai/tools/infrastructure_helpers_test.go 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 7494b1203c..e8e030d736 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 592d95431a..d9830f0586 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 df67f9edb2..e5e74a2391 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -19161,12 +19161,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 { @@ -19176,29 +19174,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 @@ -22002,7 +21977,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 8b911ca2a2..ee43aa0841 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, @@ -2579,13 +2578,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{