mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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, ""))
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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(""))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user