mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 10:52:08 +00:00
1059 lines
36 KiB
Go
1059 lines
36 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"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"
|
|
)
|
|
|
|
const approvalAuditActor = approval.RequesterPulseAssistant
|
|
|
|
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)
|
|
|
|
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()),
|
|
}
|
|
e.recordActionAudit(record)
|
|
e.recordActionLifecycle(record.ID, unifiedresources.ActionStateFailed, requestedBy, "plan drift refused")
|
|
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." Persists a
|
|
// Failed audit record with `resource_remediation_locked:` prefix
|
|
// so the audit history shows every refused dispatch.
|
|
if locked, lockErr := e.isResourceRemediationLocked(resourceID); lockErr != nil {
|
|
log.Warn().Str("action_id", plan.ActionID).Err(lockErr).Msg("Operator-state lookup failed; allowing dispatch (fail-open) but logging")
|
|
} else if locked {
|
|
log.Warn().
|
|
Str("action_id", plan.ActionID).
|
|
Str("resource_id", resourceID).
|
|
Str("capability", capabilityName).
|
|
Msg("Refusing action execution: resource is operator-locked against automated remediation")
|
|
now := time.Now().UTC()
|
|
record.State = unifiedresources.ActionStateFailed
|
|
record.UpdatedAt = now
|
|
errMessage := fmt.Sprintf("resource_remediation_locked: %s", unifiedresources.ErrResourceRemediationLocked.Error())
|
|
record.Result = &unifiedresources.ExecutionResult{
|
|
Success: false,
|
|
ErrorMessage: errMessage,
|
|
}
|
|
e.recordActionAudit(record)
|
|
e.recordActionLifecycle(record.ID, unifiedresources.ActionStateFailed, requestedBy, "resource remediation lock refused")
|
|
e.publishActionCompleted(record)
|
|
return nil, unifiedresources.ErrResourceRemediationLocked
|
|
}
|
|
|
|
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)
|
|
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)
|
|
|
|
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
|
|
|
|
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 {
|
|
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 {
|
|
if err := e.actionAuditStore.RecordActionAudit(record); 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
|
|
}
|
|
event := unifiedresources.ActionLifecycleEvent{
|
|
ActionID: record.ID,
|
|
Timestamp: now,
|
|
State: unifiedresources.ActionStatePlanned,
|
|
Actor: actor,
|
|
Message: strings.TrimSpace(record.Request.Reason),
|
|
}
|
|
if err := e.actionAuditStore.RecordActionLifecycleEvent(event); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("action_id", record.ID).
|
|
Str("state", string(unifiedresources.ActionStatePlanned)).
|
|
Msg("failed to persist planned action lifecycle event")
|
|
return record, err
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if err := e.actionAuditStore.RecordActionAudit(failed); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("action_id", failed.ID).
|
|
Str("state", string(failed.State)).
|
|
Msg("failed to persist action execution refusal")
|
|
return record, err
|
|
}
|
|
if err := e.actionAuditStore.RecordActionLifecycleEvent(event); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("action_id", failed.ID).
|
|
Str("state", string(failed.State)).
|
|
Msg("failed to persist action execution refusal lifecycle event")
|
|
return record, err
|
|
}
|
|
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 {
|
|
if err := e.actionAuditStore.RecordActionAudit(record); err != nil {
|
|
log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist approved action audit before execution")
|
|
return record, err
|
|
}
|
|
return record, nil
|
|
}
|
|
if current.State != unifiedresources.ActionStatePending {
|
|
return current, nil
|
|
}
|
|
|
|
approvalRecord := actionApprovalRecordForExecution(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(approvalID, actor string, now time.Time, fallbackOutcome unifiedresources.ApprovalOutcome) unifiedresources.ActionApprovalRecord {
|
|
if fallbackOutcome == "" {
|
|
fallbackOutcome = unifiedresources.OutcomeApproved
|
|
}
|
|
records := approvalRecordsForID(approvalID)
|
|
if len(records) > 0 {
|
|
record := records[len(records)-1]
|
|
if record.Outcome == "" {
|
|
record.Outcome = fallbackOutcome
|
|
}
|
|
if record.Timestamp.IsZero() {
|
|
record.Timestamp = now
|
|
}
|
|
if strings.TrimSpace(record.Actor) == "" {
|
|
record.Actor = actor
|
|
}
|
|
return record
|
|
}
|
|
return unifiedresources.ActionApprovalRecord{
|
|
Actor: actor,
|
|
Method: unifiedresources.MethodAPI,
|
|
Timestamp: now,
|
|
Outcome: fallbackOutcome,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
e.recordActionAudit(record)
|
|
e.recordActionLifecycle(record.ID, record.State, actor, message)
|
|
e.publishActionCompleted(record)
|
|
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
|
|
}
|
|
|
|
func (e *PulseToolExecutor) recordActionAudit(record unifiedresources.ActionAuditRecord) {
|
|
if e == nil || e.actionAuditStore == nil {
|
|
return
|
|
}
|
|
if err := e.actionAuditStore.RecordActionAudit(record); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("action_id", record.ID).
|
|
Str("resource_id", record.Request.ResourceID).
|
|
Msg("failed to persist action audit")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
func (e *PulseToolExecutor) recordActionLifecycle(actionID string, state unifiedresources.ActionState, actor, message string) {
|
|
if e == nil || e.actionAuditStore == nil || strings.TrimSpace(actionID) == "" {
|
|
return
|
|
}
|
|
event := unifiedresources.ActionLifecycleEvent{
|
|
ActionID: actionID,
|
|
Timestamp: time.Now().UTC(),
|
|
State: state,
|
|
Actor: actor,
|
|
Message: message,
|
|
}
|
|
if err := e.actionAuditStore.RecordActionLifecycleEvent(event); err != nil {
|
|
log.Warn().
|
|
Err(err).
|
|
Str("action_id", actionID).
|
|
Str("state", string(state)).
|
|
Msg("failed to persist action lifecycle event")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
req := approvalRequestForID(approvalID)
|
|
if e == nil || req == nil || req.Plan == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(actor) == "" {
|
|
actor = approvalDecisionActor(req, approvalAuditActor)
|
|
}
|
|
if strings.TrimSpace(message) == "" {
|
|
message = string(state)
|
|
}
|
|
record := actionAuditRecordFromApproval(req, state, actor)
|
|
record.Approvals = approvalRecordsForID(req.ID)
|
|
if e.recordApprovalDecisionAtomically(req.ID, record, actor) {
|
|
return
|
|
}
|
|
e.recordActionAudit(record)
|
|
e.recordActionLifecycle(req.Plan.ActionID, state, actor, message)
|
|
}
|
|
|
|
func (e *PulseToolExecutor) recordApprovalDecisionAtomically(approvalID string, record unifiedresources.ActionAuditRecord, actor string) bool {
|
|
if e == nil || e.actionAuditStore == nil {
|
|
return false
|
|
}
|
|
if record.State != unifiedresources.ActionStateApproved && record.State != unifiedresources.ActionStateRejected {
|
|
return false
|
|
}
|
|
|
|
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 approval decision")
|
|
return false
|
|
}
|
|
if !ok {
|
|
return false
|
|
}
|
|
if current.State != unifiedresources.ActionStatePending {
|
|
if current.State != record.State {
|
|
log.Warn().
|
|
Str("action_id", record.ID).
|
|
Str("current_state", string(current.State)).
|
|
Str("decision_state", string(record.State)).
|
|
Msg("ignoring approval decision for action audit that has already moved past pending")
|
|
}
|
|
return true
|
|
}
|
|
|
|
outcome := unifiedresources.OutcomeApproved
|
|
if record.State == unifiedresources.ActionStateRejected {
|
|
outcome = unifiedresources.OutcomeRejected
|
|
}
|
|
approvalRecord := actionApprovalRecordForExecution(approvalID, actor, time.Now().UTC(), outcome)
|
|
approvalRecord.Outcome = outcome
|
|
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")
|
|
return false
|
|
}
|
|
if err := e.actionAuditStore.RecordActionDecision(updated, event); err != nil {
|
|
log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist approval decision")
|
|
return false
|
|
}
|
|
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)
|
|
if err := store.RecordActionAudit(record); err != nil {
|
|
log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to persist pending approval action")
|
|
return
|
|
}
|
|
recordApprovalLifecycle(store, req.Plan.ActionID, unifiedresources.ActionStatePlanned, actor, strings.TrimSpace(req.Context))
|
|
recordApprovalLifecycle(store, req.Plan.ActionID, unifiedresources.ActionStatePending, actor, "waiting for approval")
|
|
}
|
|
|
|
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, actor string) unifiedresources.ActionAuditRecord {
|
|
now := time.Now().UTC()
|
|
plan := *req.Plan
|
|
createdAt := plan.PlannedAt
|
|
if createdAt.IsZero() {
|
|
createdAt = now
|
|
}
|
|
requestID := strings.TrimSpace(plan.RequestID)
|
|
if requestID == "" {
|
|
requestID = strings.TrimSpace(req.ID)
|
|
}
|
|
params := map[string]any{
|
|
"command": req.Command,
|
|
"targetType": req.TargetType,
|
|
"targetId": req.TargetID,
|
|
"targetName": req.TargetName,
|
|
"approvalId": req.ID,
|
|
}
|
|
if orgID := strings.TrimSpace(req.OrgID); orgID != "" {
|
|
params["orgId"] = orgID
|
|
}
|
|
return unifiedresources.ActionAuditRecord{
|
|
ID: plan.ActionID,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: now,
|
|
State: state,
|
|
Request: unifiedresources.ActionRequest{
|
|
RequestID: requestID,
|
|
ResourceID: approvalAuditResourceID(req.TargetType, req.TargetID, req.TargetName),
|
|
CapabilityName: approvalCapabilityForTargetType(req.TargetType),
|
|
Params: params,
|
|
Reason: strings.TrimSpace(req.Context),
|
|
RequestedBy: actor,
|
|
},
|
|
Plan: plan,
|
|
}
|
|
}
|
|
|
|
func approvalRequestForID(approvalID string) *approval.ApprovalRequest {
|
|
approvalID = strings.TrimSpace(approvalID)
|
|
if approvalID == "" {
|
|
return nil
|
|
}
|
|
store := approval.GetStore()
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
req, ok := store.GetApproval(approvalID)
|
|
if !ok || req == nil {
|
|
return nil
|
|
}
|
|
return req
|
|
}
|
|
|
|
func approvalDecisionActor(req *approval.ApprovalRequest, fallback string) string {
|
|
if req != nil {
|
|
if actor := strings.TrimSpace(req.DecidedBy); actor != "" {
|
|
return actor
|
|
}
|
|
}
|
|
if actor := strings.TrimSpace(fallback); actor != "" {
|
|
return actor
|
|
}
|
|
return approvalAuditActor
|
|
}
|
|
|
|
func approvalCapabilityForTargetType(targetType string) string {
|
|
switch strings.ToLower(strings.TrimSpace(targetType)) {
|
|
case "docker":
|
|
return "pulse_docker"
|
|
case "file":
|
|
return "pulse_file_edit"
|
|
case "kubernetes":
|
|
return "pulse_kubernetes"
|
|
default:
|
|
return "pulse_control"
|
|
}
|
|
}
|
|
|
|
func approvalAuditResourceID(targetType, targetID, targetName string) string {
|
|
targetType = strings.TrimSpace(targetType)
|
|
targetID = strings.TrimSpace(targetID)
|
|
if targetID == "" {
|
|
targetID = strings.TrimSpace(targetName)
|
|
}
|
|
if targetID == "" {
|
|
return strings.TrimSpace(targetType)
|
|
}
|
|
if targetType == "" || strings.Contains(targetID, ":") {
|
|
return targetID
|
|
}
|
|
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) []unifiedresources.ActionApprovalRecord {
|
|
approvalID = strings.TrimSpace(approvalID)
|
|
if approvalID == "" {
|
|
return nil
|
|
}
|
|
store := approval.GetStore()
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
req, ok := store.GetApproval(approvalID)
|
|
if !ok || req == nil {
|
|
return nil
|
|
}
|
|
|
|
record := unifiedresources.ActionApprovalRecord{
|
|
Actor: strings.TrimSpace(req.DecidedBy),
|
|
Method: unifiedresources.MethodAPI,
|
|
Timestamp: approvalTimestamp(req),
|
|
Outcome: unifiedresources.OutcomeApproved,
|
|
Reason: strings.TrimSpace(req.Context),
|
|
}
|
|
if req.Status == approval.StatusDenied {
|
|
record.Outcome = unifiedresources.OutcomeRejected
|
|
}
|
|
return []unifiedresources.ActionApprovalRecord{record}
|
|
}
|
|
|
|
func approvalTimestamp(req *approval.ApprovalRequest) time.Time {
|
|
if req == nil || req.DecidedAt == nil {
|
|
return time.Now().UTC()
|
|
}
|
|
return req.DecidedAt.UTC()
|
|
}
|
|
|
|
// isResourceRemediationLocked returns whether the operator has set
|
|
// NeverAutoRemediate=true on the resource the dispatch targets.
|
|
// Returns (false, nil) when no audit store is wired (degraded mode —
|
|
// the fail-open posture is consistent with the rest of audit-store
|
|
// callers in this file), when the resource has no operator state, or
|
|
// when only IntentionallyOffline (without NeverAutoRemediate) is set.
|
|
// Returns the error from the store on lookup failure so the caller can
|
|
// log it; caller policy is fail-open on lookup error.
|
|
func (e *PulseToolExecutor) isResourceRemediationLocked(resourceID string) (bool, error) {
|
|
if e == nil || e.actionAuditStore == nil {
|
|
return false, nil
|
|
}
|
|
canonical := strings.TrimSpace(resourceID)
|
|
if canonical == "" {
|
|
return false, nil
|
|
}
|
|
state, found, err := e.actionAuditStore.GetResourceOperatorState(canonical)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !found {
|
|
return false, nil
|
|
}
|
|
return state.NeverAutoRemediate, nil
|
|
}
|