Reconcile upstream unused Assistant handler removal

Preserve all reviewed local maintenance commits while incorporating the current published main. Upstream removes unused Assistant handlers; local updater, guest admission and help changes remain additive.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-11 13:03:10 +01:00
36 changed files with 138 additions and 8703 deletions
+1 -6
View File
@@ -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",
@@ -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 <unit>` 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
@@ -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
@@ -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`.
-5
View File
@@ -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)
}
@@ -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{
-915
View File
@@ -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"
}
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
@@ -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)
}
})
}
}
@@ -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)
}
}
@@ -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))
})
}
-200
View File
@@ -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
}
@@ -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, ""))
})
}
-12
View File
@@ -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
@@ -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.
-184
View File
@@ -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)
}
+2 -4
View File
@@ -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:
+12 -25
View File
@@ -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
+6 -6
View File
@@ -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)
}
}
+1 -7
View File
@@ -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)
+2 -387
View File
@@ -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)
@@ -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(""))
}
+5 -18
View File
@@ -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")
@@ -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")
}
@@ -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;
-9
View File
@@ -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
-129
View File
@@ -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) {
File diff suppressed because it is too large Load Diff
+1 -425
View File
@@ -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) {
-491
View File
@@ -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)
}
-137
View File
@@ -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"])
}
-469
View File
@@ -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)
}
-23
View File
@@ -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) {
+4 -30
View File
@@ -19162,12 +19162,10 @@ func TestContract_UpdateFunnelTelemetryStaysContentFree(t *testing.T) {
}
}
// TestContract_ExecutorPostCompletionCallback pins the seam the SSE
// bridge depends on. The executor must expose SetOnActionCompleted
// and the action_audit hot path must dispatch the callback for every
// terminal-state record (success, runtime fail, plan-drift refusal,
// remediation-lock refusal). Removing or in-lining the callback
// breaks the bridge silently.
// TestContract_ExecutorPostCompletionCallback pins the executor-side
// seam the API layer attaches the agent SSE bridge to. The Assistant
// executor no longer dispatches action audits itself (typed actions
// plan through Pulse Actions), so this pins only that the seam exists.
func TestContract_ExecutorPostCompletionCallback(t *testing.T) {
executor, err := os.ReadFile("../ai/tools/executor.go")
if err != nil {
@@ -19177,29 +19175,6 @@ func TestContract_ExecutorPostCompletionCallback(t *testing.T) {
if !strings.Contains(executorSrc, "func (e *PulseToolExecutor) SetOnActionCompleted(cb func(unifiedresources.ActionAuditRecord))") {
t.Error("executor must expose SetOnActionCompleted so the API layer can bridge action completion into the agent SSE stream")
}
audit, err := os.ReadFile("../ai/tools/action_audit.go")
if err != nil {
t.Fatalf("read action_audit.go: %v", err)
}
auditSrc := string(audit)
if !strings.Contains(auditSrc, "func (e *PulseToolExecutor) publishActionCompleted(record unifiedresources.ActionAuditRecord)") {
t.Error("action_audit.go must define publishActionCompleted helper so terminal-state writers route through one bridge point")
}
if !strings.Contains(auditSrc, "go cb(record)") {
t.Error("post-completion callback must run on its own goroutine to keep the dispatch hot path off any consumer's slowness")
}
// Pin the terminal persistence sites and the shared completion publisher.
terminalSites := []string{
`persistFailedActionAudit(e.actionAuditStore, record, requestedBy, "plan drift refused")`,
`e.actionAuditStore.RecordActionExecutionResult(completed, event)`,
`persistFailedActionAudit(e.actionAuditStore, record, requestedBy, remediationLockLifecycleMessage(refusal))`,
}
for _, site := range terminalSites {
if !strings.Contains(auditSrc, site) {
t.Errorf("action_audit.go must call publishActionCompleted at every terminal-state site; missing block:\n%s", site)
}
}
}
// TestContract_AgentEventActionCompletedKindIsStable pins the
@@ -22003,7 +21978,6 @@ func TestContract_PulseMCPAdapterProjectsAgentCapabilitiesManifest(t *testing.T)
`Name: agentcapabilities.PulseControlToolName`,
`Name: agentcapabilities.PulseDiscoveryToolName`,
`Name: agentcapabilities.PulseDockerToolName`,
`Name: agentcapabilities.PulseFileEditToolName`,
`Name: agentcapabilities.PulseKubernetesToolName`,
`Name: agentcapabilities.PulseKnowledgeToolName`,
`Name: agentcapabilities.PulseMetricsToolName`,
@@ -322,7 +322,6 @@ func TestCanonicalActionPlanConstructionCannotBypassPolicyProvenancePlanner(t *t
// Boundary-only compatibility conversions and retired command-shaped
// audit history remain readable but are not canonical action producers.
"../api/router_routes_ai_relay.go": true,
"../ai/tools/action_audit.go": true,
// Graph-owned mock records are immutable presentation fixtures. They do
// not admit, approve, or dispatch executable actions.
"../mock/action_fixtures.go": true,
@@ -2580,13 +2579,6 @@ func TestV6DirectHostAliasValidatorCoverage(t *testing.T) {
`app_container is no longer supported; use app-container`,
},
},
{
path: filepath.Join(repoRoot, "internal", "ai", "tools", "tools_file_test.go"),
requiredSnippets: []string{
`Legacy AppContainer Rejected`,
`app_container is no longer supported; use app-container`,
},
},
{
path: filepath.Join(repoRoot, "internal", "ai", "chat", "context_prefetch_additional_test.go"),
requiredSnippets: []string{