diff --git a/README.md b/README.md index 1271a0a..58e61af 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ AgentSSH uses standard SSH from the local machine (its built-in Go SSH client by ```bash # 1. Install — static binary, no Go required (see "Install" for macOS / arm64). -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.9.1/agentssh_v0.9.1_linux_amd64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.9.1_linux_amd64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.10.0/agentssh_v0.10.0_linux_amd64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.10.0_linux_amd64/agentssh # 2. Open the console — this is your main entry point: agentssh tui @@ -75,23 +75,23 @@ Static binaries (`CGO_ENABLED=0`, no runtime deps). Pick your platform; each is ```bash # Linux x86_64 -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.9.1/agentssh_v0.9.1_linux_amd64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.9.1_linux_amd64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.10.0/agentssh_v0.10.0_linux_amd64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.10.0_linux_amd64/agentssh # Linux arm64 -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.9.1/agentssh_v0.9.1_linux_arm64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.9.1_linux_arm64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.10.0/agentssh_v0.10.0_linux_arm64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.10.0_linux_arm64/agentssh # macOS Apple Silicon (arm64) -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.9.1/agentssh_v0.9.1_darwin_arm64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.9.1_darwin_arm64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.10.0/agentssh_v0.10.0_darwin_arm64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.10.0_darwin_arm64/agentssh # macOS Intel (amd64) -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.9.1/agentssh_v0.9.1_darwin_amd64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.9.1_darwin_amd64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.10.0/agentssh_v0.10.0_darwin_amd64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.10.0_darwin_amd64/agentssh ``` -Verify: `agentssh --version`. (Bump `v0.9.1` for a different release; checksums are in `SHA256SUMS.txt` on the Releases page.) +Verify: `agentssh --version`. (Bump `v0.10.0` for a different release; checksums are in `SHA256SUMS.txt` on the Releases page.) ### From source (needs Go matching the go.mod directive) diff --git a/cmd/agentssh/main.go b/cmd/agentssh/main.go index 1d0cb1d..b2ae1d6 100644 --- a/cmd/agentssh/main.go +++ b/cmd/agentssh/main.go @@ -3,16 +3,22 @@ package main import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" + "os/signal" "path/filepath" + "reflect" "sort" "strconv" "strings" + "syscall" "time" + "unicode/utf8" "filippo.io/age" "github.com/Praeviso/AgentSSH/internal/approval" @@ -151,6 +157,7 @@ func newRootCommand() *cobra.Command { newSecretCommand(), newPolicyCommand(), newApprovalCommand(), + newPlanCommand(), newAuditCommand(), newSessionCommand(), ) @@ -179,7 +186,7 @@ func newHostsCommand() *cobra.Command { func newRunCommand() *cobra.Command { var flags runFlags cmd := &cobra.Command{ - Use: "run [--session ] [--session-label ] [--json] -- ", + Use: "run [--session ] [--session-label ] [--stdin-file ] [--json] -- ", Short: "Run a policy-checked command on a configured host or group.", Args: func(cmd *cobra.Command, args []string) error { if cmd.ArgsLenAtDash() != 1 { @@ -193,12 +200,20 @@ func newRunCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { target := args[0] remoteCommand := strings.Join(args[1:], " ") + if _, err := parseRunFields(flags.fields); err != nil { + return err + } + if flags.fields != "" { + flags.jsonOutput = true + } return runDirect(cmd, target, remoteCommand, flags) }, } cmd.Flags().StringVar(&flags.session, "session", "", "associate the run with a session id") cmd.Flags().StringVar(&flags.sessionLabel, "session-label", "", "attach a human-readable label to the session") cmd.Flags().BoolVar(&flags.jsonOutput, "json", false, "emit machine-readable JSON") + cmd.Flags().StringVar(&flags.fields, "fields", "", "comma-separated JSON fields to emit (implies --json), e.g. req_id,status,exit_code,stdout") + cmd.Flags().StringVar(&flags.stdinFile, "stdin-file", "", "local file streamed to the remote command's stdin (audited by sha256+size; approvals bind to the exact content)") return cmd } @@ -939,6 +954,66 @@ type runFlags struct { session string sessionLabel string jsonOutput bool + fields string + stdinFile string +} + +// maxStdinBytes caps --stdin-file payloads. Stdin exists for configuration +// files and small artifacts, not bulk transfer; the cap protects memory and +// keeps a single approval reviewable. +const maxStdinBytes = 32 << 20 + +// stdinSpec is the loaded stdin payload plus the identity (hash + size) that +// flows into policy grants, the audit log, and run responses. +type stdinSpec struct { + data []byte + sha256 string + bytes int64 +} + +func loadStdinSpec(path string) (stdinSpec, error) { + if strings.TrimSpace(path) == "" { + return stdinSpec{}, nil + } + info, err := os.Stat(path) + if err != nil { + return stdinSpec{}, newUsageError("cannot read --stdin-file: %v", err) + } + // Require a regular file. A device (/dev/zero), FIFO, or socket reports size + // 0 from Stat yet streams unbounded bytes, so reading it whole would hang or + // exhaust memory before any size check — reject it up front. + if !info.Mode().IsRegular() { + return stdinSpec{}, newUsageError("--stdin-file %s is not a regular file", path) + } + if info.Size() > maxStdinBytes { + return stdinSpec{}, newUsageError("--stdin-file %s is %d bytes; the limit is %d bytes (32 MiB)", path, info.Size(), int64(maxStdinBytes)) + } + file, err := os.Open(path) + if err != nil { + return stdinSpec{}, newUsageError("cannot read --stdin-file: %v", err) + } + defer func() { _ = file.Close() }() + // Enforce the cap during the read (LimitReader to cap+1) so a file that grew + // between Stat and Open, or a lying size, still cannot exceed the limit. + data, err := io.ReadAll(io.LimitReader(file, maxStdinBytes+1)) + if err != nil { + return stdinSpec{}, newUsageError("cannot read --stdin-file: %v", err) + } + if len(data) > maxStdinBytes { + return stdinSpec{}, newUsageError("--stdin-file %s exceeds the limit of %d bytes (32 MiB)", path, int64(maxStdinBytes)) + } + sum := sha256.Sum256(data) + return stdinSpec{ + data: data, + sha256: hex.EncodeToString(sum[:]), + bytes: int64(len(data)), + }, nil +} + +func stampStdin(record audit.Record, stdin stdinSpec) audit.Record { + record.StdinSHA256 = stdin.sha256 + record.StdinBytes = stdin.bytes + return record } type runResponse struct { @@ -946,6 +1021,10 @@ type runResponse struct { SessionID string `json:"session_id"` Host string `json:"host"` Cmd string `json:"cmd,omitempty"` + CmdSHA256 string `json:"cmd_sha256,omitempty"` + CmdTruncated bool `json:"cmd_truncated,omitempty"` + StdinSHA256 string `json:"stdin_sha256,omitempty"` + StdinBytes int64 `json:"stdin_bytes,omitempty"` Status string `json:"status"` ExitCode int `json:"exit_code"` DurationMS int64 `json:"duration_ms"` @@ -960,6 +1039,125 @@ type runResponse struct { ProposedScopes []string `json:"proposed_scope,omitempty"` } +// cmdEchoMaxBytes caps the command echoed back in run JSON responses. The full +// command stays in the audit log; callers that need to correlate use cmd_sha256. +const cmdEchoMaxBytes = 2048 + +// finalizeRunResponses stamps cmd_sha256 (of the full command), truncates +// oversized cmd echoes so a large command payload is not mirrored back to the +// caller verbatim, and stamps the stdin identity on every response so all +// status branches carry the same fields. +func finalizeRunResponses(responses []runResponse, stdin stdinSpec) { + for i := range responses { + responses[i].StdinSHA256 = stdin.sha256 + responses[i].StdinBytes = stdin.bytes + if responses[i].Cmd == "" { + continue + } + sum := sha256.Sum256([]byte(responses[i].Cmd)) + responses[i].CmdSHA256 = hex.EncodeToString(sum[:]) + if len(responses[i].Cmd) > cmdEchoMaxBytes { + cut := cmdEchoMaxBytes + for cut > 0 && !utf8.RuneStart(responses[i].Cmd[cut]) { + cut-- + } + responses[i].Cmd = responses[i].Cmd[:cut] + responses[i].CmdTruncated = true + } + } +} + +func runResponseFieldNames() map[string]struct{} { + names := map[string]struct{}{} + t := reflect.TypeOf(runResponse{}) + for i := 0; i < t.NumField(); i++ { + tag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0] + if tag != "" && tag != "-" { + names[tag] = struct{}{} + } + } + return names +} + +func parseRunFields(spec string) ([]string, error) { + if strings.TrimSpace(spec) == "" { + return nil, nil + } + known := runResponseFieldNames() + var fields []string + for _, raw := range strings.Split(spec, ",") { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + if _, ok := known[name]; !ok { + options := make([]string, 0, len(known)) + for key := range known { + options = append(options, key) + } + sort.Strings(options) + return nil, newUsageError("unknown --fields name %q; known fields: %s", name, strings.Join(options, ", ")) + } + fields = append(fields, name) + } + if len(fields) == 0 { + return nil, newUsageError("--fields requires at least one field name") + } + return fields, nil +} + +// selectRunFields projects a response onto the requested JSON keys. Fields that +// are omitempty and empty simply stay absent. +func selectRunFields(response runResponse, fields []string) (map[string]any, error) { + raw, err := json.Marshal(response) + if err != nil { + return nil, err + } + var full map[string]any + if err := json.Unmarshal(raw, &full); err != nil { + return nil, err + } + out := make(map[string]any, len(fields)) + for _, name := range fields { + if value, ok := full[name]; ok { + out[name] = value + } + } + return out, nil +} + +// writeRunResponses is the single JSON output path for run: it finalizes the +// cmd echo and applies the optional --fields projection. +func writeRunResponses(cmd *cobra.Command, resolved inventory.ResolvedTarget, responses []runResponse, flags runFlags, stdin stdinSpec) error { + finalizeRunResponses(responses, stdin) + fields, err := parseRunFields(flags.fields) + if err != nil { + return err + } + if fields == nil { + if resolved.Kind == inventory.TargetKindHost { + return writeJSON(cmd, responses[0]) + } + return writeJSON(cmd, responses) + } + if resolved.Kind == inventory.TargetKindHost { + selected, err := selectRunFields(responses[0], fields) + if err != nil { + return err + } + return writeJSON(cmd, selected) + } + selected := make([]map[string]any, 0, len(responses)) + for _, response := range responses { + row, err := selectRunFields(response, fields) + if err != nil { + return err + } + selected = append(selected, row) + } + return writeJSON(cmd, selected) +} + type runPlan struct { Target inventory.Target ReqID string @@ -2069,31 +2267,45 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag } store := audit.NewStore(cfg.Paths.AuditFile) sessionStore := approval.SessionStore{Dir: cfg.Paths.SessionsDir} - pendingStore := approval.PendingStore{PendingDir: cfg.Paths.PendingDir, ResponsesDir: cfg.Paths.ResponsesDir} + pendingStore := approvalStore(cfg.Paths) + stdin, err := loadStdinSpec(flags.stdinFile) + if err != nil { + return err + } ssh := newExecutor(cfg) defer func() { _ = ssh.Close() }() - plans, err := buildRunPlans(cfg, resolved, remoteCommand, flags, runtime, sessionStore, runtime.Enabled) + plans, err := buildRunPlans(cfg, resolved, remoteCommand, flags, runtime, sessionStore, runtime.Enabled, stdin) if err != nil { return err } if runtime.Enabled && anyPlanNeedsApproval(plans) { - return handleApprovalPreflightBlock(cmd, pendingStore, store, plans, remoteCommand, flags, resolved) + return handleApprovalPreflightBlock(cmd, pendingStore, store, plans, remoteCommand, flags, resolved, stdin) } + // Intercept SIGINT/SIGTERM so a locally cancelled run can settle its + // once-grant claim (release, not consume) and audit the failed attempt. + // A second signal restores default delivery and kills the process. + ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() + go func() { + <-ctx.Done() + stopSignals() + }() + exitCode := exitOK responses := make([]runResponse, 0, len(resolved.Targets)) for _, plan := range plans { target := plan.Target sessionCtx := plan.SessionCtx reqID := plan.ReqID - auth, err := approval.Authorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, remoteCommand) + auth, err := approval.Authorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, remoteCommand, stdin.sha256, reqID) if err != nil { return newUsageError("policy.yaml is invalid: %v\n fix the rule in ~/.agentssh/policy.yaml, then re-run (check: agentssh policy show)", err) } plan.Auth = auth switch auth.Status { case approval.AuthHardDeny: - response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied) + response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied, stdin) if err != nil { return err } @@ -2104,7 +2316,7 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag continue case approval.AuthNeedsApproval: if !runtime.Enabled { - response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied) + response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied, stdin) if err != nil { return err } @@ -2114,7 +2326,7 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag exitCode = mergeExitCode(exitCode, exitPolicyDenied) continue } - response, err := appendApprovalPending(cmd, pendingStore, store, plan, remoteCommand, flags) + response, err := appendApprovalPending(cmd, pendingStore, store, plan, remoteCommand, flags, stdin) if err != nil { return err } @@ -2129,13 +2341,17 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag } decision := auth.Decision - if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, audit.EventStarted, target.Name, remoteCommand, decision, nil, "", 0)); err != nil { + if _, err := store.Append(stampStdin(baseAuditRecord(reqID, sessionCtx, audit.EventStarted, target.Name, remoteCommand, decision, nil, "", 0), stdin)); err != nil { + // The command never executed: hand the claimed once grant back. + if auth.Status == approval.AuthAllowByGrant && auth.GrantScope == approval.ScopeOnce { + _ = sessionStore.Release(sessionCtx.ID, reqID) + } return err } streamExec, canStream := ssh.(executor.StreamingExecutor) streamFilter, canStreamFilter := outputFilter.(output.StreamFilter) if canStream && canStreamFilter && shouldStreamRun(flags, resolved) { - streamed := runStreaming(cmd, streamExec, target, remoteCommand, streamFilter) + streamed := runStreaming(ctx, cmd, streamExec, target, remoteCommand, stdin.data, streamFilter) result := streamed.Result status := statusForResult(result) event := audit.EventCompleted @@ -2147,19 +2363,24 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag OutputTruncated: streamed.OutputTruncated, Redactions: streamed.Redactions, } - if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, event, target.Name, remoteCommand, decision, &result.ExitCode, outputHash, result.Duration.Milliseconds(), filtered)); err != nil { + if _, err := store.Append(stampStdin(baseAuditRecord(reqID, sessionCtx, event, target.Name, remoteCommand, decision, &result.ExitCode, outputHash, result.Duration.Milliseconds(), filtered), stdin)); err != nil { return err } + settleOnceClaimWithWarning(cmd, sessionStore, sessionCtx.ID, reqID, auth, result) if !isSSHErrorResult(result) { refreshInventoryHostOS(cfg.Paths, target.Name, result.OS) } printRunStreamFooter(cmd, target.Name, result, streamed.Stdout) exitCode = mergeExitCode(exitCode, exitCodeForResult(result)) + if ctx.Err() != nil { + break + } continue } - result := ssh.Run(context.Background(), executor.Request{ + result := ssh.Run(ctx, executor.Request{ Target: target, Command: remoteCommand, + Stdin: stdin.data, }) status := statusForResult(result) event := audit.EventCompleted @@ -2170,9 +2391,10 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag // The audit hash records the bytes that crossed the trust boundary and // were returned to the agent after output filtering. outputHash := audit.ComputeOutputSHA256(filtered.Stdout, filtered.Stderr) - if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, event, target.Name, remoteCommand, decision, &result.ExitCode, outputHash, result.Duration.Milliseconds(), filtered)); err != nil { + if _, err := store.Append(stampStdin(baseAuditRecord(reqID, sessionCtx, event, target.Name, remoteCommand, decision, &result.ExitCode, outputHash, result.Duration.Milliseconds(), filtered), stdin)); err != nil { return err } + settleOnceClaimWithWarning(cmd, sessionStore, sessionCtx.ID, reqID, auth, result) if !isSSHErrorResult(result) { refreshInventoryHostOS(cfg.Paths, target.Name, result.OS) } @@ -2200,14 +2422,13 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag } exitCode = mergeExitCode(exitCode, exitCodeForResult(result)) + if ctx.Err() != nil { + break + } } if flags.jsonOutput { - if resolved.Kind == inventory.TargetKindHost { - if err := writeJSON(cmd, responses[0]); err != nil { - return err - } - } else if err := writeJSON(cmd, responses); err != nil { + if err := writeRunResponses(cmd, resolved, responses, flags, stdin); err != nil { return err } } @@ -2239,7 +2460,7 @@ func approvalRuntimeWithWarning(cmd *cobra.Command, cfg *config.Config) approval } } -func buildRunPlans(cfg *config.Config, resolved inventory.ResolvedTarget, remoteCommand string, flags runFlags, runtime approval.RuntimeConfig, sessionStore approval.SessionStore, preflight bool) ([]runPlan, error) { +func buildRunPlans(cfg *config.Config, resolved inventory.ResolvedTarget, remoteCommand string, flags runFlags, runtime approval.RuntimeConfig, sessionStore approval.SessionStore, preflight bool, stdin stdinSpec) ([]runPlan, error) { sessionResolver := session.Resolver{} plans := make([]runPlan, 0, len(resolved.Targets)) for _, target := range resolved.Targets { @@ -2260,9 +2481,9 @@ func buildRunPlans(cfg *config.Config, resolved inventory.ResolvedTarget, remote sessionCtx.ID = sessionIDForTarget(sessionCtx.ID, target.Name, len(resolved.Targets) > 1) var auth approval.Authorization if preflight { - auth, err = approval.PreflightAuthorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, remoteCommand) + auth, err = approval.PreflightAuthorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, remoteCommand, stdin.sha256) } else { - auth, err = approval.Authorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, remoteCommand) + auth, err = approval.Authorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, remoteCommand, stdin.sha256, reqID) } if err != nil { return nil, newUsageError("policy.yaml is invalid: %v\n fix the rule in ~/.agentssh/policy.yaml, then re-run (check: agentssh policy show)", err) @@ -2281,20 +2502,20 @@ func anyPlanNeedsApproval(plans []runPlan) bool { return false } -func handleApprovalPreflightBlock(cmd *cobra.Command, pending approval.PendingStore, store audit.Store, plans []runPlan, remoteCommand string, flags runFlags, resolved inventory.ResolvedTarget) error { +func handleApprovalPreflightBlock(cmd *cobra.Command, pending approval.PendingStore, store audit.Store, plans []runPlan, remoteCommand string, flags runFlags, resolved inventory.ResolvedTarget, stdin stdinSpec) error { exitCode := exitOK responses := make([]runResponse, 0, len(plans)) for _, plan := range plans { switch plan.Auth.Status { case approval.AuthHardDeny: - response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied) + response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied, stdin) if err != nil { return err } responses = append(responses, response) exitCode = mergeExitCode(exitCode, exitPolicyDenied) case approval.AuthNeedsApproval: - response, err := appendApprovalPending(cmd, pending, store, plan, remoteCommand, flags) + response, err := appendApprovalPending(cmd, pending, store, plan, remoteCommand, flags, stdin) if err != nil { return err } @@ -2302,7 +2523,7 @@ func handleApprovalPreflightBlock(cmd *cobra.Command, pending approval.PendingSt exitCode = mergeExitCode(exitCode, exitApprovalRequired) default: exit := exitApprovalRequired - if _, err := store.Append(baseAuditRecord(plan.ReqID, plan.SessionCtx, audit.EventDenied, plan.Target.Name, remoteCommand, plan.Auth.Decision, &exit, "", 0)); err != nil { + if _, err := store.Append(stampStdin(baseAuditRecord(plan.ReqID, plan.SessionCtx, audit.EventDenied, plan.Target.Name, remoteCommand, plan.Auth.Decision, &exit, "", 0), stdin)); err != nil { return err } response := runResponse{ @@ -2323,11 +2544,7 @@ func handleApprovalPreflightBlock(cmd *cobra.Command, pending approval.PendingSt } } if flags.jsonOutput { - if resolved.Kind == inventory.TargetKindHost { - if err := writeJSON(cmd, responses[0]); err != nil { - return err - } - } else if err := writeJSON(cmd, responses); err != nil { + if err := writeRunResponses(cmd, resolved, responses, flags, stdin); err != nil { return err } } @@ -2337,8 +2554,8 @@ func handleApprovalPreflightBlock(cmd *cobra.Command, pending approval.PendingSt return nil } -func appendDeniedRun(cmd *cobra.Command, store audit.Store, plan runPlan, remoteCommand string, flags runFlags, exitCode int) (runResponse, error) { - if _, err := store.Append(baseAuditRecord(plan.ReqID, plan.SessionCtx, audit.EventDenied, plan.Target.Name, remoteCommand, plan.Auth.Decision, nil, "", 0)); err != nil { +func appendDeniedRun(cmd *cobra.Command, store audit.Store, plan runPlan, remoteCommand string, flags runFlags, exitCode int, stdin stdinSpec) (runResponse, error) { + if _, err := store.Append(stampStdin(baseAuditRecord(plan.ReqID, plan.SessionCtx, audit.EventDenied, plan.Target.Name, remoteCommand, plan.Auth.Decision, nil, "", 0), stdin)); err != nil { return runResponse{}, err } response := runResponse{ @@ -2357,19 +2574,21 @@ func appendDeniedRun(cmd *cobra.Command, store audit.Store, plan runPlan, remote return response, nil } -func appendApprovalPending(cmd *cobra.Command, pending approval.PendingStore, store audit.Store, plan runPlan, remoteCommand string, flags runFlags) (runResponse, error) { +func appendApprovalPending(cmd *cobra.Command, pending approval.PendingStore, store audit.Store, plan runPlan, remoteCommand string, flags runFlags, stdin stdinSpec) (runResponse, error) { req, err := pending.Create(approval.PendingRequest{ - ReqID: plan.ReqID, - SessionID: plan.SessionCtx.ID, - Host: plan.Target.Name, - Cmd: remoteCommand, - Candidate: plan.Auth.ApprovalMatcher, + ReqID: plan.ReqID, + SessionID: plan.SessionCtx.ID, + Host: plan.Target.Name, + Cmd: remoteCommand, + Candidate: plan.Auth.ApprovalMatcher, + StdinSHA256: stdin.sha256, + StdinBytes: stdin.bytes, }) if err != nil { return runResponse{}, err } exit := exitApprovalRequired - record := baseAuditRecord(plan.ReqID, plan.SessionCtx, audit.EventApprovalRequested, plan.Target.Name, remoteCommand, plan.Auth.Decision, &exit, "", 0) + record := stampStdin(baseAuditRecord(plan.ReqID, plan.SessionCtx, audit.EventApprovalRequested, plan.Target.Name, remoteCommand, plan.Auth.Decision, &exit, "", 0), stdin) record.ApprovalID = req.ID record.ApprovalMatcher = req.Candidate.Regex record.ApprovalChannel = approval.ChannelExit @@ -2450,12 +2669,45 @@ func shouldStreamRun(flags runFlags, resolved inventory.ResolvedTarget) bool { return !flags.jsonOutput && len(resolved.Targets) == 1 } -func runStreaming(cmd *cobra.Command, streamExec executor.StreamingExecutor, target inventory.Target, remoteCommand string, streamFilter output.StreamFilter) streamingRunResult { +// settleOnceClaim closes the two-phase consumption of a once grant after the +// execution outcome is known. A remote exit (success or failure) consumes the +// grant. A transport-level failure (dial failure, local cancel, dropped +// connection) restores it so the approval survives for a clean re-run. +// +// Deliberate trade-off: SSH cannot prove that a transport failure happened +// before the remote started the command. A connection dropped (or Ctrl-C +// pressed) mid-execution therefore restores a grant whose command may already +// have run, and the re-run executes it a second time. The restored grant is +// still pinned to the exact command, session, and stdin hash, and both +// attempts are in the audit log; treat once approvals for non-idempotent +// commands accordingly. +func settleOnceClaim(sessionStore approval.SessionStore, sessionID string, reqID string, auth approval.Authorization, result executor.Result) error { + if auth.Status != approval.AuthAllowByGrant || auth.GrantScope != approval.ScopeOnce { + return nil + } + if isSSHErrorResult(result) { + return sessionStore.Release(sessionID, reqID) + } + return sessionStore.Commit(sessionID, reqID) +} + +// settleOnceClaimWithWarning runs after the completion audit record is safely +// appended; a settle failure must not suppress the run's result or audit +// trail. An unsettled claim fails closed (the grant stays reserved), so a +// warning is the right severity. +func settleOnceClaimWithWarning(cmd *cobra.Command, sessionStore approval.SessionStore, sessionID string, reqID string, auth approval.Authorization, result executor.Result) { + if err := settleOnceClaim(sessionStore, sessionID, reqID, auth, result); err != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: failed to settle once-approval grant: %v\n", err) + } +} + +func runStreaming(ctx context.Context, cmd *cobra.Command, streamExec executor.StreamingExecutor, target inventory.Target, remoteCommand string, stdin []byte, streamFilter output.StreamFilter) streamingRunResult { stdout := streamFilter.NewStreamWriter(cmd.OutOrStdout()) stderr := streamFilter.NewStreamWriter(cmd.ErrOrStderr()) - result := streamExec.RunStreaming(context.Background(), executor.Request{ + result := streamExec.RunStreaming(ctx, executor.Request{ Target: target, Command: remoteCommand, + Stdin: stdin, }, stdout, stderr) stdout.Flush() stderr.Flush() @@ -3477,12 +3729,9 @@ func runApprovalWait(cmd *cobra.Command, id string, timeoutValue string) error { if err != nil { return newUsageError("%v", err) } - timeout := runtime.WaitTimeout - if timeoutValue != "" { - timeout, err = time.ParseDuration(timeoutValue) - if err != nil || timeout < 0 { - return newUsageError("invalid --timeout %q", timeoutValue) - } + timeout, err := resolveWaitTimeout(runtime.WaitTimeout, timeoutValue) + if err != nil { + return err } status, err := approvalStore(cfg.Paths).Wait(id, timeout) if errors.Is(err, approval.ErrPendingNotFound) || errors.Is(err, approval.ErrInvalidID) { @@ -3509,6 +3758,19 @@ func runSessionEnd(cmd *cobra.Command, id string) error { return nil } +// resolveWaitTimeout applies the runtime default unless the caller passed an +// explicit --timeout; approval wait and plan wait share this contract. +func resolveWaitTimeout(defaultTimeout time.Duration, value string) (time.Duration, error) { + if value == "" { + return defaultTimeout, nil + } + timeout, err := time.ParseDuration(value) + if err != nil || timeout < 0 { + return 0, newUsageError("invalid --timeout %q", value) + } + return timeout, nil +} + func approvalStatusExit(status approval.StatusResult) error { switch status.Status { case "approved": @@ -3542,7 +3804,7 @@ func approvalScopeFromFlags(once, sessionScope, host bool) (approval.Scope, erro } func approvalStore(paths config.Paths) approval.PendingStore { - return approval.PendingStore{PendingDir: paths.PendingDir, ResponsesDir: paths.ResponsesDir} + return approval.PendingStore{PendingDir: paths.PendingDir, ResponsesDir: paths.ResponsesDir, PlansDir: paths.PlansDir} } func runSessionLS(cmd *cobra.Command) error { diff --git a/cmd/agentssh/main_test.go b/cmd/agentssh/main_test.go index 50dcc6d..8fef3e5 100644 --- a/cmd/agentssh/main_test.go +++ b/cmd/agentssh/main_test.go @@ -924,7 +924,7 @@ output: if err != nil { t.Fatal(err) } - if _, err := (approval.SessionStore{Dir: filepath.Join(home, "approvals", "sessions")}).Grant("s_test", "web-1", approval.ScopeSession, matcher, "ap_0123456789abcdef01234567", "r1", time.Hour, approval.ChannelCLI); err != nil { + if _, err := (approval.SessionStore{Dir: filepath.Join(home, "approvals", "sessions")}).Grant("s_test", "web-1", approval.ScopeSession, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, approval.ChannelCLI); err != nil { t.Fatal(err) } diff --git a/cmd/agentssh/plan.go b/cmd/agentssh/plan.go new file mode 100644 index 0000000..1114a41 --- /dev/null +++ b/cmd/agentssh/plan.go @@ -0,0 +1,424 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/Praeviso/AgentSSH/internal/approval" + "github.com/Praeviso/AgentSSH/internal/audit" + "github.com/Praeviso/AgentSSH/internal/config" + "github.com/Praeviso/AgentSSH/internal/inventory" + "github.com/Praeviso/AgentSSH/internal/policy" + "github.com/Praeviso/AgentSSH/internal/session" + "github.com/spf13/cobra" +) + +// A plan bundles one task's commands into a single approval round-trip: the +// agent submits N commands, the operator reviews the batch once, and every +// gray-zone line becomes an ordinary once/session grant. Execution still goes +// through `run` per command, so audit granularity and explicit-deny precedence +// are unchanged. + +func newPlanCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "plan", + Short: "Submit and track multi-command approval plans.", + } + + var submitFlags struct { + session string + sessionLabel string + file string + jsonOutput bool + } + submitCmd := &cobra.Command{ + Use: "submit [--session ] [--file ] [--json] [-- ...]", + Short: "Submit a batch of commands for one approval review.", + Long: "Each argument after -- is one complete remote command (quote each one).\n" + + "--file adds one command per line (blank lines and # comments are skipped).\n" + + "Allowed commands are reported as such; gray-zone commands become one\n" + + "pending approval each, bundled under a single plan id.", + Args: func(cmd *cobra.Command, args []string) error { + if cmd.ArgsLenAtDash() > 1 || (cmd.ArgsLenAtDash() < 0 && len(args) != 1) { + return newUsageError("requires and commands after -- (or --file)") + } + if len(args) < 1 { + return newUsageError("requires ") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + commands := append([]string(nil), args[1:]...) + return runPlanSubmit(cmd, args[0], commands, submitFlags.session, submitFlags.sessionLabel, submitFlags.file, submitFlags.jsonOutput) + }, + } + submitCmd.Flags().StringVar(&submitFlags.session, "session", "", "associate the plan with a session id") + submitCmd.Flags().StringVar(&submitFlags.sessionLabel, "session-label", "", "attach a human-readable label to the session") + submitCmd.Flags().StringVar(&submitFlags.file, "file", "", "read additional commands from a file, one per line") + submitCmd.Flags().BoolVar(&submitFlags.jsonOutput, "json", false, "emit machine-readable JSON") + + var statusJSON bool + statusCmd := &cobra.Command{ + Use: "status [--json]", + Short: "Read a plan's aggregate approval state.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPlanStatus(cmd, args[0], statusJSON) + }, + } + statusCmd.Flags().BoolVar(&statusJSON, "json", false, "emit machine-readable JSON") + + var waitTimeout string + var waitJSON bool + waitCmd := &cobra.Command{ + Use: "wait [--timeout ] [--json]", + Short: "Wait until every command in a plan is adjudicated.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPlanWait(cmd, args[0], waitTimeout, waitJSON) + }, + } + waitCmd.Flags().StringVar(&waitTimeout, "timeout", "", "maximum wait duration, e.g. 10m") + waitCmd.Flags().BoolVar(&waitJSON, "json", false, "emit machine-readable JSON") + + var grantOnce, grantSession bool + grantCmd := &cobra.Command{ + Use: "grant --once|--session", + Short: "Approve every pending command in a plan.", + Args: exactArgs(1), + PersistentPreRunE: requireOperator, + RunE: func(cmd *cobra.Command, args []string) error { + var scope approval.Scope + switch { + case grantOnce && !grantSession: + scope = approval.ScopeOnce + case grantSession && !grantOnce: + scope = approval.ScopeSession + default: + return newUsageError("choose exactly one of --once or --session (plans never grant host scope; use approval grant --host per command)") + } + return runPlanDecision(cmd, args[0], approval.VerdictApproved, scope) + }, + } + grantCmd.Flags().BoolVar(&grantOnce, "once", false, "approve one run per command") + grantCmd.Flags().BoolVar(&grantSession, "session", false, "approve each command for this session") + + denyCmd := &cobra.Command{ + Use: "deny ", + Short: "Deny every pending command in a plan.", + Args: exactArgs(1), + PersistentPreRunE: requireOperator, + RunE: func(cmd *cobra.Command, args []string) error { + return runPlanDecision(cmd, args[0], approval.VerdictDenied, "") + }, + } + + cmd.AddCommand(submitCmd, statusCmd, waitCmd, grantCmd, denyCmd) + return cmd +} + +type planSubmitLine struct { + Seq int `json:"seq"` + Cmd string `json:"cmd"` + Status string `json:"status"` // allowed | denied | approval_pending + PolicyRule string `json:"policy_rule,omitempty"` + ApprovalID string `json:"approval_id,omitempty"` +} + +type planSubmitResponse struct { + PlanID string `json:"plan_id,omitempty"` + SessionID string `json:"session_id"` + Host string `json:"host"` + Commands []planSubmitLine `json:"commands"` + Allowed int `json:"allowed"` + Denied int `json:"denied"` + Pending int `json:"pending"` +} + +func readPlanFile(path string) ([]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, newUsageError("cannot read --file: %v", err) + } + var commands []string + for _, line := range strings.Split(string(data), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + commands = append(commands, trimmed) + } + return commands, nil +} + +func runPlanSubmit(cmd *cobra.Command, targetName string, commands []string, sessionFlag string, sessionLabel string, file string, jsonOutput bool) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + runtime := approvalRuntimeWithWarning(cmd, cfg) + if !runtime.Enabled { + return newUsageError("plan submit requires the async approval channel\n" + + " enable it in ~/.agentssh/policy.yaml (approval.enabled: true) or via AGENTSSH_APPROVAL\n" + + " without approval, pre-check commands with: agentssh policy test --host ''") + } + if file != "" { + fromFile, err := readPlanFile(file) + if err != nil { + return err + } + commands = append(commands, fromFile...) + } + cleaned := make([]string, 0, len(commands)) + for _, command := range commands { + if strings.TrimSpace(command) == "" { + continue + } + cleaned = append(cleaned, command) + } + if len(cleaned) == 0 { + return newUsageError("plan submit requires at least one command (after -- or via --file)") + } + + resolved, err := inventory.NewResolver(cfg.Inventory).Resolve(targetName) + if err != nil { + return newUsageError("%v\n list all hosts: agentssh hosts", err) + } + if resolved.Kind != inventory.TargetKindHost || len(resolved.Targets) != 1 { + return newUsageError("plan submit targets a single host; submit one plan per host") + } + target := resolved.Targets[0] + + sessionCtx, err := (session.Resolver{}).Resolve(target.Name, sessionFlag, sessionLabel) + if err != nil { + if errors.Is(err, session.ErrNoSession) { + return newUsageError("a session must be declared for plan submit\n" + + " mint one id per task: agentssh session new\n" + + " then pass --session here and on every run in the task") + } + return fmt.Errorf("resolve session: %w", err) + } + + pendingStore := approvalStore(cfg.Paths) + sessionStore := approval.SessionStore{Dir: cfg.Paths.SessionsDir} + store := audit.NewStore(cfg.Paths.AuditFile) + planID, err := approval.NewPlanID() + if err != nil { + return err + } + + response := planSubmitResponse{SessionID: sessionCtx.ID, Host: target.Name} + var memberIDs []string + exitCode := exitOK + for i, command := range cleaned { + line := planSubmitLine{Seq: i + 1, Cmd: command} + auth, err := approval.PreflightAuthorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, command, "") + if err != nil { + return newUsageError("policy.yaml is invalid: %v\n fix the rule in ~/.agentssh/policy.yaml (check: agentssh policy show)", err) + } + line.PolicyRule = auth.Decision.Rule + switch auth.Status { + case approval.AuthAllow, approval.AuthAllowByGrant: + line.Status = "allowed" + response.Allowed++ + case approval.AuthHardDeny: + line.Status = "denied" + response.Denied++ + exitCode = mergeExitCode(exitCode, exitPolicyDenied) + case approval.AuthNeedsApproval: + reqID, err := newReqID() + if err != nil { + return err + } + req, err := pendingStore.Create(approval.PendingRequest{ + ReqID: reqID, + SessionID: sessionCtx.ID, + Host: target.Name, + Cmd: command, + Candidate: auth.ApprovalMatcher, + PlanID: planID, + PlanSeq: i + 1, + PlanTotal: len(cleaned), + }) + if err != nil { + return err + } + line.Status = "approval_pending" + line.ApprovalID = req.ID + memberIDs = append(memberIDs, req.ID) + exit := exitApprovalRequired + record := baseAuditRecord(reqID, sessionCtx, audit.EventApprovalRequested, target.Name, command, auth.Decision, &exit, "", 0) + record.ApprovalID = req.ID + record.ApprovalMatcher = req.Candidate.Regex + record.ApprovalChannel = approval.ChannelPlan + record.PlanID = planID + if _, err := store.Append(record); err != nil { + return err + } + response.Pending++ + exitCode = mergeExitCode(exitCode, exitApprovalRequired) + default: + return fmt.Errorf("unknown approval authorization status %q", auth.Status) + } + response.Commands = append(response.Commands, line) + } + + if len(memberIDs) > 0 { + manifest, err := pendingStore.CreatePlan(approval.PlanManifest{ + ID: planID, + SessionID: sessionCtx.ID, + Host: target.Name, + MemberIDs: memberIDs, + }) + if err != nil { + return err + } + response.PlanID = manifest.ID + } + + if jsonOutput { + if err := writeJSON(cmd, response); err != nil { + return err + } + } else { + printPlanSubmitHuman(cmd, response) + } + if exitCode != exitOK { + return commandExitError{Code: exitCode} + } + return nil +} + +func printPlanSubmitHuman(cmd *cobra.Command, response planSubmitResponse) { + out := cmd.ErrOrStderr() + for _, line := range response.Commands { + marker := "✓" + note := "allowed" + switch line.Status { + case "denied": + marker = "✗" + note = "denied by policy (" + line.PolicyRule + ")" + case "approval_pending": + marker = "!" + note = "approval pending " + line.ApprovalID + } + _, _ = fmt.Fprintf(out, "%s %d/%d %s · %s\n", marker, line.Seq, len(response.Commands), line.Cmd, note) + } + if response.PlanID != "" { + _, _ = fmt.Fprintf(out, "! plan %s · %d command(s) awaiting one operator review\n", response.PlanID, response.Pending) + _, _ = fmt.Fprintf(out, " wait for the decision: agentssh plan wait %s\n", response.PlanID) + } else if response.Denied == 0 { + _, _ = fmt.Fprintln(out, "✓ all commands already allowed — run them directly") + } +} + +func runPlanStatus(cmd *cobra.Command, id string, jsonOutput bool) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + status, err := approvalStore(cfg.Paths).PlanStatus(id) + if err != nil { + return mapPlanError(err) + } + return emitPlanStatus(cmd, status, jsonOutput) +} + +func runPlanWait(cmd *cobra.Command, id string, timeoutValue string, jsonOutput bool) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + runtime := approvalRuntimeWithWarning(cmd, cfg) + timeout, err := resolveWaitTimeout(runtime.WaitTimeout, timeoutValue) + if err != nil { + return err + } + status, err := approvalStore(cfg.Paths).WaitPlan(id, timeout) + if err != nil { + return mapPlanError(err) + } + return emitPlanStatus(cmd, status, jsonOutput) +} + +// emitPlanStatus prints the aggregate state and maps it to the approval exit +// contract: approved 0, denied 6, still pending 7, expired 2 (stale records — +// re-submit rather than assume a verdict). +func emitPlanStatus(cmd *cobra.Command, status approval.PlanStatus, jsonOutput bool) error { + if jsonOutput { + if err := writeJSON(cmd, status); err != nil { + return err + } + } else { + out := cmd.OutOrStdout() + _, _ = fmt.Fprintf(out, "plan %s · %s · approved %d · denied %d · pending %d", + status.ID, status.Status, status.Approved, status.Denied, status.Pending) + if status.Expired > 0 { + _, _ = fmt.Fprintf(out, " · expired %d", status.Expired) + } + _, _ = fmt.Fprintln(out) + for _, member := range status.Members { + cmdText := "" + if member.Request != nil { + cmdText = " · " + member.Request.Cmd + } + _, _ = fmt.Fprintf(out, " %s %s%s\n", member.ApprovalID, member.Status, cmdText) + } + } + switch status.Status { + case "approved": + return nil + case "denied": + return commandExitError{Code: exitPolicyDenied} + case "expired": + return newUsageError("plan %s records have expired; re-submit the plan", status.ID) + default: + return commandExitError{Code: exitApprovalRequired} + } +} + +func runPlanDecision(cmd *cobra.Command, id string, verdict approval.Verdict, scope approval.Scope) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + runtime, err := approvalRuntime(cfg) + if err != nil { + return newUsageError("%v", err) + } + results, err := approval.ApplyPlanDecision(approval.ApplyOptions{ + Pending: approvalStore(cfg.Paths), + Sessions: approval.SessionStore{Dir: cfg.Paths.SessionsDir}, + Audit: audit.NewStore(cfg.Paths.AuditFile), + Bundle: policy.Bundle{Policy: cfg.Policy, Inventory: cfg.Inventory}, + PolicyPath: cfg.Paths.PolicyFile, + SessionTTL: runtime.SessionTTL, + Channel: approval.ChannelCLI, + SavePolicy: func(next policy.Config) error { + return saveValidatedPolicy(cfg.Paths, next) + }, + }, id, verdict, scope) + if err != nil { + return mapPlanError(err) + } + action := "denied" + if verdict == approval.VerdictApproved { + action = fmt.Sprintf("approved scope=%s", scope) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "plan %s: %s %d command(s)\n", id, action, len(results)) + return nil +} + +func mapPlanError(err error) error { + switch { + case errors.Is(err, approval.ErrInvalidPlanID), + errors.Is(err, approval.ErrPlanNotFound), + errors.Is(err, approval.ErrPlanScope), + errors.Is(err, approval.ErrPlanNoPending): + return newUsageError("%v", err) + default: + return err + } +} diff --git a/cmd/agentssh/plan_test.go b/cmd/agentssh/plan_test.go new file mode 100644 index 0000000..9a08055 --- /dev/null +++ b/cmd/agentssh/plan_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "sync/atomic" + "testing" + + "github.com/Praeviso/AgentSSH/internal/approval" + "github.com/Praeviso/AgentSSH/internal/audit" +) + +func setupPlanHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true + host_grant_mode: safe-prefix +rules: + - name: catastrophic + priority: 100 + match: { cmd_regex: 'rm\s+-rf' } + action: deny + - name: allow-echo + priority: 0 + match: { cmd_regex: '^echo\b' } + action: allow +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + return home +} + +func TestPlanSubmitGrantRunE2E(t *testing.T) { + home := setupPlanHome(t) + var calls int32 + withFakeExecutor(t, fakeExecutor{calls: &calls}) + + code, stdout, stderr := runExit(t, "plan", "submit", "web-1", "--json", "--", + "echo preflight", + "systemctl restart nginx", + "docker compose -f /opt/app/compose.yml up -d") + if code != exitApprovalRequired { + t.Fatalf("submit exit=%d want 7 stdout=%s stderr=%s", code, stdout, stderr) + } + var response planSubmitResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode submit response: %v\n%s", err, stdout) + } + if response.PlanID == "" || response.Allowed != 1 || response.Pending != 2 || response.Denied != 0 { + t.Fatalf("submit response = %+v", response) + } + if atomic.LoadInt32(&calls) != 0 { + t.Fatalf("plan submit executed commands: calls=%d", calls) + } + + // Status while pending: exit 7. + if code, _, _ := runExit(t, "plan", "status", response.PlanID); code != exitApprovalRequired { + t.Fatalf("pending plan status exit=%d want 7", code) + } + + // One operator decision approves the whole batch for the session. + withOperatorAuth(t, home) + grantOut, _, err := runCommandForTest(t, "plan", "grant", response.PlanID, "--session") + if err != nil { + t.Fatalf("plan grant: %v", err) + } + if !strings.Contains(grantOut, "approved scope=session 2 command(s)") { + t.Fatalf("grant stdout=%q", grantOut) + } + if code, _, _ := runExit(t, "plan", "status", response.PlanID); code != exitOK { + t.Fatalf("approved plan status exit=%d want 0", code) + } + if code, _, _ := runExit(t, "plan", "wait", response.PlanID, "--timeout", "1ms"); code != exitOK { + t.Fatalf("approved plan wait exit=%d want 0", code) + } + + // Both gray commands now run without further approvals. + if code, _, stderr := runExit(t, "run", "web-1", "--json", "--", "systemctl", "restart", "nginx"); code != exitOK { + t.Fatalf("run 1 exit=%d stderr=%s", code, stderr) + } + if code, _, stderr := runExit(t, "run", "web-1", "--json", "--", "docker", "compose", "-f", "/opt/app/compose.yml", "up", "-d"); code != exitOK { + t.Fatalf("run 2 exit=%d stderr=%s", code, stderr) + } + if atomic.LoadInt32(&calls) != 2 { + t.Fatalf("executor calls=%d want 2", calls) + } + + // Audit ties the batch together via plan_id on the approval lifecycle. + records := mustReadAudit(t, home) + var requested, granted int + for _, record := range records { + if record.PlanID != response.PlanID { + continue + } + switch record.Event { + case audit.EventApprovalRequested: + requested++ + if record.ApprovalChannel != approval.ChannelPlan { + t.Fatalf("requested channel=%q", record.ApprovalChannel) + } + case audit.EventApprovalGranted: + granted++ + } + } + if requested != 2 || granted != 2 { + t.Fatalf("plan audit requested=%d granted=%d want 2/2", requested, granted) + } + verify, err := audit.NewStore(home + "/audit.log").Verify() + if err != nil || !verify.OK { + t.Fatalf("audit chain broken: %+v err=%v", verify, err) + } +} + +func TestPlanSubmitReportsHardDenyLines(t *testing.T) { + setupPlanHome(t) + withFakeExecutor(t, fakeExecutor{}) + code, stdout, _ := runExit(t, "plan", "submit", "web-1", "--json", "--", + "rm -rf /var/tmp/cache", + "systemctl restart nginx") + if code != exitPolicyDenied { + t.Fatalf("submit exit=%d want 6 (deny dominates)", code) + } + var response planSubmitResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode: %v", err) + } + if response.Denied != 1 || response.Pending != 1 { + t.Fatalf("response=%+v", response) + } + if response.Commands[0].Status != "denied" || !strings.Contains(response.Commands[0].PolicyRule, "catastrophic") { + t.Fatalf("denied line=%+v", response.Commands[0]) + } +} + +func TestPlanDenyDeniesAllPending(t *testing.T) { + home := setupPlanHome(t) + withFakeExecutor(t, fakeExecutor{}) + _, stdout, _ := runExit(t, "plan", "submit", "web-1", "--json", "--", "systemctl restart nginx", "systemctl restart redis") + var response planSubmitResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode: %v", err) + } + withOperatorAuth(t, home) + if _, _, err := runCommandForTest(t, "plan", "deny", response.PlanID); err != nil { + t.Fatalf("plan deny: %v", err) + } + if code, _, _ := runExit(t, "plan", "status", response.PlanID); code != exitPolicyDenied { + t.Fatalf("denied plan status exit=%d want 6", code) + } +} + +func TestPlanSubmitFromFile(t *testing.T) { + setupPlanHome(t) + withFakeExecutor(t, fakeExecutor{}) + planFile := t.TempDir() + "/cmds.txt" + content := "# deployment steps\nsystemctl restart nginx\n\nsystemctl restart redis\n" + if err := os.WriteFile(planFile, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + code, stdout, _ := runExit(t, "plan", "submit", "web-1", "--json", "--file", planFile) + if code != exitApprovalRequired { + t.Fatalf("submit exit=%d want 7", code) + } + var response planSubmitResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode: %v", err) + } + if len(response.Commands) != 2 || response.Pending != 2 { + t.Fatalf("response=%+v", response) + } +} + +func TestPlanSubmitRequiresApprovalEnabled(t *testing.T) { + setupHome(t) // approval disabled policy + withFakeExecutor(t, fakeExecutor{}) + _, _, err := runCommandForTest(t, "plan", "submit", "web-1", "--", "systemctl restart nginx") + if exitCodeForError(err) != exitUsage { + t.Fatalf("exit=%d want usage", exitCodeForError(err)) + } + if err == nil || !strings.Contains(err.Error(), "approval") { + t.Fatalf("err=%v", err) + } +} + +func TestPlanGrantRequiresScope(t *testing.T) { + home := setupPlanHome(t) + withFakeExecutor(t, fakeExecutor{}) + _, stdout, _ := runExit(t, "plan", "submit", "web-1", "--json", "--", "systemctl restart nginx") + var response planSubmitResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode: %v", err) + } + withOperatorAuth(t, home) + _, _, err := runCommandForTest(t, "plan", "grant", response.PlanID) + if exitCodeForError(err) != exitUsage || !strings.Contains(err.Error(), "--once or --session") { + t.Fatalf("err=%v", err) + } +} diff --git a/cmd/agentssh/run_output_test.go b/cmd/agentssh/run_output_test.go new file mode 100644 index 0000000..c21299a --- /dev/null +++ b/cmd/agentssh/run_output_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "sync/atomic" + "testing" +) + +func TestRunJSONCmdEchoTruncatedWithSHA(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +rules: + - name: allow-echo + priority: 0 + match: { cmd_regex: '^echo\b' } + action: allow +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + withFakeExecutor(t, fakeExecutor{}) + + payload := strings.Repeat("x", 3*cmdEchoMaxBytes) + fullCmd := "echo " + payload + code, stdout, stderr := runExit(t, "run", "web-1", "--json", "--", "echo", payload) + if code != exitOK { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + var response runResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if !response.CmdTruncated { + t.Fatalf("cmd_truncated not set; cmd len=%d", len(response.Cmd)) + } + if len(response.Cmd) > cmdEchoMaxBytes { + t.Fatalf("cmd echo len=%d > cap %d", len(response.Cmd), cmdEchoMaxBytes) + } + sum := sha256.Sum256([]byte(fullCmd)) + if response.CmdSHA256 != hex.EncodeToString(sum[:]) { + t.Fatalf("cmd_sha256=%q want hash of full command", response.CmdSHA256) + } + // The audit log keeps the full command; only the echo is truncated. + records := mustReadAudit(t, home) + var sawFull bool + for _, record := range records { + if record.Cmd == fullCmd { + sawFull = true + } + } + if !sawFull { + t.Fatalf("audit log does not retain the full command") + } +} + +func TestRunJSONShortCmdNotTruncated(t *testing.T) { + setupHome(t) + withFakeExecutor(t, fakeExecutor{}) + code, stdout, stderr := runExit(t, "run", "web-1", "--json", "--", "echo", "hi") + if code != exitOK { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + var response runResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Cmd != "echo hi" || response.CmdTruncated { + t.Fatalf("short cmd echo mangled: %#v", response) + } + if response.CmdSHA256 == "" { + t.Fatalf("cmd_sha256 missing") + } +} + +func TestRunFieldsProjection(t *testing.T) { + setupHome(t) + withFakeExecutor(t, fakeExecutor{}) + code, stdout, stderr := runExit(t, "run", "web-1", "--fields", "req_id,status,exit_code", "--", "echo", "hi") + if code != exitOK { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + var row map[string]any + if err := json.Unmarshal([]byte(stdout), &row); err != nil { + t.Fatalf("decode projected response: %v\n%s", err, stdout) + } + if len(row) != 3 { + t.Fatalf("projection has %d keys, want 3: %#v", len(row), row) + } + for _, key := range []string{"req_id", "status", "exit_code"} { + if _, ok := row[key]; !ok { + t.Fatalf("projection missing %q: %#v", key, row) + } + } + if row["status"] != "completed" { + t.Fatalf("status=%v", row["status"]) + } +} + +func TestRunFieldsRejectsUnknownName(t *testing.T) { + setupHome(t) + var calls int32 + withFakeExecutor(t, fakeExecutor{calls: &calls}) + _, _, err := runCommandForTest(t, "run", "web-1", "--fields", "bogus", "--", "echo", "hi") + if exitCodeForError(err) != exitUsage { + t.Fatalf("exit=%d want usage error", exitCodeForError(err)) + } + if err == nil || !strings.Contains(err.Error(), "unknown --fields name") { + t.Fatalf("err=%v", err) + } + if atomic.LoadInt32(&calls) != 0 { + t.Fatalf("executor ran despite invalid --fields") + } +} + +// TestOnceGrantTwoPhaseE2E covers the "approved but wasted" fix: a once +// approval must survive a transport-level failure (the command never executed) +// and be consumed only when the command actually reaches the remote. +func TestOnceGrantTwoPhaseE2E(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true + host_grant_mode: safe-prefix +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + + withFakeExecutor(t, fakeExecutor{}) + code, stdout, _ := runExit(t, "run", "web-1", "--json", "--", "systemctl", "restart", "nginx") + if code != exitApprovalRequired { + t.Fatalf("initial run exit=%d want 7", code) + } + var pending runResponse + if err := json.Unmarshal([]byte(stdout), &pending); err != nil { + t.Fatalf("decode pending: %v", err) + } + + withOperatorAuth(t, home) + if _, _, err := runCommandForTest(t, "approval", "grant", pending.ApprovalID, "--once"); err != nil { + t.Fatalf("approval grant --once: %v", err) + } + + // Transport failure: the remote never ran the command, so the once + // approval must be released for a clean re-run. + withFakeExecutor(t, fakeExecutor{exitCode: 255}) + if code, _, _ := runExit(t, "run", "web-1", "--json", "--", "systemctl", "restart", "nginx"); code != exitSSHError { + t.Fatalf("transport-failure run exit=%d want 9", code) + } + + // Re-run succeeds on the same once approval (no new approval request). + var calls int32 + withFakeExecutor(t, fakeExecutor{calls: &calls}) + if code, _, stderr := runExit(t, "run", "web-1", "--json", "--", "systemctl", "restart", "nginx"); code != exitOK { + t.Fatalf("re-run exit=%d stderr=%s", code, stderr) + } + if atomic.LoadInt32(&calls) != 1 { + t.Fatalf("executor calls=%d want 1", calls) + } + + // The successful run consumed the grant: the next identical run needs a + // fresh approval. + if code, _, _ := runExit(t, "run", "web-1", "--json", "--", "systemctl", "restart", "nginx"); code != exitApprovalRequired { + t.Fatalf("post-consumption run exit=%d want 7", code) + } + if atomic.LoadInt32(&calls) != 1 { + t.Fatalf("executor ran after grant was consumed: calls=%d", calls) + } +} diff --git a/cmd/agentssh/run_stdin_test.go b/cmd/agentssh/run_stdin_test.go new file mode 100644 index 0000000..77ac444 --- /dev/null +++ b/cmd/agentssh/run_stdin_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Praeviso/AgentSSH/internal/audit" + "github.com/Praeviso/AgentSSH/internal/config" + "github.com/Praeviso/AgentSSH/internal/executor" +) + +// stdinCaptureExecutor records the stdin payload each Run request carried. +type stdinCaptureExecutor struct { + captured *[][]byte +} + +func (e stdinCaptureExecutor) Run(_ context.Context, request executor.Request) executor.Result { + if e.captured != nil { + *e.captured = append(*e.captured, append([]byte(nil), request.Stdin...)) + } + return executor.Result{Stdout: "ok\n", Argv: []string{"ssh", request.Target.Name, request.Command}} +} + +func (e stdinCaptureExecutor) RunStreaming(_ context.Context, request executor.Request, stdout io.Writer, _ io.Writer) executor.Result { + if e.captured != nil { + *e.captured = append(*e.captured, append([]byte(nil), request.Stdin...)) + } + _, _ = stdout.Write([]byte("ok\n")) + return executor.Result{Argv: []string{"ssh", request.Target.Name, request.Command}} +} + +func (e stdinCaptureExecutor) Close() error { return nil } + +func writeStdinFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "payload.conf") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write stdin file: %v", err) + } + return path +} + +func withCaptureExecutor(t *testing.T, captured *[][]byte) { + t.Helper() + restore := newExecutor + newExecutor = func(_ *config.Config) executor.Executor { return stdinCaptureExecutor{captured: captured} } + t.Cleanup(func() { newExecutor = restore }) +} + +func TestRunStdinFileAllowedRuleFeedsExecutorAndAudits(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +rules: + - name: allow-cat + priority: 0 + match: { cmd_regex: '^cat\b' } + action: allow +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + + content := "server {\n listen 80;\n}\n" + path := writeStdinFile(t, content) + var captured [][]byte + withCaptureExecutor(t, &captured) + + code, stdout, stderr := runExit(t, "run", "web-1", "--json", "--stdin-file", path, "--", "cat") + if code != exitOK { + t.Fatalf("exit=%d stderr=%s", code, stderr) + } + if len(captured) != 1 || string(captured[0]) != content { + t.Fatalf("executor stdin=%q want %q", captured, content) + } + + sum := sha256.Sum256([]byte(content)) + wantSHA := hex.EncodeToString(sum[:]) + var response runResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.StdinSHA256 != wantSHA || response.StdinBytes != int64(len(content)) { + t.Fatalf("response stdin identity = %q/%d", response.StdinSHA256, response.StdinBytes) + } + + records := mustReadAudit(t, home) + var stamped int + for _, record := range records { + if record.StdinSHA256 == wantSHA && record.StdinBytes == int64(len(content)) { + stamped++ + } + } + if stamped == 0 { + t.Fatalf("no audit record carries the stdin hash: %#v", records) + } + verify, err := audit.NewStore(filepath.Join(home, "audit.log")).Verify() + if err != nil || !verify.OK { + t.Fatalf("audit chain broken after stdin records: %+v err=%v", verify, err) + } +} + +func TestRunStdinFileApprovalBindsContent(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true + host_grant_mode: prefix +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + + path := writeStdinFile(t, "v1 content") + var captured [][]byte + withCaptureExecutor(t, &captured) + + code, stdout, _ := runExit(t, "run", "web-1", "--json", "--stdin-file", path, "--", "tee", "/etc/app.conf") + if code != exitApprovalRequired { + t.Fatalf("initial run exit=%d want 7", code) + } + var pending runResponse + if err := json.Unmarshal([]byte(stdout), &pending); err != nil { + t.Fatalf("decode pending: %v", err) + } + // Even under the permissive prefix mode, a stdin request must stay exact + // and must not offer host scope. + for _, scope := range pending.ProposedScopes { + if scope == "host" { + t.Fatalf("stdin approval offered host scope: %v", pending.ProposedScopes) + } + } + if pending.StdinSHA256 == "" || pending.StdinBytes == 0 { + t.Fatalf("pending response missing stdin identity: %#v", pending) + } + + withOperatorAuth(t, home) + if _, _, err := runCommandForTest(t, "approval", "grant", pending.ApprovalID, "--session"); err != nil { + t.Fatalf("approval grant: %v", err) + } + + // Same command + same content executes. + if code, _, stderr := runExit(t, "run", "web-1", "--json", "--stdin-file", path, "--", "tee", "/etc/app.conf"); code != exitOK { + t.Fatalf("approved rerun exit=%d stderr=%s", code, stderr) + } + if len(captured) != 1 || string(captured[0]) != "v1 content" { + t.Fatalf("executor stdin=%q", captured) + } + + // Same command with different content must NOT ride the grant. + otherPath := writeStdinFile(t, "v2 content — changed") + if code, _, _ := runExit(t, "run", "web-1", "--json", "--stdin-file", otherPath, "--", "tee", "/etc/app.conf"); code != exitApprovalRequired { + t.Fatalf("changed-content run exit=%d want 7", code) + } + // Same command with NO stdin must not ride the grant either. + if code, _, _ := runExit(t, "run", "web-1", "--json", "--", "tee", "/etc/app.conf"); code != exitApprovalRequired { + t.Fatalf("no-stdin run exit=%d want 7", code) + } + if len(captured) != 1 { + t.Fatalf("executor ran for unapproved stdin variants: %d calls", len(captured)) + } + + // The grant approved with stdin must not be granted to a host rule either: + // verify the stored request rejects host scope outright. + if _, _, err := runCommandForTest(t, "approval", "grant", pending.ApprovalID, "--host"); err == nil { + t.Fatalf("host grant of a stdin approval unexpectedly succeeded") + } +} + +func TestRunStdinFileTooLargeIsUsageError(t *testing.T) { + setupHome(t) + withFakeExecutor(t, fakeExecutor{}) + path := filepath.Join(t.TempDir(), "big.bin") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(maxStdinBytes + 1); err != nil { + t.Fatal(err) + } + _ = file.Close() + _, _, cmdErr := runCommandForTest(t, "run", "web-1", "--stdin-file", path, "--", "echo", "hi") + if exitCodeForError(cmdErr) != exitUsage { + t.Fatalf("exit=%d want usage", exitCodeForError(cmdErr)) + } + if cmdErr == nil || !strings.Contains(cmdErr.Error(), "limit") { + t.Fatalf("err=%v", cmdErr) + } +} diff --git a/cmd/agentssh/stdin_file_test.go b/cmd/agentssh/stdin_file_test.go new file mode 100644 index 0000000..5caeac1 --- /dev/null +++ b/cmd/agentssh/stdin_file_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestLoadStdinSpecRejectsNonRegularFile(t *testing.T) { + dir := t.TempDir() + fifo := filepath.Join(dir, "pipe") + if err := syscall.Mkfifo(fifo, 0o600); err != nil { + t.Skipf("cannot create FIFO on this platform: %v", err) + } + // loadStdinSpec must reject the FIFO by mode, not block reading it. + _, err := loadStdinSpec(fifo) + if err == nil || !strings.Contains(err.Error(), "not a regular file") { + t.Fatalf("err = %v, want non-regular-file rejection", err) + } +} + +func TestLoadStdinSpecRejectsOversizeDuringRead(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "big.bin") + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(maxStdinBytes + 1); err != nil { + t.Fatal(err) + } + _ = f.Close() + _, err = loadStdinSpec(path) + if err == nil || !strings.Contains(err.Error(), "limit") { + t.Fatalf("err = %v, want size-limit rejection", err) + } +} + +func TestLoadStdinSpecReadsRegularFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "conf") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + spec, err := loadStdinSpec(path) + if err != nil { + t.Fatalf("loadStdinSpec: %v", err) + } + if string(spec.data) != "hello" || spec.bytes != 5 || spec.sha256 == "" { + t.Fatalf("spec = %+v", spec) + } +} diff --git a/docs/plans/plan-approval.md b/docs/plans/plan-approval.md new file mode 100644 index 0000000..90aebbf --- /dev/null +++ b/docs/plans/plan-approval.md @@ -0,0 +1,69 @@ +# 计划审批(Plan Approval)— 设计与实现记录 + +> 状态:已实现 · 配套:`docs/plans/approval-system.md`(单命令异步审批 v1.1) +> +> 起因:实测反馈「精确匹配审批太细碎——一次部署十几条命令批了 ~10 次」。 +> +> 一句话:`agentssh plan submit --session -- 'cmd1' 'cmd2' …` 把一个任务的 N 条灰区命令打包成**一张审批单**,操作员整体审阅一次(TUI `[p]` 或 `plan grant`),等价于**批量铸造 N 个 exact 的 once/session grant**。执行仍然逐条走 `run` → `Authorize`,审计粒度与显式 deny 的不可逾越性**零变化**。 + +## 1. 设计原则:计划是审批的批处理,不是执行的批处理 + +刻意不做「approve 后自动执行 N 条命令」: + +- **执行路径一行不改**。每条命令仍由 agent 逐条 `run`,逐条实时过 `Authorize`(分离引擎:显式 allow/deny 优先,grant 只在 default-deny 灰区参与)。计划里混进一条被 deny 的命令,批了也执行不了。 +- **审计粒度不丢**。每条命令照旧有自己的 `started/completed/failed` 记录;审批生命周期事件(`approval_requested/granted/denied`)通过新增的 `plan_id` 字段(`,omitempty`,追加在 canonical 字段表末尾,旧链不回归)串起来。 +- **grant 语义照旧**。批量批准 = 逐条 `ApplyDecision`,once/session 两档,exact matcher(`Generalize` 的解释器/特权/破坏性护栏逐条照跑);**批量档永不提供 host scope**——持久放宽必须回到单条命令的 deliberate 决定(`approval grant --host`)。 + +## 2. 数据模型 + +- **成员请求**:复用现有 `PendingRequest`(pending/responses 存储、`req_digest`、O_EXCL、去重全部沿用),新增展示元数据 `plan_id / plan_seq / plan_total`(omitempty)。 +- **计划清单**:`approvals/plans/.json`(0600,O_EXCL 一次写入): + ```json + { "version":1, "id":"pl_<96bit>", "session_id":"s_…", "host":"web-1", + "ts":"…Z", "member_ids":["ap_…","ap_…"] } + ``` + 清单是**成员关系的权威来源**(`plan status/wait` 以它为准),避免重写 pending 文件;成员的裁决状态始终从 pending/responses 实时推导。清单同 resolution 一样**不是授权依据**——授权永远在 `run` → `Authorize` 处从 grant store 重新推导。 +- **成员失效 = 拒绝**:成员 pending 文件被 TTL 清扫后,`PlanStatus` 计为 `expired` 并归入 denied(fail-closed,agent 重新提交而不是假设已批)。 + +## 3. 命面 + +**agent 面(无审批动词)**: +- `plan submit --session [--file ] [--json] -- '' '' …` + - `--` 后**每个参数是一条完整命令**(不做 join;与 `run` 的 join 语义刻意不同,help 里写明);`--file` 每行一条,跳过空行与 `#`。 + - 单 host(组 → usage error;每 host 一张计划)。要求审批通道开启,否则 usage error 并指路 `policy test`。 + - 逐条 `PreflightAuthorize`(无副作用、不消费 grant):allow/allow_by_grant → `allowed`;显式 deny → `denied`(终态,不入计划);default-deny → 铸 pending(带 plan 标记)+ audit `approval_requested{plan_id, channel=plan}`。 + - 退出码沿用合并语义:任一 denied → 6 > 任一 pending → 7 > 全 allowed → 0。 +- `plan status [--json]` / `plan wait [--timeout] [--json]`:聚合裁决状态,退出码对齐 approval:全批 0、任一拒/失效 6、仍有 pending 7、未知/畸形 2。 + +**操作员面(`requireOperator` 守门)**: +- `plan grant --once|--session` — 对全部仍 pending 的成员逐条 `ApplyDecision`(封装为 `ApplyPlanDecision`);`--host` 不存在。 +- `plan deny ` — 全部拒绝(不持久化,同单条 deny)。 +- **TUI**:成员行在 Approvals 队列照常出现,详情行显示 `plan k/N` 与 `[p] decide whole plan`;按 `p` 打开整批 chooser(once/session/deny,无 host),单条 `o/s/h/d` 仍可逐行裁决(操作员可先 `d` 掉个别行,再 `p` 批余下的——这就是"逐行剔除")。 + +## 4. agent 工作流(SKILL.md 已写入) + +``` +1. policy test 逐条预检(免费,发现 deny 提前剔除) +2. plan submit → 拿到 plan_id + 每行 approval_id +3. 转告操作员 → plan wait +4. 全批后逐条 run(与计划同一 --session)→ grant 逐条命中 +``` + +一次部署的操作员交互从 ~10 次降到 1-2 次(计划一次 + 可能的 stdin 写文件一次),审计里每条命令仍独立可回放。 + +## 5. 安全权衡 + +1. **整批批准的粗粒度风险**:操作员可能不逐行细看。缓解:TUI 逐行展示 + 单行可先 deny 再整批;批量档强制 exact matcher + 无 host scope;显式 deny 结构上不可被计划越过。 +2. **计划与 session 绑定**:grant 绑 `(session_id, host)`,换 session 重跑不会搭车。 +3. **成员被单独裁决**:`ApplyPlanDecision` 跳过已决成员(`ErrAlreadyResolved` 容忍),单条与整批两条路径可交错,幂等。 +4. **plan_id 审计字段**:与 stdin 字段同批追加在 `Record`/`canonicalRecord` 末尾(`,omitempty`),升级前旧日志 `audit verify` 逐字节不变(已有 golden 链测试覆盖)。 + +## 6. 测试 + +`cmd/agentssh/plan_test.go`:submit(allowed/denied/pending 分流与退出码合并)、grant --session 后逐条 run 零额外审批、audit plan_id 生命周期 + verify、deny 全批、--file 解析、未开审批 usage error、grant 缺 scope usage error。`internal/tui` 既有测试回归通过。 + +## 7. 未来项 + +- 组 target 的多 host 计划(每 host preflight,与 run 的 group preflight 语义对齐)。 +- 计划行内嵌 stdin(当前 `--stdin-file` 只在 `run` 上;计划行如需 stdin,先单独 run 走 stdin 审批)。 +- TUI 折叠视图(同一计划聚合成一行,展开逐行)——当前逐行 + `[p]` 已可用,聚合视图等实测反馈。 diff --git a/docs/plans/run-stdin.md b/docs/plans/run-stdin.md new file mode 100644 index 0000000..c67c342 --- /dev/null +++ b/docs/plans/run-stdin.md @@ -0,0 +1,50 @@ +# `run --stdin-file` — 远端 stdin 输入 · 设计与实现记录 + +> 状态:已实现 · 起因:实测反馈「往主机写配置文件只能靠 printf 内联,长内容和引号转义都很痛苦;大参数还会撞 Linux `MAX_ARG_STRLEN`(单个 execve 参数 128KiB)」。 +> +> 一句话:`agentssh run --stdin-file -- ` 把本地文件喂给远端命令的 stdin。内容不进审批存储、不进审计日志——两处都只记 `sha256 + 字节数`;灰区审批强制 exact 匹配、禁止升 host 持久规则,grant 绑定「命令 + stdin 内容哈希」。 + +## 1. 为什么是 stdin 而不是先做 push/pull + +- stdin 天然绕开 `MAX_ARG_STRLEN`:内容走管道,不占 execve 参数。 +- 复用现有的一切:policy 引擎照旧只看命令文本;审批、审计、会话完全沿用,零新信任面。 +- `--stdin-file nginx.conf -- tee /etc/nginx/nginx.conf` 直接覆盖「写配置文件」这个最高频场景,也顺带解决小文件上传(base64 分块 hack 从此不需要)。 +- 真正的 `push/pull`(SFTP + 路径维度策略)仍值得做,但它引入新依赖和新策略形态,单独立项。 + +## 2. 行为定义 + +- `run` 新增 `--stdin-file `。文件上限 **32 MiB**(`maxStdinBytes`,`cmd/agentssh/main.go`),超限 usage error(exit 2)。 +- 不传该 flag 时行为逐字节同今(远端 stdin 是 /dev/null)。 +- 两个传输后端都支持:shell-out(`exec.Cmd.Stdin`)与 native(`ssh.Session.Stdin`)。 +- 组 run:同一份内容喂给每个目标 host。 + +## 3. 安全模型(核心) + +stdin 是操作员在审批界面上**看不见的内容**,所以: + +1. **审计**:`audit.Record` 新增 `stdin_sha256` / `stdin_bytes`(`,omitempty`,追加在 `canonicalRecord` 字段表**末尾**——旧记录的规范 JSON 逐字节不变,hash 链不回归)。内容本身永不入日志,日志不会被大 payload 灌满。 +2. **灰区审批强制收紧**(`internal/approval/authorize.go`): + - 候选 matcher 强制 `Exact(command)` 且 `Promotable=false`——即使 `host_grant_mode: prefix` 也不放宽,TUI 不提供 `[h]`。 + - **持久 host 审批规则对 stdin run 一律不生效**:host 规则只见过命令文本,不能为任意输入流背书。 +3. **grant 绑定内容**:`Grant.StdinSHA256` 参与匹配(`session_store.go`)——同命令换内容、同命令去掉 stdin,都不命中 grant,重新走审批。pending 去重键与 `req_digest` 同步纳入 stdin 哈希(空值时不参与,升级前的旧审批单摘要不变)。 +4. **显式 allow 规则对 stdin run 照常生效**:操作员写下 `^tee /etc/nginx/\b` 类 allow 时即视为接受其输入流(命令的参数本就可携带任意内容,这里不额外收紧);要更严可以只依赖审批通道。 + +## 4. 操作员可见性 + +- TUI Approvals:KIND 列显示 `stdin`;详情行显示 `stdin bytes sha256=<12位>… — approval binds to this exact content; host-allow unavailable`。 +- `run --json` 响应带 `stdin_sha256` / `stdin_bytes`,agent 可自行核对喂进去的内容。 + +## 5. 测试 + +- `internal/executor/stdin_test.go`:ExecRunner/streaming 喂 stdin;nil stdin 不阻塞(/dev/null 语义保留)。 +- `cmd/agentssh/run_stdin_test.go`: + - allow 规则 + stdin → 执行、响应/审计带哈希、audit verify 通过; + - `prefix` 模式下 stdin 审批仍 exact、无 host scope、`approval grant --host` 被拒; + - grant 绑定内容:换内容 / 去 stdin → exit 7,不搭车; + - 超限文件 → usage error。 + +## 6. 已知边界 + +- 32 MiB 上限是常量,不是 policy 可调项;需要更大传输时应做 `push/pull` 原语而非调大 stdin。 +- stdin 内容在本地进程内存中整体读入(为了先算哈希再授权);上限保证了内存可控。 +- `policy test` 不感知 stdin(引擎判定只看命令文本,stdin 不改变 allow/deny/needs-approval 三态结论)。 diff --git a/internal/approval/adjudicate.go b/internal/approval/adjudicate.go index c9a7865..c566d64 100644 --- a/internal/approval/adjudicate.go +++ b/internal/approval/adjudicate.go @@ -68,6 +68,17 @@ func ApplyDecision(opts ApplyOptions, id string, verdict Verdict, scope Scope) ( } result.Resolution = resolution + // Commit the approval audit record BEFORE any durable grant. Authorization is + // derived at run time from the grant store (the resolution file is only a + // notification), so ordering audit → grant guarantees a usable grant can + // never exist without its approval_granted record. If the audit append fails + // no grant is written; if a grant write later fails the resolution is stuck + // approved-without-grant, which fails closed (the agent must re-submit) — the + // safe direction versus a silent grant with no audit trail. + if err := appendApprovalAudit(opts, req, verdict, scope); err != nil { + return ApplyResult{}, err + } + switch verdict { case VerdictApproved: if scope == ScopeHost { @@ -86,9 +97,6 @@ func ApplyDecision(opts ApplyOptions, id string, verdict Verdict, scope Scope) ( case VerdictDenied: } - if err := appendApprovalAudit(opts, req, verdict, scope); err != nil { - return ApplyResult{}, err - } return result, nil } @@ -101,7 +109,7 @@ func applySessionGrant(opts ApplyOptions, req PendingRequest, scope Scope) (Gran if ttl <= 0 { ttl = DefaultSessionTTL } - return opts.Sessions.Grant(req.SessionID, req.Host, scope, matcher, req.ID, req.ReqID, ttl, opts.Channel) + return opts.Sessions.Grant(req.SessionID, req.Host, scope, matcher, req.StdinSHA256, req.ID, req.ReqID, ttl, opts.Channel) } func applyHostGrant(opts ApplyOptions, req PendingRequest) (string, error) { @@ -174,6 +182,9 @@ func appendApprovalAudit(opts ApplyOptions, req PendingRequest, verdict Verdict, ApprovalScope: string(scope), ApprovalMatcher: req.Candidate.Regex, ApprovalChannel: opts.Channel, + StdinSHA256: req.StdinSHA256, + StdinBytes: req.StdinBytes, + PlanID: req.PlanID, } _, err := opts.Audit.Append(record) return err diff --git a/internal/approval/authorize.go b/internal/approval/authorize.go index a7ee0f3..ca2e4ba 100644 --- a/internal/approval/authorize.go +++ b/internal/approval/authorize.go @@ -26,17 +26,38 @@ type Authorization struct { ApprovalMatcher Matcher } -func Authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string) (Authorization, error) { - return authorize(cfg, inv, sessionStore, runtime, sessionID, host, command, true) +// Authorize decides one run request. A matching once grant is claimed under +// reqID (two-phase consumption): the caller must settle the claim with +// SessionStore.Commit once the command reaches the remote, or +// SessionStore.Release if it verifiably never executed. +// stdinSHA256 is empty for runs without stdin; when set, grants must carry the +// same stdin hash, persistent host approval rules never match, and the +// candidate matcher is forced to exact and non-promotable. +func Authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string, stdinSHA256 string, reqID string) (Authorization, error) { + if reqID == "" { + return Authorization{}, fmt.Errorf("authorize requires a request id") + } + return authorize(cfg, inv, sessionStore, runtime, sessionID, host, command, stdinSHA256, reqID) } -func PreflightAuthorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string) (Authorization, error) { - return authorize(cfg, inv, sessionStore, runtime, sessionID, host, command, false) +// PreflightAuthorize is the side-effect-free variant used to preview a batch +// before executing any of it. +func PreflightAuthorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string, stdinSHA256 string) (Authorization, error) { + return authorize(cfg, inv, sessionStore, runtime, sessionID, host, command, stdinSHA256, "") } -func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string, consumeOnce bool) (Authorization, error) { +func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string, stdinSHA256 string, claimReqID string) (Authorization, error) { if !runtime.Enabled { - engine, err := policy.NewEngine(cfg, inv) + // A generated approval host rule matches command text only and can never + // vouch for an arbitrary stdin payload. It must not authorize a stdin run + // even when the async approval channel is disabled, so evaluate stdin + // runs against a base engine with those rules stripped. Operator-authored + // allow/deny rules are untouched. + baseCfg := cfg + if stdinSHA256 != "" { + baseCfg, _ = splitPolicy(cfg, host) + } + engine, err := policy.NewEngine(baseCfg, inv) if err != nil { return Authorization{}, err } @@ -70,10 +91,10 @@ func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionS } var grant Grant var ok bool - if consumeOnce { - grant, ok, err = sessionStore.Match(sessionID, host, command) + if claimReqID != "" { + grant, ok, err = sessionStore.Claim(sessionID, host, command, stdinSHA256, claimReqID) } else { - grant, ok, err = sessionStore.Peek(sessionID, host, command) + grant, ok, err = sessionStore.Peek(sessionID, host, command, stdinSHA256) } if err != nil { return Authorization{}, err @@ -86,18 +107,22 @@ func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionS GrantMatcher: grant.Regex, }, nil } - for _, matcher := range hostMatchers { - matches, err := matcher.Match(command) - if err != nil { - return Authorization{}, err - } - if matches { - return Authorization{ - Status: AuthAllowByGrant, - Decision: policy.Decision{Action: policy.ActionAllow, Rule: "approval/host/" + matcherSHA12(matcher)}, - GrantScope: ScopeHost, - GrantMatcher: matcher.Regex, - }, nil + // Persistent host approval rules match the command text only; they cannot + // vouch for an arbitrary stdin payload, so stdin runs skip them entirely. + if stdinSHA256 == "" { + for _, matcher := range hostMatchers { + matches, err := matcher.Match(command) + if err != nil { + return Authorization{}, err + } + if matches { + return Authorization{ + Status: AuthAllowByGrant, + Decision: policy.Decision{Action: policy.ActionAllow, Rule: "approval/host/" + matcherSHA12(matcher)}, + GrantScope: ScopeHost, + GrantMatcher: matcher.Regex, + }, nil + } } } matcher, err := Generalize(command, runtime.HostGrantMode) @@ -107,6 +132,16 @@ func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionS if err != nil { return Authorization{}, err } + if stdinSHA256 != "" { + // The operator sees only the stdin hash and size, never the content, so + // a stdin approval must stay pinned to this exact command + payload and + // must never widen into a persistent host rule. + matcher, err = Exact(command) + if err != nil { + return Authorization{}, err + } + matcher.Promotable = false + } return Authorization{Status: AuthNeedsApproval, Decision: decision, ApprovalMatcher: matcher}, nil } diff --git a/internal/approval/authorize_test.go b/internal/approval/authorize_test.go index 4c72db6..31e7338 100644 --- a/internal/approval/authorize_test.go +++ b/internal/approval/authorize_test.go @@ -25,7 +25,7 @@ func TestAuthorizeApprovalGrantCannotShadowExplicitDeny(t *testing.T) { }}}, }, } - auth, err := Authorize(cfg, inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "systemctl restart prod-db") + auth, err := Authorize(cfg, inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "systemctl restart prod-db", "", "req-test") if err != nil { t.Fatalf("Authorize: %v", err) } @@ -38,10 +38,10 @@ func TestAuthorizeSessionAndHostGrantOnlyAfterDefaultDeny(t *testing.T) { inv := inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}} store := SessionStore{Dir: t.TempDir()} matcher, _ := Exact("systemctl restart nginx") - if _, err := store.Grant("s", "web-1", ScopeSession, matcher, "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant("s", "web-1", ScopeSession, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { t.Fatal(err) } - auth, err := Authorize(policy.Config{}, inv, store, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "systemctl restart nginx") + auth, err := Authorize(policy.Config{}, inv, store, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "systemctl restart nginx", "", "req-test") if err != nil { t.Fatalf("Authorize session: %v", err) } @@ -57,7 +57,7 @@ func TestAuthorizeSessionAndHostGrantOnlyAfterDefaultDeny(t *testing.T) { Group: policy.ApprovalGroup, }}}, }} - auth, err = Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s2", "web-1", "ls /var") + auth, err = Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s2", "web-1", "ls /var", "", "req-test") if err != nil { t.Fatalf("Authorize host: %v", err) } @@ -70,7 +70,7 @@ func TestAuthorizeNewDenyInvalidatesExistingGrant(t *testing.T) { inv := inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}} store := SessionStore{Dir: t.TempDir()} matcher, _ := Exact("id") - if _, err := store.Grant("s", "web-1", ScopeSession, matcher, "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant("s", "web-1", ScopeSession, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { t.Fatal(err) } cfg := policy.Config{Rules: []policy.Rule{{ @@ -78,7 +78,7 @@ func TestAuthorizeNewDenyInvalidatesExistingGrant(t *testing.T) { Match: policy.Match{CmdRegex: `\Aid\z`}, Action: policy.ActionDeny, }}} - auth, err := Authorize(cfg, inv, store, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "id") + auth, err := Authorize(cfg, inv, store, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "id", "", "req-test") if err != nil { t.Fatalf("Authorize: %v", err) } @@ -97,7 +97,7 @@ func TestAuthorizeDisabledApprovalUsesPersistedHostRules(t *testing.T) { Group: policy.ApprovalGroup, }}}, }} - auth, err := Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: false, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "ls /var") + auth, err := Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: false, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "ls /var", "", "req-test") if err != nil { t.Fatalf("Authorize persisted host rule while disabled: %v", err) } @@ -105,7 +105,7 @@ func TestAuthorizeDisabledApprovalUsesPersistedHostRules(t *testing.T) { t.Fatalf("auth = %#v, want raw allow while disabled", auth) } - auth, err = Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: false, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "id") + auth, err = Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: false, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "id", "", "req-test") if err != nil { t.Fatalf("Authorize gray while disabled: %v", err) } diff --git a/internal/approval/fixes_test.go b/internal/approval/fixes_test.go new file mode 100644 index 0000000..30d9145 --- /dev/null +++ b/internal/approval/fixes_test.go @@ -0,0 +1,182 @@ +package approval + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Praeviso/AgentSSH/internal/audit" + "github.com/Praeviso/AgentSSH/internal/inventory" + "github.com/Praeviso/AgentSSH/internal/policy" +) + +// A generated __agentssh_approval host rule matches command text only. It must +// never authorize a stdin run, even when the async approval channel is +// disabled: the operator never saw or hash-bound the payload. +func TestAuthorizeDisabledApprovalSkipsApprovalHostRulesForStdin(t *testing.T) { + cfg := policy.Config{ + HostOverrides: map[string]policy.HostOverride{ + policy.HostRulesKey("web-1"): {Rules: []policy.Rule{{ + Name: "approval/deadbeef", + Match: policy.Match{CmdRegex: `\Atee /etc/app.conf\z`}, + Action: policy.ActionAllow, + Group: policy.ApprovalGroup, + }}}, + }, + } + inv := inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}} + runtime := RuntimeConfig{Enabled: false, HostGrantMode: HostGrantSafePrefix} + store := SessionStore{Dir: t.TempDir()} + + // Without stdin, the operator-visible approval host rule still allows the + // bare command (unchanged behavior). + auth, err := PreflightAuthorize(cfg, inv, store, runtime, "s", "web-1", "tee /etc/app.conf", "") + if err != nil { + t.Fatalf("no-stdin authorize: %v", err) + } + if auth.Status != AuthAllow { + t.Fatalf("no-stdin status = %q, want allow", auth.Status) + } + + // With stdin, the approval host rule is stripped, so the command falls to + // default-deny rather than riding a rule that never vouched for the payload. + auth, err = PreflightAuthorize(cfg, inv, store, runtime, "s", "web-1", "tee /etc/app.conf", "deadbeefstdinhash") + if err != nil { + t.Fatalf("stdin authorize: %v", err) + } + if auth.Status == AuthAllow { + t.Fatalf("stdin run was authorized by an approval host rule: %#v", auth) + } +} + +// An operator-authored (non-approval) allow rule keeps authorizing stdin runs: +// writing such a rule is a deliberate decision, so stdin must not disable it. +func TestAuthorizeDisabledApprovalKeepsOperatorAllowRuleForStdin(t *testing.T) { + cfg := policy.Config{ + HostOverrides: map[string]policy.HostOverride{ + policy.HostRulesKey("web-1"): {Rules: []policy.Rule{{ + Name: "operator-tee", + Match: policy.Match{CmdRegex: `\Atee /etc/app.conf\z`}, + Action: policy.ActionAllow, + }}}, + }, + } + inv := inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}} + runtime := RuntimeConfig{Enabled: false, HostGrantMode: HostGrantSafePrefix} + auth, err := PreflightAuthorize(cfg, inv, SessionStore{Dir: t.TempDir()}, runtime, "s", "web-1", "tee /etc/app.conf", "stdinhash") + if err != nil { + t.Fatalf("authorize: %v", err) + } + if auth.Status != AuthAllow { + t.Fatalf("operator allow rule did not authorize stdin run: %#v", auth) + } +} + +// blockedAuditStore returns an audit.Store whose Append fails: its parent path +// component is a regular file, so MkdirAll inside Append errors. +func blockedAuditStore(t *testing.T) audit.Store { + t.Helper() + root := t.TempDir() + blocker := filepath.Join(root, "not-a-dir") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + return audit.NewStore(filepath.Join(blocker, "audit.log")) +} + +// If the approval_granted audit append fails, no usable grant may exist: +// authorization is derived from the grant store, so a grant without its audit +// record would be a silent, untraceable authorization. +func TestApplyDecisionAuditFailureDoesNotCreateGrant(t *testing.T) { + root := t.TempDir() + pending := PendingStore{ + PendingDir: filepath.Join(root, "pending"), + ResponsesDir: filepath.Join(root, "responses"), + } + matcher, err := Exact("systemctl restart nginx") + if err != nil { + t.Fatal(err) + } + req, err := pending.Create(PendingRequest{ + ReqID: "r1", + SessionID: "s_fail", + Host: "web-1", + Cmd: "systemctl restart nginx", + Candidate: matcher, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + sessions := SessionStore{Dir: filepath.Join(root, "sessions")} + opts := ApplyOptions{ + Pending: pending, + Sessions: sessions, + Audit: blockedAuditStore(t), + Channel: ChannelCLI, + } + if _, err := ApplyDecision(opts, req.ID, VerdictApproved, ScopeSession); err == nil { + t.Fatalf("ApplyDecision succeeded despite audit failure") + } + // The grant store must hold nothing for this session. + if _, ok, err := sessions.Peek("s_fail", "web-1", "systemctl restart nginx", ""); err != nil { + t.Fatalf("Peek: %v", err) + } else if ok { + t.Fatalf("a grant was created despite the approval audit failing") + } +} + +// A mid-batch audit failure in a plan decision must leave no member grant +// without its audit record either. +func TestApplyPlanDecisionAuditFailureDoesNotCreateGrant(t *testing.T) { + root := t.TempDir() + pending := PendingStore{ + PendingDir: filepath.Join(root, "pending"), + ResponsesDir: filepath.Join(root, "responses"), + PlansDir: filepath.Join(root, "plans"), + } + var memberIDs []string + for _, cmd := range []string{"systemctl restart nginx", "systemctl restart redis"} { + matcher, err := Exact(cmd) + if err != nil { + t.Fatal(err) + } + req, err := pending.Create(PendingRequest{ + ReqID: "r_" + cmd, + SessionID: "s_planfail", + Host: "web-1", + Cmd: cmd, + Candidate: matcher, + PlanID: "pl_000000000000000000000000", + }) + if err != nil { + t.Fatalf("Create %q: %v", cmd, err) + } + memberIDs = append(memberIDs, req.ID) + } + manifest, err := pending.CreatePlan(PlanManifest{ + ID: "pl_000000000000000000000000", + SessionID: "s_planfail", + Host: "web-1", + MemberIDs: memberIDs, + }) + if err != nil { + t.Fatalf("CreatePlan: %v", err) + } + sessions := SessionStore{Dir: filepath.Join(root, "sessions")} + opts := ApplyOptions{ + Pending: pending, + Sessions: sessions, + Audit: blockedAuditStore(t), + Channel: ChannelPlan, + } + if _, err := ApplyPlanDecision(opts, manifest.ID, VerdictApproved, ScopeSession); err == nil { + t.Fatalf("ApplyPlanDecision succeeded despite audit failure") + } + for _, cmd := range []string{"systemctl restart nginx", "systemctl restart redis"} { + if _, ok, err := sessions.Peek("s_planfail", "web-1", cmd, ""); err != nil { + t.Fatalf("Peek %q: %v", cmd, err) + } else if ok { + t.Fatalf("plan member %q got a grant despite audit failure", cmd) + } + } +} diff --git a/internal/approval/matcher.go b/internal/approval/matcher.go index 88bb7e6..001ef3f 100644 --- a/internal/approval/matcher.go +++ b/internal/approval/matcher.go @@ -35,6 +35,7 @@ const ( ChannelCLI = "cli" ChannelTUI = "tui" ChannelExit = "exit" + ChannelPlan = "plan" ) // Matcher is the reusable command matcher that can be stored in session grants diff --git a/internal/approval/plan.go b/internal/approval/plan.go new file mode 100644 index 0000000..055decd --- /dev/null +++ b/internal/approval/plan.go @@ -0,0 +1,231 @@ +package approval + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// A plan bundles the gray-zone commands of one multi-step task into a single +// review unit. Approving a plan mints one ordinary once/session grant per +// command — execution still happens per command through `run` → Authorize, so +// audit granularity and explicit-deny precedence are untouched. + +var ( + ErrInvalidPlanID = errors.New("invalid plan id") + ErrPlanNotFound = errors.New("plan not found") + ErrPlanScope = errors.New("plan approvals support --once or --session only") + ErrPlanNoPending = errors.New("plan has no pending requests") + ErrPlansDirUnset = errors.New("plan store directory is not configured") +) + +// PlanManifest is the authoritative membership record for one submitted plan, +// written once (O_EXCL) at submit time. Member requests resolve individually +// through the ordinary pending/response stores. +type PlanManifest struct { + Version int `json:"version"` + ID string `json:"id"` + SessionID string `json:"session_id"` + Host string `json:"host"` + TS string `json:"ts"` + MemberIDs []string `json:"member_ids"` +} + +// PlanMember pairs one member request with its current resolution status. +type PlanMember struct { + ApprovalID string `json:"approval_id"` + Status string `json:"status"` // pending | approved | denied | expired + Scope Scope `json:"scope,omitempty"` + Request *PendingRequest `json:"request,omitempty"` +} + +// PlanStatus is the aggregate view returned by plan status/wait. +type PlanStatus struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Host string `json:"host"` + Status string `json:"status"` // pending | approved | denied | expired + Pending int `json:"pending"` + Approved int `json:"approved"` + Denied int `json:"denied"` + Expired int `json:"expired,omitempty"` + Members []PlanMember `json:"members"` +} + +func NewPlanID() (string, error) { + return newPrefixedID("pl_") +} + +func validPlanID(id string) bool { + return validPrefixedID(id, "pl_") +} + +func (s PendingStore) CreatePlan(manifest PlanManifest) (PlanManifest, error) { + if s.PlansDir == "" { + return PlanManifest{}, ErrPlansDirUnset + } + if manifest.ID == "" { + id, err := NewPlanID() + if err != nil { + return PlanManifest{}, err + } + manifest.ID = id + } + if !validPlanID(manifest.ID) { + return PlanManifest{}, ErrInvalidPlanID + } + if err := os.MkdirAll(s.PlansDir, 0o700); err != nil { + return PlanManifest{}, fmt.Errorf("create plan directory: %w", err) + } + manifest.Version = 1 + if manifest.TS == "" { + manifest.TS = s.now().UTC().Format(time.RFC3339) + } + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return PlanManifest{}, fmt.Errorf("marshal plan manifest: %w", err) + } + file, err := os.OpenFile(planPath(s.PlansDir, manifest.ID), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + return PlanManifest{}, fmt.Errorf("plan id collision %s: %w", manifest.ID, err) + } + if err != nil { + return PlanManifest{}, fmt.Errorf("create plan manifest: %w", err) + } + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() + return PlanManifest{}, fmt.Errorf("write plan manifest: %w", err) + } + if err := file.Close(); err != nil { + return PlanManifest{}, fmt.Errorf("close plan manifest: %w", err) + } + return manifest, nil +} + +func (s PendingStore) GetPlan(id string) (PlanManifest, error) { + if s.PlansDir == "" { + return PlanManifest{}, ErrPlansDirUnset + } + if !validPlanID(id) { + return PlanManifest{}, ErrInvalidPlanID + } + data, err := os.ReadFile(planPath(s.PlansDir, id)) + if errors.Is(err, os.ErrNotExist) { + return PlanManifest{}, ErrPlanNotFound + } + if err != nil { + return PlanManifest{}, fmt.Errorf("read plan manifest: %w", err) + } + var manifest PlanManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return PlanManifest{}, fmt.Errorf("parse plan manifest: %w", err) + } + if manifest.ID != id { + return PlanManifest{}, fmt.Errorf("plan manifest %s id mismatch", id) + } + return manifest, nil +} + +// PlanStatus resolves every member's current status. A member whose pending +// file has been reaped after resolution counts as expired — fail-closed, the +// plan never reports approved from unknowable members — but expiry is kept +// distinct from denied so an approved-then-reaped plan is not misreported as +// operator-rejected. +func (s PendingStore) PlanStatus(id string) (PlanStatus, error) { + manifest, err := s.GetPlan(id) + if err != nil { + return PlanStatus{}, err + } + status := PlanStatus{ID: manifest.ID, SessionID: manifest.SessionID, Host: manifest.Host} + for _, memberID := range manifest.MemberIDs { + member := PlanMember{ApprovalID: memberID, Status: "expired"} + if result, err := s.Status(memberID); err == nil { + member.Status = result.Status + member.Scope = result.Scope + member.Request = result.Request + } + switch member.Status { + case "approved": + status.Approved++ + case "denied": + status.Denied++ + case "expired": + status.Expired++ + default: + status.Pending++ + } + status.Members = append(status.Members, member) + } + switch { + case status.Pending > 0: + status.Status = "pending" + case status.Denied > 0: + status.Status = "denied" + case status.Expired > 0: + status.Status = "expired" + default: + status.Status = "approved" + } + return status, nil +} + +// WaitPlan polls until every member is resolved or the timeout elapses, +// mirroring PendingStore.Wait for single approvals. +func (s PendingStore) WaitPlan(id string, timeout time.Duration) (PlanStatus, error) { + deadline := time.Now().Add(timeout) + sleep := 50 * time.Millisecond + for { + status, err := s.PlanStatus(id) + if err != nil { + return PlanStatus{}, err + } + if status.Pending == 0 { + return status, nil + } + if !time.Now().Before(deadline) { + return status, nil + } + time.Sleep(sleep) + if sleep < 500*time.Millisecond { + sleep *= 2 + } + } +} + +// ApplyPlanDecision adjudicates every still-pending member of a plan with one +// verdict. Approvals are capped at once/session: host promotion widens policy +// permanently and must stay a deliberate per-command decision. +func ApplyPlanDecision(opts ApplyOptions, id string, verdict Verdict, scope Scope) ([]ApplyResult, error) { + if verdict == VerdictApproved && scope != ScopeOnce && scope != ScopeSession { + return nil, ErrPlanScope + } + status, err := opts.Pending.PlanStatus(id) + if err != nil { + return nil, err + } + var results []ApplyResult + for _, member := range status.Members { + if member.Status != "pending" { + continue + } + result, err := ApplyDecision(opts, member.ApprovalID, verdict, scope) + if errors.Is(err, ErrAlreadyResolved) { + continue + } + if err != nil { + return results, fmt.Errorf("plan %s member %s: %w", id, member.ApprovalID, err) + } + results = append(results, result) + } + if len(results) == 0 { + return nil, ErrPlanNoPending + } + return results, nil +} + +func planPath(dir string, id string) string { + return filepath.Join(dir, id+".json") +} diff --git a/internal/approval/plan_test.go b/internal/approval/plan_test.go new file mode 100644 index 0000000..20e2c75 --- /dev/null +++ b/internal/approval/plan_test.go @@ -0,0 +1,94 @@ +package approval + +import ( + "os" + "testing" + "time" +) + +func planTestStore(t *testing.T) PendingStore { + t.Helper() + dir := t.TempDir() + return PendingStore{ + PendingDir: dir + "/pending", + ResponsesDir: dir + "/responses", + PlansDir: dir + "/plans", + } +} + +func mintPlanMember(t *testing.T, store PendingStore, cmd string) PendingRequest { + t.Helper() + matcher, err := Exact(cmd) + if err != nil { + t.Fatal(err) + } + req, err := store.Create(PendingRequest{ + ReqID: "r1", + SessionID: "s_plan", + Host: "web-1", + Cmd: cmd, + Candidate: matcher, + }) + if err != nil { + t.Fatalf("create member: %v", err) + } + return req +} + +// An approved plan whose member records were later reaped must report +// "expired", never "denied": the operator did not reject anything, and the +// agent's fix is to re-submit, not to treat the commands as forbidden. +func TestPlanStatusApprovedThenReapedIsExpiredNotDenied(t *testing.T) { + store := planTestStore(t) + member := mintPlanMember(t, store, "systemctl restart nginx") + manifest, err := store.CreatePlan(PlanManifest{ + SessionID: "s_plan", + Host: "web-1", + MemberIDs: []string{member.ID}, + }) + if err != nil { + t.Fatalf("create plan: %v", err) + } + if _, err := store.Resolve(member, VerdictApproved, ScopeSession); err != nil { + t.Fatalf("resolve: %v", err) + } + status, err := store.PlanStatus(manifest.ID) + if err != nil || status.Status != "approved" || status.Approved != 1 { + t.Fatalf("approved status = %+v err=%v", status, err) + } + + // Simulate the resolved-request reaper removing the member's files. + if err := os.Remove(store.PendingDir + "/" + member.ID + ".json"); err != nil { + t.Fatal(err) + } + if err := os.Remove(store.ResponsesDir + "/" + member.ID + ".json"); err != nil { + t.Fatal(err) + } + status, err = store.PlanStatus(manifest.ID) + if err != nil { + t.Fatalf("expired status err: %v", err) + } + if status.Status != "expired" || status.Expired != 1 || status.Denied != 0 { + t.Fatalf("reaped plan status = %+v, want expired/1/0", status) + } +} + +func TestWaitPlanReturnsOnceAllResolved(t *testing.T) { + store := planTestStore(t) + member := mintPlanMember(t, store, "docker compose up -d") + manifest, err := store.CreatePlan(PlanManifest{ + SessionID: "s_plan", + Host: "web-1", + MemberIDs: []string{member.ID}, + }) + if err != nil { + t.Fatalf("create plan: %v", err) + } + if _, err := store.Resolve(member, VerdictDenied, ""); err != nil { + t.Fatalf("resolve: %v", err) + } + status, err := store.WaitPlan(manifest.ID, time.Second) + if err != nil || status.Status != "denied" || status.Pending != 0 { + t.Fatalf("wait status = %+v err=%v", status, err) + } +} diff --git a/internal/approval/request.go b/internal/approval/request.go index d351cd6..0904c15 100644 --- a/internal/approval/request.go +++ b/internal/approval/request.go @@ -17,6 +17,7 @@ import ( type PendingStore struct { PendingDir string ResponsesDir string + PlansDir string Now func() time.Time } @@ -36,6 +37,18 @@ type PendingRequest struct { Promotable bool `json:"promotable"` TS string `json:"ts"` ProposedScopes []Scope `json:"proposed_scope"` + // StdinSHA256/StdinBytes describe the stdin payload the run would feed the + // command. The content itself never enters the approval store; the operator + // adjudicates on hash + size, and the resulting grant is pinned to the hash. + StdinSHA256 string `json:"stdin_sha256,omitempty"` + StdinBytes int64 `json:"stdin_bytes,omitempty"` + // PlanID/PlanSeq/PlanTotal tag requests minted by one `plan submit` so the + // operator can review and adjudicate the batch as a unit. Authoritative plan + // membership lives in the plan manifest (plans/.json); these fields are + // display metadata on the requests this submit created. + PlanID string `json:"plan_id,omitempty"` + PlanSeq int `json:"plan_seq,omitempty"` + PlanTotal int `json:"plan_total,omitempty"` } type Resolution struct { @@ -63,16 +76,22 @@ var ( ) func NewID() (string, error) { + return newPrefixedID("ap_") +} + +// newPrefixedID mints a + 96-bit random hex identifier; approval and +// plan IDs share this so entropy/format changes apply to both. +func newPrefixedID(prefix string) (string, error) { var bytes [12]byte if _, err := rand.Read(bytes[:]); err != nil { - return "", fmt.Errorf("generate approval id: %w", err) + return "", fmt.Errorf("generate %sid: %w", prefix, err) } - return "ap_" + hex.EncodeToString(bytes[:]), nil + return prefix + hex.EncodeToString(bytes[:]), nil } func (s PendingStore) Create(req PendingRequest) (PendingRequest, error) { _ = s.reapResolved(resolvedReapTTL) - if existing, ok, err := s.findUnresolved(req.SessionID, req.Host, shaHex(req.Cmd)); err != nil { + if existing, ok, err := s.findUnresolved(req.SessionID, req.Host, shaHex(req.Cmd), req.StdinSHA256); err != nil { return PendingRequest{}, err } else if ok { return existing, nil @@ -276,7 +295,7 @@ func (s PendingStore) readResolution(id string) (Resolution, bool, error) { return resolution, true, nil } -func (s PendingStore) findUnresolved(sessionID string, host string, cmdSHA256 string) (PendingRequest, bool, error) { +func (s PendingStore) findUnresolved(sessionID string, host string, cmdSHA256 string, stdinSHA256 string) (PendingRequest, bool, error) { entries, err := os.ReadDir(s.PendingDir) if errors.Is(err, os.ErrNotExist) { return PendingRequest{}, false, nil @@ -293,7 +312,7 @@ func (s PendingStore) findUnresolved(sessionID string, host string, cmdSHA256 st if err != nil { continue } - if req.SessionID != sessionID || req.Host != host || req.CmdSHA256 != cmdSHA256 { + if req.SessionID != sessionID || req.Host != host || req.CmdSHA256 != cmdSHA256 || req.StdinSHA256 != stdinSHA256 { continue } if _, ok, err := s.readResolution(id); err != nil { @@ -341,6 +360,11 @@ func (s PendingStore) reapResolved(ttl time.Duration) error { func RequestDigest(req PendingRequest, scope Scope) string { parts := []string{req.ID, req.ReqID, req.SessionID, req.Host, req.CmdSHA256, req.MatcherSHA256, string(scope)} + // Appended only when present so digests of pre-stdin requests are unchanged + // across an upgrade. + if req.StdinSHA256 != "" { + parts = append(parts, req.StdinSHA256) + } sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) return hex.EncodeToString(sum[:]) } @@ -354,10 +378,14 @@ func proposedScopes(matcher Matcher) []Scope { } func validApprovalID(id string) bool { - if !strings.HasPrefix(id, "ap_") || len(id) < len("ap_")+24 { + return validPrefixedID(id, "ap_") +} + +func validPrefixedID(id string, prefix string) bool { + if !strings.HasPrefix(id, prefix) || len(id) < len(prefix)+24 { return false } - for _, r := range id[len("ap_"):] { + for _, r := range id[len(prefix):] { if (r < '0' || r > '9') && (r < 'a' || r > 'f') { return false } diff --git a/internal/approval/session_store.go b/internal/approval/session_store.go index f4ff00a..bf45619 100644 --- a/internal/approval/session_store.go +++ b/internal/approval/session_store.go @@ -32,6 +32,17 @@ type Grant struct { ApprovalID string `json:"approval_id"` ReqID string `json:"req_id"` Channel string `json:"channel"` + // StdinSHA256 binds the grant to one exact stdin payload. Empty means the + // approved command had no stdin; a grant never matches a run whose stdin + // hash differs from the one the operator approved. + StdinSHA256 string `json:"stdin_sha256,omitempty"` + // ClaimReqID/ClaimTS implement two-phase once-grant consumption: Claim marks + // the grant as reserved by one run request; Commit deletes it once the command + // reached the remote; Release restores it when the command never executed. + // A claim never expires by wall clock: a crash between claim and settle leaves + // the grant unusable (fail-closed), same as a consumed grant. + ClaimReqID string `json:"claim_req_id,omitempty"` + ClaimTS string `json:"claim_ts,omitempty"` } type sessionFile struct { @@ -42,7 +53,7 @@ type sessionFile struct { Grants []Grant `json:"grants"` } -func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher Matcher, approvalID string, reqID string, ttl time.Duration, channel string) (Grant, error) { +func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher Matcher, stdinSHA256 string, approvalID string, reqID string, ttl time.Duration, channel string) (Grant, error) { if scope != ScopeOnce && scope != ScopeSession { return Grant{}, fmt.Errorf("session store cannot grant scope %q", scope) } @@ -51,17 +62,18 @@ func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher } now := s.now() grant := Grant{ - Scope: scope, - Kind: matcher.Kind, - Regex: matcher.Regex, - Prefix: append([]string(nil), matcher.Prefix...), - SourceCmd: matcher.SourceCmd, - Host: host, - GrantedTS: now.UTC().Format(time.RFC3339), - ExpiresTS: now.Add(ttl).UTC().Format(time.RFC3339), - ApprovalID: approvalID, - ReqID: reqID, - Channel: channel, + Scope: scope, + Kind: matcher.Kind, + Regex: matcher.Regex, + Prefix: append([]string(nil), matcher.Prefix...), + SourceCmd: matcher.SourceCmd, + Host: host, + GrantedTS: now.UTC().Format(time.RFC3339), + ExpiresTS: now.Add(ttl).UTC().Format(time.RFC3339), + ApprovalID: approvalID, + ReqID: reqID, + Channel: channel, + StdinSHA256: stdinSHA256, } err := s.withLockedSession(sessionID, func(doc *sessionFile) error { if doc.Version == 0 { @@ -76,7 +88,7 @@ func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher doc.Grants = filterLiveGrants(doc.Grants, now) out := doc.Grants[:0] for _, existing := range doc.Grants { - if existing.Host == host && existing.Scope == scope && existing.Regex == grant.Regex { + if existing.Host == host && existing.Scope == scope && existing.Regex == grant.Regex && existing.StdinSHA256 == grant.StdinSHA256 { continue } out = append(out, existing) @@ -88,15 +100,75 @@ func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher return grant, err } -func (s SessionStore) Match(sessionID string, host string, command string) (Grant, bool, error) { - return s.match(sessionID, host, command, true) +// Peek reports whether a grant would authorize the command without reserving +// or consuming anything. Once grants already claimed by an in-flight run are +// invisible: they can no longer authorize a different request. +func (s SessionStore) Peek(sessionID string, host string, command string, stdinSHA256 string) (Grant, bool, error) { + return s.match(sessionID, host, command, stdinSHA256, "") } -func (s SessionStore) Peek(sessionID string, host string, command string) (Grant, bool, error) { - return s.match(sessionID, host, command, false) +// Claim matches a grant for one run request. A matching once grant is marked +// as claimed by reqID (in the same lock, so two concurrent runs can never +// claim the same once grant); session grants match without side effects. +// The caller must settle every once claim with Commit or Release. +func (s SessionStore) Claim(sessionID string, host string, command string, stdinSHA256 string, reqID string) (Grant, bool, error) { + if reqID == "" { + return Grant{}, false, fmt.Errorf("once-grant claim requires a request id") + } + return s.match(sessionID, host, command, stdinSHA256, reqID) +} + +// Commit consumes every once grant claimed by reqID. Call it as soon as the +// command has been handed to the remote: from that point re-running requires a +// fresh approval. +func (s SessionStore) Commit(sessionID string, reqID string) error { + return s.settleClaims(sessionID, reqID, true) +} + +// Release restores every once grant claimed by reqID. Call it only when the +// command verifiably never executed (local cancel, transport failure before +// the remote ran it, audit append failure before execution). +func (s SessionStore) Release(sessionID string, reqID string) error { + return s.settleClaims(sessionID, reqID, false) +} + +func (s SessionStore) settleClaims(sessionID string, reqID string, consume bool) error { + if sessionID == "" || reqID == "" { + return nil + } + now := s.now() + return s.withLockedSession(sessionID, func(doc *sessionFile) error { + if doc.SessionID == "" { + return nil + } + if doc.SessionID != sessionID { + return fmt.Errorf("session store file mismatch: %q != %q", doc.SessionID, sessionID) + } + changed := false + remaining := doc.Grants[:0] + for _, grant := range doc.Grants { + if grant.Scope != ScopeOnce || grant.ClaimReqID != reqID { + remaining = append(remaining, grant) + continue + } + changed = true + if consume { + continue + } + grant.ClaimReqID = "" + grant.ClaimTS = "" + remaining = append(remaining, grant) + } + if changed { + doc.Grants = remaining + doc.Updated = now.UTC().Format(time.RFC3339) + } + return nil + }) } -func (s SessionStore) match(sessionID string, host string, command string, consumeOnce bool) (Grant, bool, error) { +// match implements Peek (claimReqID == "") and Claim (claimReqID != ""). +func (s SessionStore) match(sessionID string, host string, command string, stdinSHA256 string, claimReqID string) (Grant, bool, error) { if sessionID == "" { return Grant{}, false, nil } @@ -122,6 +194,16 @@ func (s SessionStore) match(sessionID string, host string, command string, consu remaining = append(remaining, grant) continue } + // A grant only covers the exact stdin payload it was approved with. + if grant.StdinSHA256 != stdinSHA256 { + remaining = append(remaining, grant) + continue + } + // A once grant claimed by another in-flight request is spoken for. + if grant.Scope == ScopeOnce && grant.ClaimReqID != "" && grant.ClaimReqID != claimReqID { + remaining = append(remaining, grant) + continue + } matcher := grant.matcher() matches, err := matcher.Match(command) if err != nil { @@ -131,12 +213,13 @@ func (s SessionStore) match(sessionID string, host string, command string, consu remaining = append(remaining, grant) continue } - matched = grant - ok = true - if grant.Scope == ScopeOnce && consumeOnce { + if grant.Scope == ScopeOnce && claimReqID != "" && grant.ClaimReqID != claimReqID { + grant.ClaimReqID = claimReqID + grant.ClaimTS = now.UTC().Format(time.RFC3339) changed = true - continue } + matched = grant + ok = true remaining = append(remaining, grant) } if changed { diff --git a/internal/approval/session_store_test.go b/internal/approval/session_store_test.go index 0e7347e..c92c6fa 100644 --- a/internal/approval/session_store_test.go +++ b/internal/approval/session_store_test.go @@ -1,6 +1,7 @@ package approval import ( + "fmt" "sync" "sync/atomic" "testing" @@ -14,57 +15,123 @@ func TestSessionStoreGrantMatchTTLHostAndEnd(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := store.Grant("s_test", "web-1", ScopeSession, matcher, "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant("s_test", "web-1", ScopeSession, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { t.Fatalf("Grant: %v", err) } - if _, ok, err := store.Match("s_test", "web-2", "systemctl restart nginx"); err != nil || ok { + if _, ok, err := store.Peek("s_test", "web-2", "systemctl restart nginx", ""); err != nil || ok { t.Fatalf("wrong host match ok=%v err=%v", ok, err) } - if grant, ok, err := store.Match("s_test", "web-1", "systemctl restart nginx"); err != nil || !ok || grant.Scope != ScopeSession { + if grant, ok, err := store.Peek("s_test", "web-1", "systemctl restart nginx", ""); err != nil || !ok || grant.Scope != ScopeSession { t.Fatalf("session grant match grant=%#v ok=%v err=%v", grant, ok, err) } now = now.Add(2 * time.Hour) - if _, ok, err := store.Match("s_test", "web-1", "systemctl restart nginx"); err != nil || ok { + if _, ok, err := store.Peek("s_test", "web-1", "systemctl restart nginx", ""); err != nil || ok { t.Fatalf("expired grant match ok=%v err=%v", ok, err) } now = time.Date(2026, 6, 30, 1, 0, 0, 0, time.UTC) - if _, err := store.Grant("s_test", "web-1", ScopeSession, matcher, "ap_0123456789abcdef01234568", "r2", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant("s_test", "web-1", ScopeSession, matcher, "", "ap_0123456789abcdef01234568", "r2", time.Hour, ChannelCLI); err != nil { t.Fatalf("Grant again: %v", err) } if err := store.End("s_test"); err != nil { t.Fatalf("End: %v", err) } - if _, ok, err := store.Match("s_test", "web-1", "systemctl restart nginx"); err != nil || ok { + if _, ok, err := store.Peek("s_test", "web-1", "systemctl restart nginx", ""); err != nil || ok { t.Fatalf("ended session match ok=%v err=%v", ok, err) } } -func TestSessionStoreOnceConcurrentConsumesOnce(t *testing.T) { +func TestSessionStoreOnceConcurrentClaimsOnce(t *testing.T) { store := SessionStore{Dir: t.TempDir()} matcher, err := Exact("id") if err != nil { t.Fatal(err) } - if _, err := store.Grant("s_once", "web-1", ScopeOnce, matcher, "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant("s_once", "web-1", ScopeOnce, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { t.Fatalf("Grant: %v", err) } var matched int32 var wg sync.WaitGroup for i := 0; i < 24; i++ { wg.Add(1) - go func() { + go func(seq int) { defer wg.Done() - if _, ok, err := store.Match("s_once", "web-1", "id"); err != nil { - t.Errorf("Match: %v", err) + if _, ok, err := store.Claim("s_once", "web-1", "id", "", fmt.Sprintf("req-%d", seq)); err != nil { + t.Errorf("Claim: %v", err) } else if ok { atomic.AddInt32(&matched, 1) } - }() + }(i) } wg.Wait() if got := atomic.LoadInt32(&matched); got != 1 { - t.Fatalf("once grant matched %d times, want 1", got) + t.Fatalf("once grant claimed by %d requests, want 1", got) + } +} + +func TestSessionStoreOnceTwoPhaseClaimCommitRelease(t *testing.T) { + store := SessionStore{Dir: t.TempDir()} + matcher, err := Exact("id") + if err != nil { + t.Fatal(err) + } + if _, err := store.Grant("s_2p", "web-1", ScopeOnce, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + t.Fatalf("Grant: %v", err) + } + + // Claim reserves the grant for one request and hides it from everyone else. + if _, ok, err := store.Claim("s_2p", "web-1", "id", "", "req-a"); err != nil || !ok { + t.Fatalf("claim ok=%v err=%v", ok, err) + } + if _, ok, err := store.Peek("s_2p", "web-1", "id", ""); err != nil || ok { + t.Fatalf("claimed grant visible to peek ok=%v err=%v", ok, err) + } + if _, ok, err := store.Claim("s_2p", "web-1", "id", "", "req-b"); err != nil || ok { + t.Fatalf("claimed grant claimable by other request ok=%v err=%v", ok, err) + } + // Re-claim by the same request is idempotent. + if _, ok, err := store.Claim("s_2p", "web-1", "id", "", "req-a"); err != nil || !ok { + t.Fatalf("same-request re-claim ok=%v err=%v", ok, err) + } + + // Release restores the grant for a clean re-run under a new request id. + if err := store.Release("s_2p", "req-a"); err != nil { + t.Fatalf("Release: %v", err) + } + if _, ok, err := store.Claim("s_2p", "web-1", "id", "", "req-c"); err != nil || !ok { + t.Fatalf("released grant not claimable ok=%v err=%v", ok, err) + } + + // Commit consumes it for good. + if err := store.Commit("s_2p", "req-c"); err != nil { + t.Fatalf("Commit: %v", err) + } + if _, ok, err := store.Claim("s_2p", "web-1", "id", "", "req-d"); err != nil || ok { + t.Fatalf("committed grant still claimable ok=%v err=%v", ok, err) + } +} + +func TestSessionStoreCommitReleaseIgnoreOtherClaims(t *testing.T) { + store := SessionStore{Dir: t.TempDir()} + matcher, err := Exact("id") + if err != nil { + t.Fatal(err) + } + if _, err := store.Grant("s_iso", "web-1", ScopeOnce, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + t.Fatalf("Grant: %v", err) + } + if _, ok, err := store.Claim("s_iso", "web-1", "id", "", "req-a"); err != nil || !ok { + t.Fatalf("claim ok=%v err=%v", ok, err) + } + // Settling under an unrelated request id must not touch req-a's claim. + if err := store.Commit("s_iso", "req-x"); err != nil { + t.Fatalf("Commit other: %v", err) + } + if err := store.Release("s_iso", "req-y"); err != nil { + t.Fatalf("Release other: %v", err) + } + if _, ok, err := store.Claim("s_iso", "web-1", "id", "", "req-a"); err != nil || !ok { + t.Fatalf("claim lost after unrelated settle ok=%v err=%v", ok, err) } } @@ -78,19 +145,19 @@ func TestSessionStoreAllowsSameSessionAcrossHosts(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := store.Grant("s_shared", "web-1", ScopeSession, webMatcher, "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant("s_shared", "web-1", ScopeSession, webMatcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { t.Fatalf("Grant web: %v", err) } - if _, err := store.Grant("s_shared", "db-1", ScopeSession, dbMatcher, "ap_0123456789abcdef01234568", "r2", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant("s_shared", "db-1", ScopeSession, dbMatcher, "", "ap_0123456789abcdef01234568", "r2", time.Hour, ChannelCLI); err != nil { t.Fatalf("Grant db: %v", err) } - if grant, ok, err := store.Match("s_shared", "web-1", "systemctl restart web"); err != nil || !ok || grant.Host != "web-1" { + if grant, ok, err := store.Peek("s_shared", "web-1", "systemctl restart web", ""); err != nil || !ok || grant.Host != "web-1" { t.Fatalf("web match grant=%#v ok=%v err=%v", grant, ok, err) } - if grant, ok, err := store.Match("s_shared", "db-1", "systemctl restart db"); err != nil || !ok || grant.Host != "db-1" { + if grant, ok, err := store.Peek("s_shared", "db-1", "systemctl restart db", ""); err != nil || !ok || grant.Host != "db-1" { t.Fatalf("db match grant=%#v ok=%v err=%v", grant, ok, err) } - if _, ok, err := store.Match("s_shared", "db-1", "systemctl restart web"); err != nil || ok { + if _, ok, err := store.Peek("s_shared", "db-1", "systemctl restart web", ""); err != nil || ok { t.Fatalf("cross-host command match ok=%v err=%v", ok, err) } } @@ -102,7 +169,7 @@ func TestSessionStoreEndRemovesDerivedSessions(t *testing.T) { t.Fatal(err) } for _, sessionID := range []string{"s_batch", "s_batch@web-1", "s_batch@web-2", "s_batch_other@web-3"} { - if _, err := store.Grant(sessionID, "web-1", ScopeSession, matcher, "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { + if _, err := store.Grant(sessionID, "web-1", ScopeSession, matcher, "", "ap_0123456789abcdef01234567", "r1", time.Hour, ChannelCLI); err != nil { t.Fatalf("Grant %s: %v", sessionID, err) } } @@ -110,11 +177,11 @@ func TestSessionStoreEndRemovesDerivedSessions(t *testing.T) { t.Fatalf("End: %v", err) } for _, sessionID := range []string{"s_batch", "s_batch@web-1", "s_batch@web-2"} { - if _, ok, err := store.Match(sessionID, "web-1", "id"); err != nil || ok { + if _, ok, err := store.Peek(sessionID, "web-1", "id", ""); err != nil || ok { t.Fatalf("ended session %s match ok=%v err=%v", sessionID, ok, err) } } - if _, ok, err := store.Match("s_batch_other@web-3", "web-1", "id"); err != nil || !ok { + if _, ok, err := store.Peek("s_batch_other@web-3", "web-1", "id", ""); err != nil || !ok { t.Fatalf("unrelated derived session match ok=%v err=%v", ok, err) } } diff --git a/internal/audit/large_record_test.go b/internal/audit/large_record_test.go new file mode 100644 index 0000000..5edebf1 --- /dev/null +++ b/internal/audit/large_record_test.go @@ -0,0 +1,38 @@ +package audit_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/Praeviso/AgentSSH/internal/audit" +) + +// A command string can approach the local execve argument limit (~128 KiB), +// which exceeds the default 64 KiB bufio.Scanner token cap. Appending started +// then completed for such a command re-reads the log to chain; the reader must +// handle the oversized line, and the chain must verify. +func TestAppendVerifyLargeCommandRecords(t *testing.T) { + store := audit.NewStore(filepath.Join(t.TempDir(), "audit.log")) + bigCmd := "echo " + strings.Repeat("x", 100*1024) // ~100 KiB, over the 64 KiB scanner cap + + if _, err := store.Append(audit.Record{ReqID: "r1", Event: audit.EventStarted, Host: "web-1", Cmd: bigCmd}); err != nil { + t.Fatalf("append started: %v", err) + } + exit := 0 + if _, err := store.Append(audit.Record{ReqID: "r1", Event: audit.EventCompleted, Host: "web-1", Cmd: bigCmd, ExitCode: &exit}); err != nil { + t.Fatalf("append completed (re-read of oversized started record failed): %v", err) + } + + records, err := store.ReadAll() + if err != nil { + t.Fatalf("read all: %v", err) + } + if len(records) != 2 || records[0].Cmd != bigCmd || records[1].Cmd != bigCmd { + t.Fatalf("records not round-tripped intact: len=%d", len(records)) + } + result, err := store.Verify() + if err != nil || !result.OK { + t.Fatalf("verify = %#v err=%v", result, err) + } +} diff --git a/internal/audit/record.go b/internal/audit/record.go index 8cd59a6..515f13b 100644 --- a/internal/audit/record.go +++ b/internal/audit/record.go @@ -54,10 +54,22 @@ type Record struct { ApprovalScope string `json:"approval_scope,omitempty"` ApprovalMatcher string `json:"approval_matcher,omitempty"` ApprovalChannel string `json:"approval_channel,omitempty"` + // StdinSHA256/StdinBytes record the stdin payload fed to the remote command + // (content stays out of the log). Appended after the approval fields with + // omitempty so pre-stdin records keep a byte-identical canonical form. + StdinSHA256 string `json:"stdin_sha256,omitempty"` + StdinBytes int64 `json:"stdin_bytes,omitempty"` + // PlanID links approval lifecycle events minted by one `plan submit`. + PlanID string `json:"plan_id,omitempty"` } const ZeroHash = "0000000000000000000000000000000000000000000000000000000000000000" +// maxAuditLineBytes bounds a single JSONL record when reading the log. It sits +// well above a worst-case record (a ~128 KiB command plus JSON escaping and the +// surrounding fields) so the scanner never truncates a line the writer emitted. +const maxAuditLineBytes = 8 << 20 + // Store appends and reads an audit JSONL hash chain. type Store struct { Path string @@ -146,6 +158,11 @@ func readRecords(file *os.File) ([]Record, error) { } var records []Record scanner := bufio.NewScanner(file) + // A record embeds the full command, which can approach the local execve + // argument limit (~128 KiB) before JSON escaping. The default 64 KiB scanner + // token cap would fail to read such a line — breaking append (which re-reads + // the log to chain) and verify. Raise the cap well above any accepted record. + scanner.Buffer(make([]byte, 0, 64*1024), maxAuditLineBytes) for scanner.Scan() { var record Record if err := json.Unmarshal(scanner.Bytes(), &record); err != nil { @@ -364,6 +381,9 @@ type canonicalRecord struct { ApprovalScope string `json:"approval_scope,omitempty"` ApprovalMatcher string `json:"approval_matcher,omitempty"` ApprovalChannel string `json:"approval_channel,omitempty"` + StdinSHA256 string `json:"stdin_sha256,omitempty"` + StdinBytes int64 `json:"stdin_bytes,omitempty"` + PlanID string `json:"plan_id,omitempty"` } func canonicalJSON(record Record) ([]byte, error) { @@ -389,5 +409,8 @@ func canonicalJSON(record Record) ([]byte, error) { ApprovalScope: record.ApprovalScope, ApprovalMatcher: record.ApprovalMatcher, ApprovalChannel: record.ApprovalChannel, + StdinSHA256: record.StdinSHA256, + StdinBytes: record.StdinBytes, + PlanID: record.PlanID, }) } diff --git a/internal/audit/stdin_plan_fields_test.go b/internal/audit/stdin_plan_fields_test.go new file mode 100644 index 0000000..ad1cbc7 --- /dev/null +++ b/internal/audit/stdin_plan_fields_test.go @@ -0,0 +1,65 @@ +package audit_test + +import ( + "path/filepath" + "testing" + + "github.com/Praeviso/AgentSSH/internal/audit" +) + +// TestStdinAndPlanFieldsAreHashProtectedAndOldRecordsVerify mirrors the +// approval-fields regression test for the stdin_sha256/stdin_bytes/plan_id +// additions: records written before these fields existed keep a byte-identical +// canonical form, and tampering with any new field breaks the chain. +func TestStdinAndPlanFieldsAreHashProtectedAndOldRecordsVerify(t *testing.T) { + store := audit.NewStore(filepath.Join(t.TempDir(), "audit.log")) + old, err := store.Append(audit.Record{ReqID: "old", Event: audit.EventStarted, Host: "web-1"}) + if err != nil { + t.Fatalf("append old: %v", err) + } + stamped, err := store.Append(audit.Record{ + ReqID: "r1", + Event: audit.EventStarted, + Host: "web-1", + StdinSHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + StdinBytes: 2048, + PlanID: "pl_0123456789abcdef01234567", + }) + if err != nil { + t.Fatalf("append stamped: %v", err) + } + records := mustRead(t, store) + records[0].Hash = audit.ComputeHash(records[0]) + if records[0].Hash != old.Hash { + t.Fatalf("old hash changed after stdin/plan fields were added: %s != %s", records[0].Hash, old.Hash) + } + if result, err := store.Verify(); err != nil || !result.OK { + t.Fatalf("verify = %#v err=%v", result, err) + } + + fields := []struct { + name string + edit func(*audit.Record) + }{ + {"stdin_sha256", func(r *audit.Record) { + r.StdinSHA256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }}, + {"stdin_bytes", func(r *audit.Record) { r.StdinBytes = 4096 }}, + {"plan_id", func(r *audit.Record) { r.PlanID = "pl_ffffffffffffffffffffffff" }}, + } + for _, tt := range fields { + t.Run(tt.name, func(t *testing.T) { + tampered := append([]audit.Record(nil), records...) + tt.edit(&tampered[1]) + writeRecords(t, store.Path, tampered) + result, err := store.Verify() + if err != nil { + t.Fatalf("verify tamper: %v", err) + } + if result.OK || result.BrokenSeq != stamped.Seq || result.Reason != "hash" { + t.Fatalf("tamper result = %#v", result) + } + writeRecords(t, store.Path, records) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 92b3bfa..3ee1cb4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,6 +86,7 @@ type Paths struct { SessionsDir string PendingDir string ResponsesDir string + PlansDir string } // Config is the parsed local configuration set. @@ -207,6 +208,7 @@ func NewPaths(home string) Paths { SessionsDir: filepath.Join(approvalsDir, "sessions"), PendingDir: filepath.Join(approvalsDir, "pending"), ResponsesDir: filepath.Join(approvalsDir, "responses"), + PlansDir: filepath.Join(approvalsDir, "plans"), } } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 78f5145..f1fdfd8 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -23,6 +23,9 @@ import ( type Request struct { Target inventory.Target Command string + // Stdin, when non-nil, is fed to the remote command's standard input. + // A nil Stdin preserves the historical behavior (/dev/null). + Stdin []byte } // Result captures the outcome of a remote command execution. @@ -51,15 +54,16 @@ type StreamingExecutor interface { } // Runner executes an argv vector. Tests can inject a runner to avoid real SSH. +// stdin is nil for commands without an input stream. type Runner interface { - Run(ctx context.Context, argv []string) RunResult + Run(ctx context.Context, argv []string, stdin []byte) RunResult } // RunnerFunc adapts a function into a Runner. -type RunnerFunc func(ctx context.Context, argv []string) RunResult +type RunnerFunc func(ctx context.Context, argv []string, stdin []byte) RunResult -func (fn RunnerFunc) Run(ctx context.Context, argv []string) RunResult { - return fn(ctx, argv) +func (fn RunnerFunc) Run(ctx context.Context, argv []string, stdin []byte) RunResult { + return fn(ctx, argv, stdin) } // RunResult is the low-level process result from a Runner. @@ -123,7 +127,7 @@ func (e SSHExecutor) Close() error { func (e SSHExecutor) Run(ctx context.Context, request Request) Result { start := time.Now() argv := e.buildArgv(request.Target, request.Command) - runResult := e.Runner.Run(ctx, argv) + runResult := e.Runner.Run(ctx, argv, request.Stdin) result := Result{ Stdout: runResult.Stdout, Stderr: runResult.Stderr, @@ -146,7 +150,7 @@ func (e SSHExecutor) Run(ctx context.Context, request Request) Result { func (e SSHExecutor) RunStreaming(ctx context.Context, request Request, stdout io.Writer, stderr io.Writer) Result { start := time.Now() argv := e.buildArgv(request.Target, request.Command) - runResult := runStreamingProcess(ctx, argv, stdout, stderr) + runResult := runStreamingProcess(ctx, argv, request.Stdin, stdout, stderr) result := Result{ ExitCode: runResult.ExitCode, Duration: time.Since(start), @@ -171,7 +175,7 @@ func (e SSHExecutor) detectOS(ctx context.Context, target inventory.Target) stri } probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - result := e.Runner.Run(probeCtx, e.buildArgv(target, OSProbeCommand)) + result := e.Runner.Run(probeCtx, e.buildArgv(target, OSProbeCommand), nil) if result.Err != nil || result.ExitCode != 0 { return "" } @@ -432,13 +436,16 @@ func sshControlKey(target inventory.Target) string { // ExecRunner executes argv directly with os/exec. type ExecRunner struct{} -func (ExecRunner) Run(ctx context.Context, argv []string) RunResult { +func (ExecRunner) Run(ctx context.Context, argv []string, stdin []byte) RunResult { if len(argv) == 0 { return RunResult{ExitCode: -1, Err: errors.New("empty argv")} } cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Env = scrubbedEnv() + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout @@ -462,13 +469,16 @@ func (ExecRunner) Run(ctx context.Context, argv []string) RunResult { } } -func runStreamingProcess(ctx context.Context, argv []string, stdout io.Writer, stderr io.Writer) RunResult { +func runStreamingProcess(ctx context.Context, argv []string, stdin []byte, stdout io.Writer, stderr io.Writer) RunResult { if len(argv) == 0 { return RunResult{ExitCode: -1, Err: errors.New("empty argv")} } cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.Env = scrubbedEnv() + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } cmd.Stdout = stdout cmd.Stderr = stderr diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 2cd5ef1..09466bd 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -89,7 +89,7 @@ func TestBuildSSHArgvUsesSSHConfigAlias(t *testing.T) { func TestSSHExecutorUsesInjectedRunner(t *testing.T) { var calls [][]string - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { calls = append(calls, append([]string{}, argv...)) if argv[len(argv)-1] == OSProbeCommand { return RunResult{Stdout: "Linux\n", ExitCode: 0} @@ -131,7 +131,7 @@ func TestSSHExecutorUsesInjectedRunner(t *testing.T) { func TestSSHExecutorMultiplexesCommandAndProbe(t *testing.T) { controlDir := t.TempDir() var calls [][]string - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { calls = append(calls, append([]string{}, argv...)) if argv[len(argv)-1] == OSProbeCommand { return RunResult{Stdout: "Linux\n", ExitCode: 0} @@ -179,7 +179,7 @@ func TestSSHExecutorMultiplexesCommandAndProbe(t *testing.T) { func TestSSHExecutorCachesOSPerMuxKey(t *testing.T) { controlDir := t.TempDir() var probes int - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { if argv[len(argv)-1] == OSProbeCommand { probes++ return RunResult{Stdout: "Linux\n", ExitCode: 0} @@ -204,7 +204,7 @@ func TestSSHExecutorCachesOSPerMuxKey(t *testing.T) { func TestSSHExecutorLeavesAliasMuxOptionsToSSHConfig(t *testing.T) { controlDir := t.TempDir() var call []string - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { call = append([]string{}, argv...) return RunResult{ExitCode: 255, Err: errors.New("connect failed")} }) @@ -245,7 +245,7 @@ func TestSSHExecutorStableControlDirSurvivesClose(t *testing.T) { t.Cleanup(func() { _ = os.RemoveAll(cacheRoot) }) t.Setenv("XDG_CACHE_HOME", cacheRoot) var controlPath string - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { if controlPath == "" { controlPath = sshOptionValue(t, argv, "ControlPath") if err := os.WriteFile(controlPath, []byte("socket placeholder"), 0o600); err != nil { @@ -279,7 +279,7 @@ func TestSSHExecutorStableControlDirSurvivesClose(t *testing.T) { func TestSSHExecutorFallsBackWhenControlPathTooLong(t *testing.T) { controlDir := filepath.Join(t.TempDir(), strings.Repeat("long-segment-", 12)) var call []string - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { call = append([]string{}, argv...) return RunResult{ExitCode: 255, Err: errors.New("connect failed")} }) @@ -314,7 +314,7 @@ func TestSSHExecutorInjectsServerAliveIntervalWhenConfigured(t *testing.T) { func TestSSHExecutorMultiplexingCanBeDisabled(t *testing.T) { var calls [][]string - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { calls = append(calls, append([]string{}, argv...)) return RunResult{ExitCode: 255, Err: errors.New("connect failed")} }) @@ -341,7 +341,7 @@ func TestSSHExecutorCloseLeavesConfiguredControlPath(t *testing.T) { t.Fatalf("create short control dir: %v", err) } t.Cleanup(func() { _ = os.RemoveAll(controlDir) }) - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { if controlPath == "" { controlPath = sshOptionValue(t, argv, "ControlPath") if err := os.WriteFile(controlPath, []byte("socket placeholder"), 0o600); err != nil { @@ -378,7 +378,7 @@ func TestSSHExecutorCloseLeavesConfiguredControlPath(t *testing.T) { func TestSSHExecutorDoesNotParseUserStdoutAsOS(t *testing.T) { var probeCalled bool - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { if argv[len(argv)-1] == OSProbeCommand { probeCalled = true return RunResult{Stdout: "Darwin\n", ExitCode: 0} @@ -416,7 +416,7 @@ func TestNormalizeOS(t *testing.T) { func TestRunStreamingProcessWritesToProvidedWriters(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - result := runStreamingProcess(context.Background(), []string{"sh", "-c", "printf out; printf err >&2; exit 7"}, &stdout, &stderr) + result := runStreamingProcess(context.Background(), []string{"sh", "-c", "printf out; printf err >&2; exit 7"}, nil, &stdout, &stderr) if result.ExitCode != 7 || result.Err == nil { t.Fatalf("result = %#v", result) @@ -470,7 +470,7 @@ func TestPrintArgvDemo(t *testing.T) { t.Skip("demo skipped in short mode") } - runner := RunnerFunc(func(_ context.Context, argv []string) RunResult { + runner := RunnerFunc(func(_ context.Context, argv []string, _ []byte) RunResult { fmt.Printf("argv=%#v\n", argv) return RunResult{ExitCode: 0} }) diff --git a/internal/executor/native.go b/internal/executor/native.go index 7c8c752..1bdd34e 100644 --- a/internal/executor/native.go +++ b/internal/executor/native.go @@ -179,6 +179,9 @@ func (e NativeExecutor) runSession(ctx context.Context, request Request, stdout session.Stdout = stdout session.Stderr = stderr + if request.Stdin != nil { + session.Stdin = bytes.NewReader(request.Stdin) + } runErr := runSSHSession(ctx, session, request.Command, closeCurrentClient) exitCode, err := nativeExitCode(runErr) diff --git a/internal/executor/stdin_test.go b/internal/executor/stdin_test.go new file mode 100644 index 0000000..2db03de --- /dev/null +++ b/internal/executor/stdin_test.go @@ -0,0 +1,39 @@ +package executor + +import ( + "bytes" + "context" + "testing" +) + +func TestExecRunnerFeedsStdin(t *testing.T) { + result := ExecRunner{}.Run(context.Background(), []string{"cat"}, []byte("hello stdin")) + if result.Err != nil || result.ExitCode != 0 { + t.Fatalf("cat failed: exit=%d err=%v", result.ExitCode, result.Err) + } + if result.Stdout != "hello stdin" { + t.Fatalf("stdout=%q", result.Stdout) + } +} + +func TestExecRunnerNilStdinKeepsDevNull(t *testing.T) { + // With no stdin the child must see EOF immediately, not block. + result := ExecRunner{}.Run(context.Background(), []string{"cat"}, nil) + if result.Err != nil || result.ExitCode != 0 { + t.Fatalf("cat failed: exit=%d err=%v", result.ExitCode, result.Err) + } + if result.Stdout != "" { + t.Fatalf("stdout=%q want empty", result.Stdout) + } +} + +func TestRunStreamingProcessFeedsStdin(t *testing.T) { + var stdout, stderr bytes.Buffer + result := runStreamingProcess(context.Background(), []string{"cat"}, []byte("streamed"), &stdout, &stderr) + if result.Err != nil || result.ExitCode != 0 { + t.Fatalf("cat failed: exit=%d err=%v", result.ExitCode, result.Err) + } + if stdout.String() != "streamed" { + t.Fatalf("stdout=%q", stdout.String()) + } +} diff --git a/internal/tui/approvals_plan_render_test.go b/internal/tui/approvals_plan_render_test.go new file mode 100644 index 0000000..4044a10 --- /dev/null +++ b/internal/tui/approvals_plan_render_test.go @@ -0,0 +1,86 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Praeviso/AgentSSH/internal/approval" +) + +// planApprovalQueue mixes plan members, a stdin request, and a plain request so +// the render test covers every new consequence-line branch. +func planApprovalQueue(t *testing.T) []approval.PendingRequest { + t.Helper() + first := mkApprovalReq(t, "ap_7f3a1b2c3d4e5f6071829300", "web-1", "s_1a2b3c4d", "systemctl restart nginx") + first.PlanID = "pl_92bf946360961c4b23c4c974" + first.PlanSeq = 1 + first.PlanTotal = 2 + second := mkApprovalReq(t, "ap_5c12aabbccddeeff00112233", "web-1", "s_1a2b3c4d", "docker compose up -d") + second.PlanID = "pl_92bf946360961c4b23c4c974" + second.PlanSeq = 2 + second.PlanTotal = 2 + stdinReq := mkApprovalReq(t, "ap_9d04ffeeddccbbaa99887766", "web-1", "s_1a2b3c4d", "tee /etc/nginx/nginx.conf") + stdinReq.StdinSHA256 = strings.Repeat("ab", 32) + stdinReq.StdinBytes = 2048 + return []approval.PendingRequest{first, second, stdinReq} +} + +func loadedPlanApprovalsApp(t *testing.T) appModel { + t.Helper() + t.Setenv("AGENTSSH_APPROVAL", "1") + m := buildAppWith(t, "version: 1\nhosts: {}\n", "version: 1\n") + m = sized(t, m, 92, 20) + m = press(t, m, "3") + next, _ := m.Update(approvalsLoadedMsg{pending: planApprovalQueue(t)}) + return next.(appModel) +} + +func TestApprovalsPlanMemberShowsPlanHintAndChooser(t *testing.T) { + m := loadedPlanApprovalsApp(t) + view := m.View() + t.Logf("\n%s", view) + if !strings.Contains(view, "plan 1/2") { + t.Errorf("plan position missing:\n%s", view) + } + if !strings.Contains(view, "[p] decide whole plan") { + t.Errorf("plan key hint missing:\n%s", view) + } + + // p opens the whole-plan chooser without a host option. + m = press(t, m, "p") + view = m.View() + t.Logf("\n%s", view) + if !strings.Contains(view, "decide plan pl_92bf9463") { + t.Errorf("plan chooser label missing:\n%s", view) + } + // The chooser itself offers once/session/deny only — no host scope. + if !strings.Contains(view, "[once] session deny") { + t.Errorf("plan chooser options wrong (want once/session/deny only):\n%s", view) + } +} + +func TestApprovalsStdinRowShowsHashAndKind(t *testing.T) { + m := loadedPlanApprovalsApp(t) + m = press(t, m, "G") // focus the stdin request (last row) + view := m.View() + t.Logf("\n%s", view) + if !strings.Contains(view, "stdin 2048 B sha256=abababababab…") { + t.Errorf("stdin identity line missing:\n%s", view) + } + if !strings.Contains(view, "no host-allow") { + t.Errorf("stdin host-unavailable note missing:\n%s", view) + } + if !strings.Contains(view, "stdin") { + t.Errorf("stdin kind label missing:\n%s", view) + } +} + +func TestApprovalsPlanKeyNoopOnNonPlanRow(t *testing.T) { + m := loadedPlanApprovalsApp(t) + m = press(t, m, "G") // stdin request has no plan + m = press(t, m, "p") + view := m.View() + if strings.Contains(view, "decide plan") { + t.Errorf("plan chooser opened on non-plan request:\n%s", view) + } +} diff --git a/internal/tui/approvals_section.go b/internal/tui/approvals_section.go index 4a177e2..d09080f 100644 --- a/internal/tui/approvals_section.go +++ b/internal/tui/approvals_section.go @@ -31,9 +31,12 @@ type approvalsSection struct { choosing bool choiceIdx int choiceID string // request the open chooser targets, so a poll can't misapply it - err error - result string - w, h int + // planMode scopes the open chooser to the focused request's whole plan: + // the verdict applies to every still-pending member in one keystroke. + planMode bool + err error + result string + w, h int } const approvalsPollInterval = 1500 * time.Millisecond @@ -71,7 +74,7 @@ func approvalsTickCmd(enabled ...bool) tea.Cmd { } func (s approvalsSection) pendingStore() approval.PendingStore { - return approval.PendingStore{PendingDir: s.paths.PendingDir, ResponsesDir: s.paths.ResponsesDir} + return approval.PendingStore{PendingDir: s.paths.PendingDir, ResponsesDir: s.paths.ResponsesDir, PlansDir: s.paths.PlansDir} } func (s approvalsSection) loadCmd() tea.Cmd { @@ -142,6 +145,8 @@ func (s approvalsSection) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return s.resolveWith(approval.VerdictApproved, approval.ScopeHost) case "d": return s.resolveWith(approval.VerdictDenied, "") + case "p": + return s.openPlanChooser() case "r": s.clearStatus() return s, s.loadCmd() @@ -173,6 +178,9 @@ func (s approvalsSection) updateChoosing(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "s": return s.resolveWith(approval.VerdictApproved, approval.ScopeSession) case "h": + if s.planMode { + return s, nil + } return s.resolveWith(approval.VerdictApproved, approval.ScopeHost) case "d": return s.resolveWith(approval.VerdictDenied, "") @@ -192,6 +200,25 @@ func (s approvalsSection) openChooser() (tea.Model, tea.Cmd) { } s.clearStatus() s.choosing = true + s.planMode = false + s.choiceIdx = 0 + s.choiceID = req.ID + return s, nil +} + +// openPlanChooser starts the whole-plan verdict menu when the focused request +// belongs to a plan; a no-op otherwise. +func (s approvalsSection) openPlanChooser() (tea.Model, tea.Cmd) { + if !s.runtime.Enabled { + return s, nil + } + req, ok := s.selected() + if !ok || req.PlanID == "" { + return s, nil + } + s.clearStatus() + s.choosing = true + s.planMode = true s.choiceIdx = 0 s.choiceID = req.ID return s, nil @@ -203,8 +230,13 @@ func (s approvalsSection) resolveWith(verdict approval.Verdict, scope approval.S if !s.runtime.Enabled { return s, nil } + planMode := s.planMode s.choosing = false + s.planMode = false s.choiceID = "" + if planMode { + return s.decidePlan(verdict, scope) + } return s.decide(verdict, scope) } @@ -218,6 +250,7 @@ func (s *approvalsSection) resyncChooser() { idx := s.indexOfReq(s.choiceID) if idx < 0 { s.choosing = false + s.planMode = false s.choiceID = "" return } @@ -251,7 +284,9 @@ func (s approvalsSection) choices() []approvalChoice { {"once", approval.VerdictApproved, approval.ScopeOnce}, {"session", approval.VerdictApproved, approval.ScopeSession}, } - if req, ok := s.selected(); ok && req.Candidate.Promotable { + // Whole-plan decisions never offer host scope: persistent widening stays a + // deliberate per-command call. + if req, ok := s.selected(); ok && !s.planMode && req.Candidate.Promotable { out = append(out, approvalChoice{"host", approval.VerdictApproved, approval.ScopeHost}) } return append(out, approvalChoice{"deny", approval.VerdictDenied, ""}) @@ -345,6 +380,58 @@ func (s approvalsSection) decide(verdict approval.Verdict, scope approval.Scope) return s, tea.Batch(s.loadCmd(), toastCmd(decisionToast(req, verdict, scope))) } +// decidePlan applies one verdict to every still-pending member of the focused +// request's plan, mirroring the CLI `plan grant/deny` wiring. +func (s approvalsSection) decidePlan(verdict approval.Verdict, scope approval.Scope) (tea.Model, tea.Cmd) { + req, ok := s.selected() + if !ok || req.PlanID == "" { + return s, nil + } + inv, err := inventory.Load(s.paths.InventoryFile) + if err != nil { + s.err = err + s.result = "" + return s, nil + } + pol, err := policy.Load(s.paths.PolicyFile) + if err != nil { + s.err = err + s.result = "" + return s, nil + } + results, err := approval.ApplyPlanDecision(approval.ApplyOptions{ + Pending: s.pendingStore(), + Sessions: approval.SessionStore{Dir: s.paths.SessionsDir}, + Audit: audit.NewStore(s.paths.AuditFile), + Bundle: policy.Bundle{Policy: pol, Inventory: inv}, + PolicyPath: s.paths.PolicyFile, + SessionTTL: s.runtime.SessionTTL, + Channel: approval.ChannelTUI, + SavePolicy: func(next policy.Config) error { + return policy.Save(s.paths.PolicyFile, next) + }, + }, req.PlanID, verdict, scope) + if err != nil { + s.err = err + s.result = "" + return s, s.loadCmd() + } + s.err = nil + s.result = "" + toast := fmt.Sprintf("plan %s · denied %d command(s)", shortPlanID(req.PlanID), len(results)) + if verdict == approval.VerdictApproved { + toast = fmt.Sprintf("plan %s · approved %d command(s) · %s", shortPlanID(req.PlanID), len(results), scope) + } + return s, tea.Batch(s.loadCmd(), toastCmd(toast)) +} + +func shortPlanID(id string) string { + if len(id) > 11 { + return id[:11] + } + return id +} + func decisionToast(req approval.PendingRequest, verdict approval.Verdict, scope approval.Scope) string { short := shortApprovalID(req.ID) if verdict == approval.VerdictDenied { @@ -366,7 +453,7 @@ func (s approvalsSection) helpKeyMap() help.KeyMap { short: []key.Binding{hk("enter", "decide"), hk("j/k", "move"), hk("r", "refresh")}, full: [][]key.Binding{ {hk("j/k", "move"), hk("g/G", "home/end"), hk("enter", "decide"), hk("r", "refresh")}, - {hk("o", "once"), hk("s", "session"), hk("h", "host"), hk("d", "deny")}, + {hk("o", "once"), hk("s", "session"), hk("h", "host"), hk("d", "deny"), hk("p", "plan")}, }, } } @@ -444,6 +531,8 @@ func approvalRow(req approval.PendingRequest) []string { // the consequence line spells out for the focused row. func kindLabel(req approval.PendingRequest) string { switch { + case req.StdinBytes > 0: + return "stdin" case !req.Candidate.Promotable: return "priv" case req.Candidate.Kind == approval.MatcherPrefix: @@ -458,7 +547,19 @@ func kindLabel(req approval.PendingRequest) string { func (s approvalsSection) consequenceLine(req approval.PendingRequest) string { id := s.styles.cursor.Render(shortApprovalID(req.ID)) sep := s.styles.dim.Render(" · ") + if req.PlanID != "" { + id += sep + s.styles.header.Render(fmt.Sprintf("plan %d/%d", req.PlanSeq, req.PlanTotal)) + + s.styles.dim.Render(" [p] decide whole plan") + } c := req.Candidate + if req.StdinBytes > 0 { + sha := req.StdinSHA256 + if len(sha) > 12 { + sha = sha[:12] + } + return id + sep + s.styles.header.Render(fmt.Sprintf("stdin %d B sha256=%s…", req.StdinBytes, sha)) + + s.styles.dim.Render(" — exact content only; no host-allow") + } switch { case !c.Promotable: return id + sep + s.styles.deny.Render("[h]") + s.styles.dim.Render(" unavailable — privileged command; use once or session") @@ -484,7 +585,13 @@ func (s approvalsSection) chooserLine() string { cells[i] = s.styles.dim.Render(" " + c.label + " ") } } - return s.styles.dim.Render("decide: ") + strings.Join(cells, " ") + label := "decide: " + if s.planMode { + if req, ok := s.selected(); ok { + label = fmt.Sprintf("decide plan %s (all pending lines): ", shortPlanID(req.PlanID)) + } + } + return s.styles.dim.Render(label) + strings.Join(cells, " ") } func (s approvalsSection) visibleRows() int { diff --git a/skills/agentssh-usage/SKILL.md b/skills/agentssh-usage/SKILL.md index 1cc0ff2..41c5a89 100644 --- a/skills/agentssh-usage/SKILL.md +++ b/skills/agentssh-usage/SKILL.md @@ -36,11 +36,20 @@ AgentSSH is the only way you touch managed hosts. You call `agentssh`; the CLI r Optionally label the task on its first run: `--session-label "fix 502 on web-1"`. -3. **Diagnose read-only first.** Inspect before you change anything — status, logs, metrics. Keep output bounded (`-n`, `head`, `--no-pager`); large dumps get truncated and waste context. +3. **Pre-check commands with `policy test` before sending them.** It predicts the exact runtime verdict — `allow`, `deny`, or `needs-approval` — without executing anything or creating approval requests. Use it whenever you are not certain a command is already allowed, and pre-check a whole batch before starting a multi-step change so every approval need surfaces up front instead of one costly round-trip at a time: -4. **Act only after summarizing.** Before a state-changing command (restart, reload, write, delete), state what you found, the risk, and the exact command. Rely on the harness/operator confirmation flow for the go-ahead. + ```bash + agentssh policy test --host web-1 'systemctl status nginx' + agentssh policy test --host web-1 'docker compose -f /opt/app/compose.yml up -d' + ``` + + A `deny` verdict is final — don't send that command at all. A `needs-approval` verdict tells you to bundle it into a plan or flag it to the operator before you begin. -5. **Review via audit.** Hand back the session id and key request ids so the operator can replay the task. A run's `req_id` appears in its `--json` response and in `audit ls`; the human-readable run output omits it. +4. **Diagnose read-only first.** Inspect before you change anything — status, logs, metrics. Keep output bounded (`-n`, `head`, `--no-pager`); large dumps get truncated and waste context. + +5. **Act only after summarizing.** Before a state-changing command (restart, reload, write, delete), state what you found, the risk, and the exact command. Rely on the harness/operator confirmation flow for the go-ahead. + +6. **Review via audit.** Hand back the session id and key request ids so the operator can replay the task. A run's `req_id` appears in its `--json` response and in `audit ls`; the human-readable run output omits it. ```bash agentssh audit ls --session s_1a2b3c4d @@ -52,11 +61,11 @@ AgentSSH is the only way you touch managed hosts. You call `agentssh`; the CLI r ## Practices that matter - **Policy is the safety boundary, not a suggestion.** Exit `6` is final — do not retry the same command, reword it, or look for a syntax that slips past. It means a hard deny or disabled gray-area approval path blocked the command. -- **Never self-approve.** You may read `approval status` / `approval wait`, but you must never run `approval grant`, `approval deny`, edit policy, write approval files, or otherwise approve your own command. If a run returns exit `7`, surface the approval id and exact command to the operator, wait for the operator's decision, then rerun the same command only after approval. +- **Never self-approve.** You may read `approval status` / `approval wait` and `plan status` / `plan wait`, but you must never run `approval grant`, `approval deny`, `plan grant`, `plan deny`, edit policy, write approval files, or otherwise approve your own command. If a run returns exit `7`, surface the approval id and exact command to the operator, wait for the operator's decision, then rerun the same command only after approval. - **One session per task.** Don't reuse a previous task's session id, and don't share one session across unrelated tasks — that merges them in the audit trail. Start a new task → mint a new id. - **Bounded, relevant output.** Prefer targeted commands (`systemctl status`, `journalctl -n`, `ps --sort`) over broad recursive scans. Output filtering may redact secrets and truncate length before results reach you; treat `«REDACTED»` and truncation as expected. - **Prefer `--json` on `run`.** The structured response carries `req_id`, `approval_id`, `redactions`, and `output_truncated`, none of which appear in the human-readable output. Parse it instead of scraping text. -- **`policy test` reports its verdict on stdout, not via exit code.** It prints `allow`, `deny`, or `needs-approval` and exits `0` in all three cases — never chain it as `policy test ... && run ...`. +- **`policy test` before `run`, and read its verdict on stdout, not via exit code.** Pre-checking is free and saves whole approval round-trips; skipping it means discovering `needs-approval` one command at a time. It prints `allow`, `deny`, or `needs-approval` and exits `0` in all three cases — never chain it as `policy test ... && run ...`. - **No destructive exploration.** Never run recursive deletes, mass `kill`, or cleanup as part of diagnosis. If the task needs them, propose them explicitly and let policy + the operator gate it. - **Read exit codes, don't fight them.** `0` ok · `1` remote command failed · `2` usage (e.g. no session declared, missing `--`) · `6` policy denied/final · `7` approval required or still pending · `9` connection failed. A `9` means fix connectivity/inventory, not retry blindly. @@ -90,19 +99,55 @@ Your flow is: `agentssh approval status ` and `agentssh approval wait ` are read-only and agent-safe. Their exit codes are: approved `0`, denied `6`, pending/timeout `7`, malformed or unknown id `2`. +### Plan approvals — one review for a multi-step task + +When a task needs several gray-zone commands, do not submit them one at a time — that costs one operator round-trip per command. Bundle them into a plan: + +```bash +agentssh plan submit web-1 --session s_1a2b3c4d --json -- \ + 'mkdir -p /opt/app/releases' \ + 'docker compose -f /opt/app/compose.yml pull' \ + 'docker compose -f /opt/app/compose.yml up -d' +``` + +Each quoted argument is one complete command (`--file cmds.txt` also works, one command per line). Already-allowed commands are reported as `allowed`; hard-denied ones as `denied` (final — drop them); the rest become one pending approval each under a single `plan_id`. The operator reviews the batch once (TUI `[p]` or `agentssh plan grant --once|--session`), which mints one exact-match grant per command. Then: + +```bash +agentssh plan wait --timeout 10m # 0 all approved · 6 any denied · 7 still pending · 2 records expired (re-submit) +agentssh run web-1 --session s_1a2b3c4d --json -- # run each command as usual +``` + +(A plan queried long after resolution can report `expired` with exit `2` once its approval records are reaped — that is not a denial; re-submit the plan.) + +Execution still happens per command through `run` with full per-command audit; a plan never bypasses explicit deny rules. Submit the plan with the same `--session` you will run under — the grants are bound to that session. + +### Sending stdin — config files without quoting pain + +`run --stdin-file ` streams a local file (up to 32 MiB) to the remote command's stdin, replacing fragile `printf`-quoting and oversized inline arguments: + +```bash +agentssh run web-1 --session s_1a2b3c4d --json --stdin-file nginx.conf -- tee /etc/nginx/nginx.conf +``` + +The content never enters the approval store or audit log — both record only `stdin_sha256` + `stdin_bytes`. A stdin approval is pinned to the exact content hash: change the file and the same command needs a fresh approval, so re-run with byte-identical content after approval. + ## Command reference ```bash agentssh hosts [--json] # list targets (names + tags only; no credentials) agentssh session new # mint a fresh session id for a task agentssh session ls # recent sessions (id / label / span / command count) -agentssh run [--session ] [--session-label ] [--json] -- +agentssh run [--session ] [--session-label ] [--stdin-file ] [--json] [--fields a,b,c] -- agentssh status [--json] # look up a past run's result (exit / denied) agentssh approval status # read approval result: 0 approved, 6 denied, 7 pending agentssh approval wait [--timeout 10m] # wait for approval result, never grants approval +agentssh plan submit --session [--json] -- '' ''... # bundle gray commands into one review +agentssh plan status | wait [--timeout 10m] # 0 approved, 6 denied, 7 pending, 2 expired agentssh audit ls [--session ] | show | verify # browse / inspect / verify the hash chain agentssh policy test --host '' # static check; verdict on stdout (allow/deny/needs-approval), exits 0 either way agentssh tui # interactive audit + policy viewer (operator-facing) ``` +Large-output note: `run --json` truncates the echoed `cmd` field at 2 KiB (`cmd_truncated: true`, full command stays in the audit log; correlate via `cmd_sha256`). Use `--fields req_id,status,exit_code,stdout` to keep responses small when you only need a few fields. + The command after `--` is sent verbatim as one remote command. Bind every run in a task to the same session (via `--session `, or `$AGENTSSH_SESSION` in a persistent shell) so audit groups them by task.