From e8118b5b654070a8db5632d357e75595c0ac7fa2 Mon Sep 17 00:00:00 2001 From: Kritoooo Date: Tue, 30 Jun 2026 21:19:48 +0800 Subject: [PATCH 1/5] feat(approval): async gray-zone approval backend + CLI + design Add an opt-in, async (non-blocking) approval path for gray-zone (default-deny) commands. Explicit deny rules stay unoverridable hard blocks; only Rule=="default-deny" becomes approvable. Backend (internal/approval): injection-safe Generalize (fully \A..\z anchored, [ \t] never \s, byte-level ASCII tokenizer, interpreter/priv/ destructive denylists, safe-prefix default), split-engine Authorize (grants consulted only in the default-deny branch so they never shadow an explicit allow/deny), session grant store (flock+atomic, host-bound, TTL, once consumed under lock), pending/responses with >=96-bit ids, O_EXCL and req_digest (resolution is notification-only; run re-derives via Authorize on re-run). Wiring: run returns exit 7 on NeedsApproval (default-off => unchanged exit 6); multi-host preflights before executing; new operator CLI approval ls/grant/deny + session end, agent-readable approval status/wait. policy exports RuleDefaultDeny + reserves __agentssh_approval group. audit gains approval_requested/granted/denied + 4 omitempty fields mirrored into Record and canonicalRecord (old chains still verify). Verified locally: gofmt/vet/build clean, go test -race ./... all green. Docs: docs/plans/approval-system.md (spec, v1.1 with Codex review fixes), approval-system-codex-review.md, TUI approvals prototypes. TUI tab and overview.md update land in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/agentssh/m5_test.go | 7 + cmd/agentssh/main.go | 600 ++++++++++++++++++--- cmd/agentssh/main_test.go | 187 +++++++ docs/plans/approval-system-codex-review.md | 153 ++++++ docs/plans/approval-system.md | 263 +++++++++ docs/plans/tui-approvals-final.html | 98 ++++ docs/plans/tui-approvals-prototype.html | 171 ++++++ internal/approval/adjudicate.go | 167 ++++++ internal/approval/authorize.go | 158 ++++++ internal/approval/authorize_test.go | 88 +++ internal/approval/config.go | 71 +++ internal/approval/doc.go | 8 +- internal/approval/generalize.go | 273 ++++++++++ internal/approval/generalize_test.go | 133 +++++ internal/approval/matcher.go | 93 ++++ internal/approval/request.go | 313 +++++++++++ internal/approval/request_test.go | 96 ++++ internal/approval/session_store.go | 287 ++++++++++ internal/approval/session_store_test.go | 69 +++ internal/audit/audit_test.go | 57 ++ internal/audit/record.go | 23 +- internal/config/config.go | 11 + internal/config/config_test.go | 6 + internal/policy/policy.go | 32 +- internal/policy/store.go | 11 + internal/policy/store_test.go | 17 + skills/agentssh-usage/SKILL.md | 34 +- 27 files changed, 3353 insertions(+), 73 deletions(-) create mode 100644 docs/plans/approval-system-codex-review.md create mode 100644 docs/plans/approval-system.md create mode 100644 docs/plans/tui-approvals-final.html create mode 100644 docs/plans/tui-approvals-prototype.html create mode 100644 internal/approval/adjudicate.go create mode 100644 internal/approval/authorize.go create mode 100644 internal/approval/authorize_test.go create mode 100644 internal/approval/config.go create mode 100644 internal/approval/generalize.go create mode 100644 internal/approval/generalize_test.go create mode 100644 internal/approval/matcher.go create mode 100644 internal/approval/request.go create mode 100644 internal/approval/request_test.go create mode 100644 internal/approval/session_store.go create mode 100644 internal/approval/session_store_test.go diff --git a/cmd/agentssh/m5_test.go b/cmd/agentssh/m5_test.go index 5c502db..8c8854c 100644 --- a/cmd/agentssh/m5_test.go +++ b/cmd/agentssh/m5_test.go @@ -32,6 +32,13 @@ func withFakeExecutor(t *testing.T, fe fakeExecutor) { t.Cleanup(func() { newExecutor = restore }) } +func withOperatorTTY(t *testing.T) { + t.Helper() + restore := stdinIsTerminal + stdinIsTerminal = func() bool { return true } + t.Cleanup(func() { stdinIsTerminal = restore }) +} + // runExit runs the root command with args and returns the mapped process exit // code (via the same exitCodeForError that execute() uses) plus stdout/stderr. func runExit(t *testing.T, args ...string) (int, string, string) { diff --git a/cmd/agentssh/main.go b/cmd/agentssh/main.go index fc6596d..20c681c 100644 --- a/cmd/agentssh/main.go +++ b/cmd/agentssh/main.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/Praeviso/AgentSSH/internal/approval" "github.com/Praeviso/AgentSSH/internal/audit" "github.com/Praeviso/AgentSSH/internal/config" "github.com/Praeviso/AgentSSH/internal/discovery" @@ -28,11 +29,12 @@ import ( ) const ( - exitOK = 0 - exitRemoteFailed = 1 - exitUsage = 2 - exitPolicyDenied = 6 - exitSSHError = 9 + exitOK = 0 + exitRemoteFailed = 1 + exitUsage = 2 + exitPolicyDenied = 6 + exitApprovalRequired = 7 + exitSSHError = 9 ) const envMasterPassword = "AGENTSSH_MASTER_PASSWORD" @@ -55,6 +57,9 @@ var newExecutor = func(cfg *config.Config) executor.Executor { } var readSecretNoEcho = readSecretFromTTY +var stdinIsTerminal = func() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} func main() { os.Exit(execute()) @@ -128,6 +133,7 @@ func newRootCommand() *cobra.Command { newInventoryCommand(), newSecretCommand(), newPolicyCommand(), + newApprovalCommand(), newAuditCommand(), newSessionCommand(), ) @@ -730,6 +736,70 @@ func newAuditRepairCommand() *cobra.Command { return cmd } +func newApprovalCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "approval", + Short: "Inspect and adjudicate async approvals.", + } + var lsJSON bool + lsCmd := &cobra.Command{ + Use: "ls [--json]", + Short: "List pending approval requests.", + Args: noArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runApprovalLS(cmd, lsJSON) + }, + } + lsCmd.Flags().BoolVar(&lsJSON, "json", false, "emit machine-readable JSON") + + var grantOnce, grantSession, grantHost bool + grantCmd := &cobra.Command{ + Use: "grant --once|--session|--host", + Short: "Approve a pending request.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + scope, err := approvalScopeFromFlags(grantOnce, grantSession, grantHost) + if err != nil { + return err + } + return runApprovalGrant(cmd, args[0], scope) + }, + } + grantCmd.Flags().BoolVar(&grantOnce, "once", false, "approve one rerun") + grantCmd.Flags().BoolVar(&grantSession, "session", false, "approve this session") + grantCmd.Flags().BoolVar(&grantHost, "host", false, "persist an approval host rule") + + denyCmd := &cobra.Command{ + Use: "deny ", + Short: "Deny a pending request.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runApprovalDeny(cmd, args[0]) + }, + } + statusCmd := &cobra.Command{ + Use: "status ", + Short: "Read one approval result.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runApprovalStatus(cmd, args[0]) + }, + } + var waitTimeout string + waitCmd := &cobra.Command{ + Use: "wait [--timeout ]", + Short: "Wait for an approval result.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runApprovalWait(cmd, args[0], waitTimeout) + }, + } + waitCmd.Flags().StringVar(&waitTimeout, "timeout", "", "maximum wait duration, e.g. 10m") + + cmd.AddCommand(lsCmd, grantCmd, denyCmd, statusCmd, waitCmd) + return cmd +} + func newSessionCommand() *cobra.Command { cmd := &cobra.Command{ Use: "session", @@ -758,6 +828,14 @@ func newSessionCommand() *cobra.Command { return nil }, }) + cmd.AddCommand(&cobra.Command{ + Use: "end ", + Short: "Clear approval grants for a session.", + Args: exactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runSessionEnd(cmd, args[0]) + }, + }) return cmd } @@ -806,18 +884,28 @@ type runFlags struct { } type runResponse struct { - ReqID string `json:"req_id"` - SessionID string `json:"session_id"` - Host string `json:"host"` - Status string `json:"status"` - ExitCode int `json:"exit_code"` - DurationMS int64 `json:"duration_ms"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - OutputTruncated bool `json:"output_truncated"` - Redactions int `json:"redactions"` - PolicyAction string `json:"policy_action,omitempty"` - PolicyRule string `json:"policy_rule,omitempty"` + ReqID string `json:"req_id"` + SessionID string `json:"session_id"` + Host string `json:"host"` + Status string `json:"status"` + ExitCode int `json:"exit_code"` + DurationMS int64 `json:"duration_ms"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + OutputTruncated bool `json:"output_truncated"` + Redactions int `json:"redactions"` + PolicyAction string `json:"policy_action,omitempty"` + PolicyRule string `json:"policy_rule,omitempty"` + ApprovalID string `json:"approval_id,omitempty"` + ApprovalMatcher string `json:"approval_matcher,omitempty"` + ProposedScopes []string `json:"proposed_scope,omitempty"` +} + +type runPlan struct { + Target inventory.Target + ReqID string + SessionCtx session.Context + Auth approval.Authorization } type usageError string @@ -848,6 +936,7 @@ var _ = []int{ exitRemoteFailed, exitUsage, exitPolicyDenied, + exitApprovalRequired, exitSSHError, } @@ -1320,7 +1409,7 @@ func resolveOperatorMaster() (string, error) { if master := os.Getenv(envMasterPassword); master != "" { return master, nil } - if !term.IsTerminal(int(os.Stdin.Fd())) { + if !stdinIsTerminal() { return "", newUsageError("%s is required in non-interactive mode", envMasterPassword) } master, err := readSecretNoEcho("Enter AgentSSH master password: ") @@ -1647,6 +1736,10 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag if err != nil { return classifyConfigError(err) } + runtime, err := approvalRuntime(cfg) + if err != nil { + return newUsageError("%v", err) + } resolved, err := inventory.NewResolver(cfg.Inventory).Resolve(targetName) if err != nil { @@ -1657,62 +1750,71 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag return newUsageError("%v\n list hosts and tags with: agentssh hosts", err) } - engine, err := policy.NewEngine(cfg.Policy, cfg.Inventory) - 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) - } outputFilter, err := output.NewFilter(cfg.Policy.Output) if err != nil { return newUsageError("invalid output filter in policy.yaml: %v\n fix the regex under output.redact in ~/.agentssh/policy.yaml", err) } - sessionResolver := session.Resolver{} store := audit.NewStore(cfg.Paths.AuditFile) + sessionStore := approval.SessionStore{Dir: cfg.Paths.SessionsDir} + pendingStore := approval.PendingStore{PendingDir: cfg.Paths.PendingDir, ResponsesDir: cfg.Paths.ResponsesDir} ssh := newExecutor(cfg) + plans, err := buildRunPlans(cfg, resolved, remoteCommand, flags, runtime, sessionStore, runtime.Enabled) + if err != nil { + return err + } + if runtime.Enabled && anyPlanNeedsApproval(plans) { + return handleApprovalPreflightBlock(cmd, pendingStore, store, plans, remoteCommand, flags, resolved) + } + exitCode := exitOK responses := make([]runResponse, 0, len(resolved.Targets)) - for _, target := range resolved.Targets { - reqID, err := newReqID() - if err != nil { - return err - } - // Resolve the declared session id, then derive a host-specific id for - // multi-target runs so a session never spans several hosts. - sessionCtx, err := sessionResolver.Resolve(target.Name, flags.session, flags.sessionLabel) + 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) if err != nil { - if errors.Is(err, session.ErrNoSession) { - return newUsageError("a session must be declared for run\n" + - " pass --session or set AGENTSSH_SESSION (e.g. AGENTSSH_SESSION=$(agentssh session new))\n" + - " one session per task keeps the audit trail grouped by task") - } - return fmt.Errorf("resolve session: %w", err) + return newUsageError("policy.yaml is invalid: %v\n fix the rule in ~/.agentssh/policy.yaml, then re-run (check: agentssh policy show)", err) } - sessionCtx.ID = sessionIDForTarget(sessionCtx.ID, target.Name, len(resolved.Targets) > 1) - decision, err := engine.Evaluate(target.Name, remoteCommand) - if err != nil { - return fmt.Errorf("evaluate policy for %s: %w", target.Name, err) - } - if decision.Action == policy.ActionDeny { - if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, audit.EventDenied, target.Name, remoteCommand, decision, nil, "", 0)); err != nil { + plan.Auth = auth + switch auth.Status { + case approval.AuthHardDeny: + response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied) + if err != nil { return err } - response := runResponse{ - ReqID: reqID, - SessionID: sessionCtx.ID, - Host: target.Name, - Status: "denied", - ExitCode: exitPolicyDenied, - PolicyAction: string(decision.Action), - PolicyRule: decision.Rule, - } if flags.jsonOutput { responses = append(responses, response) - } else { - printDenyHuman(cmd, target.Name, remoteCommand, decision) } exitCode = mergeExitCode(exitCode, exitPolicyDenied) continue + case approval.AuthNeedsApproval: + if !runtime.Enabled { + response, err := appendDeniedRun(cmd, store, plan, remoteCommand, flags, exitPolicyDenied) + if err != nil { + return err + } + if flags.jsonOutput { + responses = append(responses, response) + } + exitCode = mergeExitCode(exitCode, exitPolicyDenied) + continue + } + response, err := appendApprovalPending(cmd, pendingStore, store, plan, remoteCommand, flags) + if err != nil { + return err + } + if flags.jsonOutput { + responses = append(responses, response) + } + exitCode = mergeExitCode(exitCode, exitApprovalRequired) + continue + case approval.AuthAllow, approval.AuthAllowByGrant: + default: + return fmt.Errorf("unknown approval authorization status %q", auth.Status) } + decision := auth.Decision if _, err := store.Append(baseAuditRecord(reqID, sessionCtx, audit.EventStarted, target.Name, remoteCommand, decision, nil, "", 0)); err != nil { return err } @@ -1801,6 +1903,169 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag return nil } +func approvalRuntime(cfg *config.Config) (approval.RuntimeConfig, error) { + if cfg == nil { + return approval.RuntimeConfigFromPolicy(policy.Approval{}, os.Getenv(config.EnvApproval)) + } + return approval.RuntimeConfigFromPolicy(cfg.Policy.Approval, os.Getenv(config.EnvApproval)) +} + +func buildRunPlans(cfg *config.Config, resolved inventory.ResolvedTarget, remoteCommand string, flags runFlags, runtime approval.RuntimeConfig, sessionStore approval.SessionStore, preflight bool) ([]runPlan, error) { + sessionResolver := session.Resolver{} + plans := make([]runPlan, 0, len(resolved.Targets)) + for _, target := range resolved.Targets { + reqID, err := newReqID() + if err != nil { + return nil, err + } + sessionCtx, err := sessionResolver.Resolve(target.Name, flags.session, flags.sessionLabel) + if err != nil { + if errors.Is(err, session.ErrNoSession) { + return nil, newUsageError("a session must be declared for run\n" + + " pass --session or set AGENTSSH_SESSION (e.g. AGENTSSH_SESSION=$(agentssh session new))\n" + + " one session per task keeps the audit trail grouped by task") + } + return nil, fmt.Errorf("resolve session: %w", err) + } + 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) + } else { + auth, err = approval.Authorize(cfg.Policy, cfg.Inventory, sessionStore, runtime, sessionCtx.ID, target.Name, remoteCommand) + } + 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) + } + plans = append(plans, runPlan{Target: target, ReqID: reqID, SessionCtx: sessionCtx, Auth: auth}) + } + return plans, nil +} + +func anyPlanNeedsApproval(plans []runPlan) bool { + for _, plan := range plans { + if plan.Auth.Status == approval.AuthNeedsApproval { + return true + } + } + return false +} + +func handleApprovalPreflightBlock(cmd *cobra.Command, pending approval.PendingStore, store audit.Store, plans []runPlan, remoteCommand string, flags runFlags, resolved inventory.ResolvedTarget) 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) + 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) + if err != nil { + return err + } + responses = append(responses, response) + exitCode = mergeExitCode(exitCode, exitApprovalRequired) + default: + response := runResponse{ + ReqID: plan.ReqID, + SessionID: plan.SessionCtx.ID, + Host: plan.Target.Name, + Status: "not_run", + ExitCode: exitApprovalRequired, + PolicyAction: string(plan.Auth.Decision.Action), + PolicyRule: plan.Auth.Decision.Rule, + } + responses = append(responses, response) + if !flags.jsonOutput { + printNotRunHuman(cmd, plan.Target.Name) + } + exitCode = mergeExitCode(exitCode, exitApprovalRequired) + } + } + 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 { + return err + } + } + if exitCode != exitOK { + return commandExitError{Code: exitCode} + } + 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 { + return runResponse{}, err + } + response := runResponse{ + ReqID: plan.ReqID, + SessionID: plan.SessionCtx.ID, + Host: plan.Target.Name, + Status: "denied", + ExitCode: exitCode, + PolicyAction: string(plan.Auth.Decision.Action), + PolicyRule: plan.Auth.Decision.Rule, + } + if !flags.jsonOutput { + printDenyHuman(cmd, plan.Target.Name, remoteCommand, plan.Auth.Decision) + } + return response, nil +} + +func appendApprovalPending(cmd *cobra.Command, pending approval.PendingStore, store audit.Store, plan runPlan, remoteCommand string, flags runFlags) (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, + }) + 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.ApprovalID = req.ID + record.ApprovalMatcher = req.Candidate.Regex + record.ApprovalChannel = "exit" + if _, err := store.Append(record); err != nil { + return runResponse{}, err + } + response := runResponse{ + ReqID: plan.ReqID, + SessionID: plan.SessionCtx.ID, + Host: plan.Target.Name, + Status: "approval_pending", + ExitCode: exitApprovalRequired, + PolicyAction: string(plan.Auth.Decision.Action), + PolicyRule: plan.Auth.Decision.Rule, + ApprovalID: req.ID, + ApprovalMatcher: req.Candidate.Regex, + ProposedScopes: scopeStrings(req.ProposedScopes), + } + if !flags.jsonOutput { + printApprovalPendingHuman(cmd, plan.Target.Name, req) + } + return response, nil +} + +func scopeStrings(scopes []approval.Scope) []string { + out := make([]string, 0, len(scopes)) + for _, scope := range scopes { + out = append(out, string(scope)) + } + return out +} + func sessionIDForTarget(baseID string, host string, multiTarget bool) string { if !multiTarget || strings.TrimSpace(host) == "" { return baseID @@ -1905,6 +2170,15 @@ func printDenyHuman(cmd *cobra.Command, host string, command string, decision po _, _ = fmt.Fprintln(cmd.ErrOrStderr(), " 如确需放宽,请人类修改 ~/.agentssh/policy.yaml。") } +func printApprovalPendingHuman(cmd *cobra.Command, host string, req approval.PendingRequest) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "! approval required · %s · %s\n", host, req.ID) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " 已提交审批;等待操作者裁决后重跑原命令。候选 matcher: %s\n", req.Candidate.Regex) +} + +func printNotRunHuman(cmd *cobra.Command, host string) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "! not run · %s · waiting for approval on another target\n", host) +} + func statusForResult(result executor.Result) string { if isSSHErrorResult(result) { return "ssh_error" @@ -1937,10 +2211,13 @@ func isSSHErrorResult(result executor.Result) bool { func mergeExitCode(current int, next int) int { // Multi-target runs report the most conservative outcome: - // deny(6) > ssh_error(9) > remote_failed(1) > success(0). + // deny(6) > approval_required(7) > ssh_error(9) > remote_failed(1) > success(0). if current == exitPolicyDenied || next == exitPolicyDenied { return exitPolicyDenied } + if current == exitApprovalRequired || next == exitApprovalRequired { + return exitApprovalRequired + } if current == exitSSHError || next == exitSSHError { return exitSSHError } @@ -2490,6 +2767,8 @@ func policyGroupUsageError(group string, err error) error { return newUsageError("policy group %q not found", strings.TrimSpace(group)) case errors.Is(err, policy.ErrGroupExists): return newUsageError("policy group %q already exists", strings.TrimSpace(group)) + case errors.Is(err, policy.ErrReservedGroup): + return newUsageError("policy group %q is reserved for AgentSSH internals", strings.TrimSpace(group)) default: return err } @@ -2544,14 +2823,16 @@ func runPolicyTest(cmd *cobra.Command, host string, command string) error { if err != nil { return classifyConfigError(err) } - engine, err := policy.NewEngine(cfg.Policy, cfg.Inventory) + runtime, err := approvalRuntime(cfg) 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) + return newUsageError("%v", err) } - decision, err := engine.Evaluate(host, command) + sessionID := os.Getenv(session.EnvSession) + auth, err := approval.PreflightAuthorize(cfg.Policy, cfg.Inventory, approval.SessionStore{Dir: cfg.Paths.SessionsDir}, runtime, sessionID, host, command) if err != nil { - return err + return newUsageError("policy.yaml is invalid: %v\n fix the rule in ~/.agentssh/policy.yaml, then re-run (check: agentssh policy show)", err) } + decision := auth.Decision if host != "" { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s · rule=%s · host=%s\n", decision.Action, decision.Rule, host) return nil @@ -2746,6 +3027,201 @@ func runAuditRepair(cmd *cobra.Command) error { return nil } +func runApprovalLS(cmd *cobra.Command, jsonOutput bool) error { + if err := requireOperatorTTYAndMaster(); err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + requests, err := approvalStore(cfg.Paths).List() + if err != nil { + return err + } + if jsonOutput { + return writeJSON(cmd, requests) + } + out := cmd.OutOrStdout() + if len(requests) == 0 { + _, _ = fmt.Fprintln(out, "(none)") + return nil + } + for _, req := range requests { + _, _ = fmt.Fprintf(out, "%s host=%s session=%s scope=%s cmd=%s matcher=%s\n", req.ID, req.Host, req.SessionID, strings.Join(scopeStrings(req.ProposedScopes), ","), strconv.Quote(req.Cmd), strconv.Quote(req.Candidate.Regex)) + } + return nil +} + +func runApprovalGrant(cmd *cobra.Command, id string, scope approval.Scope) error { + if err := requireOperatorTTYAndMaster(); err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + runtime, err := approvalRuntime(cfg) + if err != nil { + return newUsageError("%v", err) + } + _, err = approval.ApplyDecision(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, approval.VerdictApproved, scope) + if errors.Is(err, approval.ErrPendingNotFound) || errors.Is(err, approval.ErrInvalidID) { + return newUsageError("%v", err) + } + if errors.Is(err, approval.ErrAlreadyResolved) { + return newUsageError("approval %s is already resolved", id) + } + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "approved %s scope=%s\n", id, scope) + return nil +} + +func runApprovalDeny(cmd *cobra.Command, id string) error { + if err := requireOperatorTTYAndMaster(); err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + _, err = approval.ApplyDecision(approval.ApplyOptions{ + Pending: approvalStore(cfg.Paths), + Audit: audit.NewStore(cfg.Paths.AuditFile), + Channel: approval.ChannelCLI, + }, id, approval.VerdictDenied, "") + if errors.Is(err, approval.ErrPendingNotFound) || errors.Is(err, approval.ErrInvalidID) { + return newUsageError("%v", err) + } + if errors.Is(err, approval.ErrAlreadyResolved) { + return newUsageError("approval %s is already resolved", id) + } + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "denied %s\n", id) + return nil +} + +func runApprovalStatus(cmd *cobra.Command, id string) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + status, err := approvalStore(cfg.Paths).Status(id) + if errors.Is(err, approval.ErrPendingNotFound) || errors.Is(err, approval.ErrInvalidID) { + return newUsageError("%v", err) + } + if err != nil { + return err + } + if err := writeJSON(cmd, status); err != nil { + return err + } + return approvalStatusExit(status) +} + +func runApprovalWait(cmd *cobra.Command, id string, timeoutValue string) error { + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + runtime, err := approvalRuntime(cfg) + 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) + } + } + status, err := approvalStore(cfg.Paths).Wait(id, timeout) + if errors.Is(err, approval.ErrPendingNotFound) || errors.Is(err, approval.ErrInvalidID) { + return newUsageError("%v", err) + } + if err != nil { + return err + } + if err := writeJSON(cmd, status); err != nil { + return err + } + return approvalStatusExit(status) +} + +func runSessionEnd(cmd *cobra.Command, id string) error { + if err := requireOperatorTTYAndMaster(); err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return classifyConfigError(err) + } + if err := (approval.SessionStore{Dir: cfg.Paths.SessionsDir}).End(id); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "ended session %s\n", id) + return nil +} + +func approvalStatusExit(status approval.StatusResult) error { + switch status.Status { + case "approved": + return nil + case "denied": + return commandExitError{Code: exitPolicyDenied} + default: + return commandExitError{Code: exitApprovalRequired} + } +} + +func approvalScopeFromFlags(once, sessionScope, host bool) (approval.Scope, error) { + count := 0 + var scope approval.Scope + if once { + count++ + scope = approval.ScopeOnce + } + if sessionScope { + count++ + scope = approval.ScopeSession + } + if host { + count++ + scope = approval.ScopeHost + } + if count != 1 { + return "", newUsageError("approval grant requires exactly one of --once, --session, or --host") + } + return scope, nil +} + +func approvalStore(paths config.Paths) approval.PendingStore { + return approval.PendingStore{PendingDir: paths.PendingDir, ResponsesDir: paths.ResponsesDir} +} + +func requireOperatorTTYAndMaster() error { + if !stdinIsTerminal() { + return newUsageError("operator approval commands require an interactive TTY") + } + _, err := resolveOperatorMaster() + return err +} + func runSessionLS(cmd *cobra.Command) error { cfg, err := config.Load() if err != nil { @@ -2795,11 +3271,11 @@ type eventValue audit.Event func (v *eventValue) Set(value string) error { switch audit.Event(value) { - case "", audit.EventStarted, audit.EventCompleted, audit.EventFailed, audit.EventDenied: + case "", audit.EventStarted, audit.EventCompleted, audit.EventFailed, audit.EventDenied, audit.EventApprovalRequested, audit.EventApprovalGranted, audit.EventApprovalDenied: *v = eventValue(value) return nil default: - return newUsageError("invalid --status %q; expected one of: started, completed, failed, denied", value) + return newUsageError("invalid --status %q; expected one of: started, completed, failed, denied, approval_requested, approval_granted, approval_denied", value) } } diff --git a/cmd/agentssh/main_test.go b/cmd/agentssh/main_test.go index c94543b..a779d60 100644 --- a/cmd/agentssh/main_test.go +++ b/cmd/agentssh/main_test.go @@ -727,6 +727,193 @@ output: } } +func TestApprovalSessionGrantE2E(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true + host_grant_mode: safe-prefix + session_ttl: 12h + wait_timeout: 10m +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + + var calls int32 + withFakeExecutor(t, fakeExecutor{calls: &calls}) + code, stdout, _ := runExit(t, "run", "web-1", "--json", "--", "systemctl", "restart", "nginx") + if code != exitApprovalRequired { + t.Fatalf("initial run exit=%d want 7 stdout=%s", code, stdout) + } + if atomic.LoadInt32(&calls) != 0 { + t.Fatalf("executor ran before approval") + } + var pending runResponse + if err := json.Unmarshal([]byte(stdout), &pending); err != nil { + t.Fatalf("decode pending json: %v\n%s", err, stdout) + } + if pending.Status != "approval_pending" || pending.ApprovalID == "" || !strings.Contains(pending.ApprovalMatcher, `\Asystemctl`) { + t.Fatalf("pending response = %#v", pending) + } + + code, statusOut, _ := runExit(t, "approval", "status", pending.ApprovalID) + if code != exitApprovalRequired || !strings.Contains(statusOut, `"status": "pending"`) { + t.Fatalf("pending status code=%d out=%s", code, statusOut) + } + code, waitOut, _ := runExit(t, "approval", "wait", pending.ApprovalID, "--timeout", "1ms") + if code != exitApprovalRequired || !strings.Contains(waitOut, `"status": "pending"`) { + t.Fatalf("pending wait code=%d out=%s", code, waitOut) + } + + withOperatorTTY(t) + t.Setenv(envMasterPassword, "master") + stdout, stderr, err := runCommandForTest(t, "approval", "grant", pending.ApprovalID, "--session") + if err != nil { + t.Fatalf("approval grant err=%v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "approved "+pending.ApprovalID+" scope=session") { + t.Fatalf("grant stdout = %q", stdout) + } + + code, statusOut, _ = runExit(t, "approval", "status", pending.ApprovalID) + if code != exitOK || !strings.Contains(statusOut, `"status": "approved"`) { + t.Fatalf("approved status code=%d out=%s", code, statusOut) + } + code, stdout, stderr = runExit(t, "run", "web-1", "--json", "--", "systemctl", "restart", "nginx") + if code != exitOK { + t.Fatalf("approved rerun exit=%d stdout=%s stderr=%s", code, stdout, stderr) + } + if atomic.LoadInt32(&calls) != 1 { + t.Fatalf("executor calls=%d want 1", calls) + } + records := mustReadAudit(t, home) + var requested, granted bool + for _, record := range records { + if record.Event == audit.EventApprovalRequested && record.ApprovalID == pending.ApprovalID { + requested = true + } + if record.Event == audit.EventApprovalGranted && record.ApprovalID == pending.ApprovalID && record.ApprovalScope == "session" { + granted = true + } + } + if !requested || !granted { + t.Fatalf("approval audit missing requested=%v granted=%v records=%#v", requested, granted, records) + } +} + +func TestApprovalDisabledGrayStillExit6(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + var calls int32 + withFakeExecutor(t, fakeExecutor{calls: &calls}) + code, stdout, stderr := runExit(t, "run", "web-1", "--json", "--", "id") + if code != exitPolicyDenied { + t.Fatalf("exit=%d want 6 stdout=%s stderr=%s", code, stdout, stderr) + } + if atomic.LoadInt32(&calls) != 0 { + t.Fatalf("executor ran despite default-disabled gray deny") + } + var response runResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatalf("decode response: %v\n%s", err, stdout) + } + if response.Status != "denied" || response.PolicyRule != policy.RuleDefaultDeny || response.ApprovalID != "" { + t.Fatalf("response = %#v", response) + } + if entries, err := os.ReadDir(filepath.Join(home, "approvals", "pending")); err == nil && len(entries) > 0 { + t.Fatalf("pending approvals created while disabled: %#v", entries) + } +} + +func TestApprovalGroupPreflightDoesNotHalfExecute(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true +host_overrides: + host:web-1: + rules: + - match: { cmd_regex: '\Aid\z' } + action: allow +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + var calls int32 + withFakeExecutor(t, fakeExecutor{calls: &calls}) + code, stdout, stderr := runExit(t, "run", "web", "--json", "--", "id") + if code != exitApprovalRequired { + t.Fatalf("exit=%d want 7 stdout=%s stderr=%s", code, stdout, stderr) + } + if atomic.LoadInt32(&calls) != 0 { + t.Fatalf("executor calls=%d want 0", calls) + } + var responses []runResponse + if err := json.Unmarshal([]byte(stdout), &responses); err != nil { + t.Fatalf("decode responses: %v\n%s", err, stdout) + } + if len(responses) != 2 { + t.Fatalf("responses = %#v", responses) + } + statusByHost := map[string]string{} + for _, response := range responses { + statusByHost[response.Host] = response.Status + } + if statusByHost["web-1"] != "not_run" || statusByHost["web-2"] != "approval_pending" { + t.Fatalf("statusByHost = %#v responses=%#v", statusByHost, responses) + } + records := mustReadAudit(t, home) + if len(records) != 1 || records[0].Event != audit.EventApprovalRequested || records[0].Host != "web-2" { + t.Fatalf("audit records = %#v", records) + } +} + +func TestApprovalDenyStatusExit6(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true +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", "--", "id") + if code != exitApprovalRequired { + t.Fatalf("initial exit=%d stdout=%s", code, stdout) + } + var pending runResponse + if err := json.Unmarshal([]byte(stdout), &pending); err != nil { + t.Fatal(err) + } + withOperatorTTY(t) + t.Setenv(envMasterPassword, "master") + if _, _, err := runCommandForTest(t, "approval", "deny", pending.ApprovalID); err != nil { + t.Fatalf("deny err: %v", err) + } + code, statusOut, _ := runExit(t, "approval", "status", pending.ApprovalID) + if code != exitPolicyDenied || !strings.Contains(statusOut, `"status": "denied"`) { + t.Fatalf("denied status code=%d out=%s", code, statusOut) + } +} + func TestPolicyGroupCommands(t *testing.T) { home := t.TempDir() writeInventory(t, home, ` diff --git a/docs/plans/approval-system-codex-review.md b/docs/plans/approval-system-codex-review.md new file mode 100644 index 0000000..52b3fd7 --- /dev/null +++ b/docs/plans/approval-system-codex-review.md @@ -0,0 +1,153 @@ +# Approval System Design Review + +Adversarial review of `docs/plans/approval-system.md` against the current source. The approval package is still only a placeholder, so all approval implementation behavior below is a design review unless explicitly tied to existing source (`internal/approval/doc.go:1-6`). + +## Top Risk Ranking + +1. Critical: the generated prefix regex uses Go `\s`, which matches newline, tab, form feed, carriage return, and space, so a persisted prefix grant can match a later multi-line command even though the design says newline forces exact matching. Evidence: the plan generates `\s+` and trailing `\s*` in `docs/plans/approval-system.md:96-99`, while Go defines `\s` as `[\t\n\f\r ]` in `/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:114-119`. +2. High: the `P0` / `P1` deny-shadow self-check is unsound. A global deny such as exact `systemctl restart nginx` is not detected by probes `systemctl restart` and `systemctl restart X`, while the prefix allow still overlaps it. This matters because the raw engine evaluates host tier before global tier (`internal/policy/policy.go:303-317`) and the architecture explicitly documents that host tier can override a higher-priority global deny (`docs/architecture/overview.md:130-134`). +3. High: `Group:"approval"` collides with existing rule-group provenance. Today `StampGroupOntoHost` writes the caller-supplied group name into `Rule.Group` (`internal/policy/store.go:354-381`), `RemoveHostGroup` deletes by that same field (`internal/policy/store.go:384-404`), and no code reserves the group name `approval` (`internal/policy/store.go:268-285`). +4. High: the same-UID boundary is honest but incomplete. The plan admits no HMAC and same-UID self-approval risk (`docs/plans/approval-system.md:197-204`), but should also explicitly call out direct forging of `responses/`, `sessions/` grants, and `policy.yaml` under the same normal user-owned config tree (`internal/config/config.go:127-139`, `internal/config/config.go:186-194`). +5. Medium: audit hash compatibility is correct only if the new fields are appended with `omitempty` to both `Record` and `canonicalRecord`. The current code uses a hand-written canonical field list (`internal/audit/record.go:331-370`), so a one-sided edit can make new approval data unauthenticated without breaking compilation. +6. Medium: multi-host approval can produce partial execution and ambiguous aggregate exit semantics. The current run loop authorizes and may execute each target before moving to the next (`cmd/agentssh/main.go:1673-1785`), and current aggregation has no exit 7 path (`cmd/agentssh/main.go:1938-1950`). +7. Medium: approval IDs and response files need stronger replay/collision guarantees than the plan currently specifies. Existing audit request IDs are only 3 random bytes (`internal/audit/record.go:63-70`), which is acceptable for audit labels but too small if copied for authorization state. The plan's `ap_7f3a` is an example, not a spec, so this is a HYPOTHESIS risk until the ID format is made explicit (`docs/plans/approval-system.md:37`). +8. Medium: `approval wait` and `status` are underspecified around missing pending files, timeout exit codes, stale responses, and atomic visibility. There is no current approval code beyond the empty package marker (`internal/approval/doc.go:1-6`). +9. Low: the plan overstates `^` / `$` in Go. `\A` / `\z` are the right generated anchors, but Go documents `$` as end-of-text like `\z` unless multiline mode is active (`/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:71-78`). + +## A. Generalization Algorithm + +Verdict: problem. + +Evidence: the current policy engine compiles regexes with `regexp.Compile` and then uses `MatchString` directly (`internal/policy/policy.go:418-427`, `internal/policy/policy.go:303-317`). That means the plan is right that generated approval rules must be fully anchored, because the engine itself does not require full-string matches. The plan quote is: "引擎用非锚定的 `regexp.MatchString` ... 所以 ... 必须 `\A ... \z` 全锚定" (`docs/plans/approval-system.md:74-79`). The code confirms non-anchored matching at `internal/policy/policy.go:305` and default-deny fallback at `internal/policy/policy.go:316`. + +Evidence: Go regexp supports `\A` and `\z`. The local Go 1.25.5 syntax documentation lists `\A` as beginning of text and `\z` as end of text (`/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:71-78`). It also says `$` is "like \z not \Z" unless multiline mode is active (`/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:73-78`). `\Z` is not listed as a Go empty-width anchor in that syntax block (`/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:71-78`). Concrete fix: keep generated approvals on `\A ... \z`, but revise the prose from "cannot use `^`/`$`" to "generated grants must use `\A`/`\z`; `$` is end-of-text in default Go mode but becomes line-sensitive under `(?m)`, and `\Z` is not the Go anchor to use." + +Evidence: the tail character class `[A-Za-z0-9@%+=:,./_-]` places `-` immediately before `]` (`docs/plans/approval-system.md:96-99`). Go documents `A-Z` as the range form inside character classes (`/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:94-101`). A trailing hyphen is conventionally literal and is not between two range endpoints, so this is safe enough, but it is visually easy to misread. Concrete fix: spell the tail class as `[A-Za-z0-9@%+=:,./_\\-]` or put `-` first, and add a table test that the generated regex compiles and accepts a literal `-` argument. + +Evidence: the whitespace story is internally inconsistent and is the highest-risk injection surface. The plan says STEP 1 sends commands containing newline to exact (`docs/plans/approval-system.md:84-87`), but STEP 4 emits prefix regexes with `\s+` separators and trailing `\s*` (`docs/plans/approval-system.md:96-99`). Go `\s` includes newline, tab, form feed, carriage return, and space (`/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:114-119`). Therefore a host prefix approval for a normal command can later match a command containing a newline separator. A concrete bad shape is a prefix grant for `ls` matching `ls\nid`, because `\s+` can consume the newline and the tail token class can consume `id`. Concrete fix: generated grant regexes must not use `\s`; use an ASCII space-only separator such as `[ ]+` and trailing `[ ]*`, or require one literal space layout. Also reject every C0 control byte and every non-ASCII whitespace rune for prefix generation, not only newline. + +Evidence: vertical tab and Unicode whitespace are not consistently addressed. Go `\s` does not include vertical tab, while `[[:space:]]` does (`/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:114-119`, `/home/buyunfeng/.local/share/mise/installs/go/1.25.5/src/regexp/syntax/doc.go:123-136`). The plan does not specify the actual splitter implementation (`docs/plans/approval-system.md:84-99`). HYPOTHESIS: if implementation uses `strings.Fields`, Unicode whitespace may be treated as separators even though the generated regex and the remote shell do not share that model. Concrete fix: define a tiny byte-level tokenizer for the approval algorithm: only ASCII space separates tokens for prefix mode; `\t`, `\n`, `\r`, `\f`, `\v`, NUL, and all non-ASCII whitespace force exact or reject host promotion. + +Evidence: the metacharacter blacklist includes backslash and most shell separators (`docs/plans/approval-system.md:84-87`), so backslash-newline line continuation is not directly generalizable from a source command. The problem is that generated `\s` later admits plain newlines even when the source command had none. `%`, `@`, `,`, and `:` are allowed in the tail class (`docs/plans/approval-system.md:96-99`); they are not shell command separators by themselves, but they may still be command-specific capability wideners. Concrete fix: keep them only if the threat model is "shell separator prevention", not "semantic write prevention", and state that distinction explicitly. + +Evidence: the default `prefix` mode intentionally widens write-class subcommands: the plan says `systemctl restart nginx -> systemctl restart *`, `git push origin main -> git push *`, and `cat /etc/passwd -> cat *` are accepted tradeoffs (`docs/plans/approval-system.md:105-108`). That is a risky default for a least-privilege SSH gateway. The current architecture already warns that command-string matching is heuristic, not a sandbox (`docs/architecture/overview.md:136-138`). Concrete fix: change the default to `safe-prefix` or `exact`; require an explicit operator config or per-decision "promote to broad host prefix" action for write-class subcommands such as `systemctl restart`, `docker run`, `git push`, `kubectl apply`, `helm upgrade`, and package managers. + +Evidence: the anchor invariant self-check cannot catch all dangerous outputs. It can catch missing `\A`, missing `\z`, and `.*`, but it will not catch the `\s` newline problem, an overbroad allowed character class, an unsafe prefix choice, or the unsound deny-shadow probes. The plan claims "不变量" around anchors and no `.*` (`docs/plans/approval-system.md:96-99`) and claims the two probes "绝不少拦" (`docs/plans/approval-system.md:101-104`). Concrete fix: parse generated regexes with `regexp/syntax` and assert a narrow AST shape, then run a mandatory negative corpus containing `\n`, `\r`, `\t`, `\f`, `\v`, `$()`, pipes, redirections, backslash-newline, quotes, glob characters, and Unicode whitespace. For deny overlap, either implement a conservative family-level rule that downgrades to exact when any explicit deny exists under the same command family, or remove the claim that two probes are sound. + +## B. Authorize Split Engine + +Verdict: agree with the split-engine direction, problem with the `Group=="approval"` marker. + +Evidence: current `policy.NewEngine` compiles all top-level rules and all `host_overrides` rules without considering `Rule.Group` (`internal/policy/policy.go:90-130`). Current `Evaluate` checks host rules first, then global rules, then returns `default-deny` (`internal/policy/policy.go:303-317`). Current `hostRulesFor` includes both the concrete `host:` override and inventory group overrides whose tags match the host (`internal/policy/policy.go:319-338`). Therefore a persisted approval allow inside `host_overrides` would shadow a global explicit deny in the raw engine. + +Evidence: the plan recognizes this exact problem: "一条写进 `host_overrides` 的 approval allow 规则,天然有遮蔽掉同 host 显式 deny 的风险" (`docs/plans/approval-system.md:53-64`). Its proposed base-policy pass strips approval rules first, runs `policy.NewEngine` unchanged, and only checks grants after the base decision is `default-deny` (`docs/plans/approval-system.md:57-63`). If implemented everywhere, this does prevent grants from shadowing explicit allow or explicit deny rules, including the case where `host_overrides` is physically first and global has an explicit deny. In that case, the stripped base engine sees the global deny at `internal/policy/policy.go:310-313`, returns a non-default deny, and the grant lookup is never reached. + +Evidence: this guarantee only holds if all decision paths stop using raw `policy.NewEngine` for approval-aware answers. Today `runDirect` creates the raw engine and evaluates it directly (`cmd/agentssh/main.go:1660-1694`), and `agentssh policy test` also creates the raw engine and evaluates it directly (`cmd/agentssh/main.go:2542-2559`). The plan says both should move to `Authorize` (`docs/plans/approval-system.md:66`). Concrete fix: make `Authorize` the only approval-aware decision API and update `run`, `policy test`, and any TUI/effective-policy preview that claims runtime semantics. Keep a separate raw `policy show` or debug command clearly labeled as raw policy order. + +Evidence: `Group=="approval"` is not a safe marker in the current schema. `Rule.Group` already exists (`internal/policy/policy.go:53-60`). `StampGroupOntoHost` copies any named rule group onto a host and sets `copied.Group = groupName` (`internal/policy/store.go:354-381`). `RemoveHostGroup` removes host rules by comparing `rule.Group == groupName` (`internal/policy/store.go:384-404`). `CreateGroup` accepts any non-empty group name and does not reserve `approval` (`internal/policy/store.go:268-285`). The plan's host approval rule shape uses `Group:"approval"` and recommends `RemoveHostGroup(host,"approval")` for bulk removal (`docs/plans/approval-system.md:129-134`). A human-created reusable group named `approval` would collide with the generated approval marker. Concrete fix: reserve a generated-only marker such as `Group:"__agentssh_approval"` and reject that name in `CreateGroup` / `StampGroupOntoHost`, or use a stricter generated-rule predicate such as both `Group == "__agentssh_approval"` and `Name` prefixed by `approval/`. Document that inventory group override keys, rule-group names, and approval provenance are separate concepts. + +Evidence: there is also a scope ambiguity. The plan says "剔除所有 `Group=="approval"` 的 host_override 规则" (`docs/plans/approval-system.md:57`). In current code, `host_overrides` includes both concrete `host:` keys and inventory group override keys (`internal/policy/policy.go:319-338`, `docs/architecture/overview.md:126-128`). Concrete fix: generated persistent approval rules should be written only under `host:` keys, and stripping should be documented as "strip generated approval rules from all host-tier overrides, but generated approvals are only produced under `host:`." If group-level approval is ever added, it needs a separate design review. + +## C. Audit Hash Chain Compatibility + +Verdict: agree if implemented exactly, problem as a maintenance hazard. + +Evidence: current `ComputeHash` clears `record.Hash`, marshals a hand-written `canonicalRecord`, and hashes `prev_hash || canonical_json` (`internal/audit/record.go:320-328`, `internal/audit/record.go:351-370`). The persisted `Record` and canonical projection are separate structs (`internal/audit/record.go:27-49`, `internal/audit/record.go:331-349`). The current optional fields `Error` and `ExitCode` use `omitempty` in both structs (`internal/audit/record.go:41-42`, `internal/audit/record.go:342-343`). + +Evidence: the plan says to append four `omitempty` fields to both `Record` and `canonicalRecord`, and says old records remain byte-identical because empty fields are omitted (`docs/plans/approval-system.md:163-168`). That is correct only if the fields are appended to the end of the canonical struct, existing field tags and order remain unchanged, and the new fields are copied from `Record` into `canonicalRecord`. Old JSON lines that lack the new fields unmarshal to empty strings, and `omitempty` omits those strings during canonical marshaling. + +Evidence: the main breakage risk is two-place sync. If `ApprovalID` or `ApprovalMatcher` is added to `Record` but not to `canonicalRecord`, the approval metadata will be written to audit JSON but will not be hash-protected, because `canonicalJSON` only marshals fields manually listed at `internal/audit/record.go:351-370`. If the fields are added to `canonicalRecord` without `omitempty`, old logs will fail verification because their canonical JSON changes. If the fields are inserted before existing canonical fields, old logs can also fail because Go marshals struct fields in declaration order. + +Concrete fix: keep the plan's `omitempty` requirement, add a golden fixture generated by the current code before the approval change, and add a tamper test proving that changing each new approval field breaks `Verify`. Also add a test that `Record` and `canonicalRecord` stay in sync for every hash-protected approval field. Do not include map-valued approval fields in the canonical record unless deterministic ordering is guaranteed. + +## D. Async Exit-7 Flow + +Verdict: problem / uncertain because critical storage and concurrency details are not specified. + +Evidence: current config paths contain only `Home`, `InventoryFile`, `PolicyFile`, `AuditFile`, and `SecretsFile` (`internal/config/config.go:71-78`), and `NewPaths` maps those to normal files under the resolved home (`internal/config/config.go:186-194`). The approval package has no implementation yet (`internal/approval/doc.go:1-6`). The plan adds `approvals/sessions`, `approvals/pending`, and `approvals/responses` (`docs/plans/approval-system.md:137-151`) but does not fully specify ID entropy, atomic create, stale-file semantics, or response binding. + +Evidence: current audit append is the repository's strongest file-concurrency model: it opens the log, takes `syscall.Flock(LOCK_EX)`, reads records, computes the next hash, and appends while holding the lock (`internal/audit/record.go:72-118`). The plan says session grant read-modify-write should use flock and atomic writes (`docs/plans/approval-system.md:139-149`), which is directionally correct. Concrete fix: require the same discipline for pending and response creation: `O_EXCL` or equivalent no-overwrite creation, atomic write plus fsync where practical, exclusive lock for read-modify-write stores, and readers that tolerate incomplete temp files by ignoring non-final filenames. + +Evidence: replay and binding are underspecified. The response shape in the plan is only `{id, verdict, scope, ts}` (`docs/plans/approval-system.md:148-151`). HYPOTHESIS: if `Authorize` or `wait` trusts only that response, a stale or copied response can be replayed across a different pending request with the same ID. Concrete fix: every response should include and verify a request digest covering `id`, `req_id`, `session_id`, `host`, exact command SHA-256, candidate matcher SHA-256, requested scope, and pending creation timestamp. The grant store should also persist those fields for auditability. Without HMAC this still does not stop same-UID forgery, but it prevents accidental stale replay and catches mismatched files. + +Evidence: approval ID generation is not specified. The current audit request ID helper generates 3 random bytes (`internal/audit/record.go:63-70`); that is too collision-prone for authorization state if copied. The plan's `ap_7f3a` is shown as an example (`docs/plans/approval-system.md:37`), so this is a HYPOTHESIS risk rather than a confirmed design. Concrete fix: specify at least 96 bits of randomness for approval IDs, use collision-resistant create semantics, and treat ID collision as a hard error. + +Evidence: approval wait polling is underspecified. The plan says `approval wait` polls resolution until result or timeout (`docs/plans/approval-system.md:153-161`). There is no current code for this (`internal/approval/doc.go:1-6`). Concrete fix: specify exact behavior for missing pending ID, expired pending ID, denied response, timeout, interrupted wait, malformed response, and stale response with no pending. Also specify exit codes for `approval wait`: for example approved = 0, denied = 6, timeout/no result = 7 or 1, malformed = 1, usage = 2. If `wait` returns JSON, keep the exit code contract equally explicit. + +Evidence: real-time re-evaluate on rerun is the right security model. The current `runDirect` loads config and creates the engine at process start (`cmd/agentssh/main.go:1645-1664`), so a separate rerun after approval will reload policy from disk. The plan says execution happens on the rerun and must re-run `Authorize`, with grants effective only while base decision is still `default-deny` (`docs/plans/approval-system.md:48-52`). Concrete fix: make grant consumption part of a single `Authorize` call immediately before appending `started`, and consume once grants under lock so two concurrent reruns cannot spend the same once grant. + +Evidence: multi-host/group behavior is currently per-target and can be partial. `runDirect` loops over `resolved.Targets`, resolves a host-specific session id, evaluates policy, and may execute that host before processing later hosts (`cmd/agentssh/main.go:1673-1785`). Current aggregation prefers deny over SSH error over remote failure over success (`cmd/agentssh/main.go:1938-1950`). The plan adds exit 7 with priority `6 > 7 > 9 > 1 > 0` (`docs/plans/approval-system.md:183-195`). Concrete fix: either preflight all target authorizations before executing any target in approval-enabled mode, or explicitly document that group runs can partially execute and require harnesses to inspect per-host JSON. For non-JSON output, print every host's pending/denied/executed state before returning the aggregate exit code. + +## E. Self-Approval Boundary + +Verdict: problem, although the document is more honest than most designs. + +Evidence: the plan explicitly says resolution files are not HMAC-signed (`docs/plans/approval-system.md:151`) and says same-UID unrestricted agents can self-approve or edit policy (`docs/plans/approval-system.md:197-204`). That is honest. The hidden risk is broader than the CLI verbs. Since `AGENTSSH_HOME` resolves to a normal user-owned directory (`internal/config/config.go:127-139`) and all AgentSSH files live under normal paths (`internal/config/config.go:186-194`), a same-UID agent that can write the filesystem can forge response files, session grant files, host approval rules in `policy.yaml`, or remove/replace pending files. File mode `0600` / directory mode `0700` protects against other users, not against the same UID (`internal/config/config.go:141-180`). + +Evidence: the current operator master-password guard is for existing secret-management operator flows, not a general filesystem boundary. `resolveOperatorMaster` reads `AGENTSSH_MASTER_PASSWORD` or prompts on a TTY (`cmd/agentssh/main.go:1319-1334`). That can gate a future `approval grant` command, but it does not stop a same-UID process from writing files directly under `$AGENTSSH_HOME`. + +Concrete fix: amend §11 to state explicitly that, without OS-user separation or HMAC, `approval wait` seeing `approved` proves only that an approved-looking response file exists, not that a human operator wrote it. Also state that same-UID agents can forge `responses/`, `sessions/` grants, `pending/`, and `policy.yaml`. If the desired boundary is stronger than convenience plus audit, add HMAC over response/grant files using a secret not available to the agent, or require operator and agent to run under different OS users with filesystem ACLs. + +## F. Consistency With Existing Architecture Overview §5 + +Verdict: uncertain / requires doc revision. + +Evidence: the current overview states that TUI is a pure audit viewer and contains no approval (`docs/architecture/overview.md:41-50`). Its §5 says gray-area interactive approval is delegated to the harness, and that any future out-of-band approval would be a pending queue where `run` blocks waiting (`docs/architecture/overview.md:158-165`). The approval plan says it is asynchronous and non-blocking, returns exit 7 immediately, and adds an Approvals TUI tab (`docs/plans/approval-system.md:30-46`, `docs/plans/approval-system.md:171-181`). Those are material changes to the overview, not just implementation details. + +Evidence: the plan can still extend rather than invert the hard-deny baseline if the split-engine rule is followed. Current code treats a deny decision as audit `denied`, response status `denied`, and aggregate exit 6 (`cmd/agentssh/main.go:1694-1713`). The plan preserves explicit deny as hard deny and only diverts `Rule == "default-deny"` into approval (`docs/plans/approval-system.md:18-29`, `docs/plans/approval-system.md:53-64`). That is consistent with the baseline if approval is default-off and explicit denies are structurally unreachable by grants. + +Evidence: the current exit-code contract has no exit 7. Constants are `0`, `1`, `2`, `6`, and `9` (`cmd/agentssh/main.go:30-36`), top-level `commandExitError` controls process exit (`cmd/agentssh/main.go:81-92`), and merge aggregation knows only the existing four nonzero outcomes (`cmd/agentssh/main.go:1938-1950`). The plan adds exit 7 and says approval is default-off (`docs/plans/approval-system.md:183-195`). Default-off is sufficient for backward compatibility only if disabled behavior remains exactly today's default-deny exit 6 path. + +Concrete fix: update `docs/architecture/overview.md` in the plan itself before implementation: replace "future out-of-band synchronous approval" with "optional default-off asynchronous approval", preserve the explicit deny baseline, and revise the TUI/package sections. Also add a migration note that existing scripts keep seeing exit 6 unless `approval.enabled` or `AGENTSSH_APPROVAL` is explicitly enabled. + +## G. File:Line Discrepancies + +Verdict: agree. I found no direct mismatches in the plan's explicit file:line citations. + +Evidence: + +| Plan citation | Current source check | Result | +|---|---|---| +| `policy.go:316` in `docs/plans/approval-system.md:28` | `internal/policy/policy.go:316` returns `Decision{Action: ActionDeny, Rule: "default-deny"}` | Accurate | +| `policy.go:305` in `docs/plans/approval-system.md:76` | `internal/policy/policy.go:305` calls `rule.regex.MatchString(command)` | Accurate | +| `policy.go:418` in `docs/plans/approval-system.md:82` | `internal/policy/policy.go:418` starts `compileRegex`; `regexp.Compile` is at `internal/policy/policy.go:422` | Accurate, but cite both lines if precision matters | +| `store.go:237` in `docs/plans/approval-system.md:129` | `internal/policy/store.go:237` starts `AddHostRule` | Accurate | +| `store.go:386` in `docs/plans/approval-system.md:134` | `internal/policy/store.go:386` starts `RemoveHostGroup` | Accurate | +| `main.go:1694` in `docs/plans/approval-system.md:216` | `cmd/agentssh/main.go:1694` is `if decision.Action == policy.ActionDeny` | Accurate | +| `main.go:808` in `docs/plans/approval-system.md:216` | `cmd/agentssh/main.go:808` starts `runResponse` | Accurate | +| `main.go` "约 :30" in `docs/plans/approval-system.md:195` | `cmd/agentssh/main.go:30-36` contains the exit-code constants | Accurate | + +Evidence: non-line-specific references should be made more precise before implementation. The audit plan references `audit/record.go` without exact lines (`docs/plans/approval-system.md:165-168`, `docs/plans/approval-system.md:218`); the current relevant lines are `Record` at `internal/audit/record.go:27-49`, `ComputeHash` at `internal/audit/record.go:320-328`, and `canonicalRecord` / `canonicalJSON` at `internal/audit/record.go:331-370`. + +Concrete fix: no file:line corrections are required for the explicit citations, but revise the plan to cite `internal/audit/record.go:27-49`, `internal/audit/record.go:320-328`, and `internal/audit/record.go:331-370` where it discusses audit hash compatibility. + +## Checklist + +### Must Fix + +- Replace generated `\s+` and `\s*` with ASCII-space-only matching, and reject control/unicode whitespace for prefix-mode grants. +- Change the default host grant mode from broad `prefix` to `safe-prefix` or `exact`, or require an explicit operator opt-in for write-class prefix promotions. +- Remove the claim that the `P0` / `P1` deny-shadow probe is sound; either make it conservative by downgrading whole command families to exact when explicit denies exist, or rely solely on the split engine and document the raw-policy caveat. +- Stop using `Group:"approval"` as an unreserved marker; reserve a generated-only provenance value or use a stricter generated-rule predicate. +- Make `run`, `policy test`, and any runtime-semantics preview use `Authorize`; keep raw policy inspection clearly labeled as raw. +- Specify approval ID entropy, atomic create/no-overwrite behavior, response request binding, TTL, and GC rules. +- Explicitly document same-UID forging of `responses/`, session grants, pending files, and `policy.yaml`; add HMAC or OS-user separation if approval is meant to be a hard boundary. +- Add golden audit-chain compatibility tests and tamper tests for every new approval audit field. +- Define multi-host approval semantics before implementation: preflight all targets or explicitly document partial execution and per-host JSON interpretation. + +### Should Fix + +- Export and use `policy.RuleDefaultDeny` so gray-area checks do not compare the magic string directly. +- Specify `approval wait` / `approval status` exit codes for approved, denied, pending timeout, missing ID, expired ID, malformed response, and usage errors. +- Use flock/atomic-write discipline for all approval stores, not just the session grant store. +- Add a grant-consumption lock so one-time approvals cannot be consumed concurrently. +- Update `docs/architecture/overview.md` to describe optional default-off async approval instead of future blocking approval. +- Add table-driven and fuzz tests for `Generalize`, including newline, carriage return, tab, form feed, vertical tab, Unicode whitespace, backslash-newline, shell metacharacters, and broad write-class subcommands. +- Add an explicit test where `host_overrides["host:web-1"]` contains a generated approval allow and global rules contain an explicit deny; `Authorize` must return hard deny. + +### Optional + +- Reject NUL-containing commands for approval rather than treating them as exact; NUL is a poor fit for shell/executor boundaries. +- Include a visible operator warning when approving `host` scope for exact commands that contain shell metacharacters. +- Add a cleanup command for expired pending/responses files and stale session grant stores. +- Consider storing approval matcher digests in audit records rather than full regexes if audit log size or sensitive command disclosure becomes a concern. diff --git a/docs/plans/approval-system.md b/docs/plans/approval-system.md new file mode 100644 index 0000000..31b9cbd --- /dev/null +++ b/docs/plans/approval-system.md @@ -0,0 +1,263 @@ +# 灰区命令异步审批系统 — 设计 + +> 状态:草案 v1.1 · 配套:`docs/architecture/overview.md`(§5 审批模型、§10 包结构) +> +> 一句话:在**不改动现有硬 `deny` 底线**的前提下,给「无规则覆盖的灰区命令」(`default-deny`)加一条**异步、非阻塞**的审批通道——`run` 立刻返回 `exit 7`,操作者事后在 `agentssh tui` 裁决,模型轮询/等待结果后干净重跑。审批结果可落为「本次 session 允许」或「该 host 始终允许」;`deny` 不持久化。 +> +> **v1.1 修订(据 `approval-system-codex-review.md` 的对抗式评审 + 本地 Go 实测):** ① 泛化正则的空白由 `\s` 改为 `[ \t]`(`\s` 含换行,实测会被 `ls\nrm -rf /` 命中=注入);② 默认 `host_grant_mode` 由 `prefix` 改为 `safe-prefix`(prod 网关不默认放宽写类子命令);③ 删除不严谨的 deny 遮蔽探针,改纯靠分离引擎;④ host 规则来源标记由 `Group:"approval"` 改为保留名 `__agentssh_approval`(避免与用户规则组撞名);⑤ resolution 文件明确为**非授权依据**,只作通知;⑥ 审批 ID 规格化(≥96bit + `O_EXCL`)、`approval wait/status` 退出码与边界明确;⑦ 多 host 审批改为**先 preflight 再执行**。`\z`/`\A` 经实测 Go RE2 支持(评审转述误称不支持,以实测为准)。 + +## 1. 背景与定位 + +`overview.md` §5 的既定立场:AgentSSH 不做**同步**审批(不阻塞 `run`、不做人工实时裁决),只保留 `deny` 硬拦截 + 全量审计,灰区的交互确认委托给 harness。§10 为「可选的 out-of-band 异步审批」预留了 `internal/approval/` 空包。 + +本设计**落地那个预留项**,并明确它是既有架构的**扩展而非反转**: + +- **没变**:explicit `deny` 规则仍是不可逾越的硬拦截,谁都不能临场放行;`default-deny`(无规则即拒)仍是兜底;凭据单向、审计 hash 链照旧。 +- **新增**:灰区(`default-deny`)命令不再只能「被拒 exit 6」,而是可以走一条**异步审批**通道,由**操作者在 TUI**裁决,结果可持久化为 allow,从而「相似命令一次同意后不再问」。 + +定位上的诚实话(见 §11):**目标部署是单机、同一个 OS 用户、交互式**。在这个前提下,审批是**防误操作/注入的护栏 + 完整可验证留痕**,不是防恶意 agent 的沙箱;唯一能变成物理硬墙的办法是让 agent 跑在独立 OS 用户/容器里(见 §11,列为可选 hard 模式,单机默认不做)。这与产品既有信任假设一致(README/SKILL 已要求 agent 只调 `run`/`hosts`)。 + +## 2. 适用范围与不可逾越的边界 + +审批**仅对灰区生效**。判定入口 `Authorize` 在基础策略判定之后分流: + +| 基础判定 | 含义 | 走向 | +|---|---|---| +| `allow` | 命中 allow 规则 | 照常执行(不变) | +| `deny` 且 `Rule != "default-deny"` | **命中显式 deny 规则** | **硬拦截**:audit `denied` + `exit 6`,**不进审批通道、TUI 里不出现**(结构上无法被审批) | +| `deny` 且 `Rule == "default-deny"` | 无任何规则覆盖(灰区) | 进入审批通道 | + +落地要点:在 `internal/policy` 导出常量 `RuleDefaultDeny = "default-deny"`(`policy.go:316` 已经返回这个字面量),让「是否灰区」成为符号判断而非魔法字符串比较。 + +## 3. 端到端流程(异步 / 非阻塞) + +``` +1. agent: agentssh run web-1 --session s_ab12 -- systemctl status nginx +2. Authorize → default-deny 灰区,且 session/host grant 均未命中 + → 写 pending 请求 + audit(approval_requested) + → 立刻返回 exit 7 + JSON: + {"status":"approval_pending","id":"ap_<96bit>","host":"web-1", + "cmd":"systemctl status nginx","proposed_scope":["once","session","host"]} + (人读 stderr:已提交审批 ap_…,等操作者在 agentssh tui 裁决,通过后重跑此命令) +3. agent: agentssh approval wait ap_… --timeout 10m # 见 §7,可被打断;run 本身早已返回 +4. operator(已在 agentssh tui · Approvals):看到该行 → 按 [o]nce/[s]ession/[h]ost/[d]eny + → 写 grant(或 host 规则)+ audit(approval_granted{scope}) +5. agent: agentssh approval wait 立刻返回 {"verdict":"approved","scope":"session"} +6. agent: 重跑 agentssh run web-1 --session s_ab12 -- systemctl status nginx + → Authorize 实时重判:仍是 default-deny → 命中 session grant → 执行 → 审计 → 回输出 +``` + +关键安全性质: + +- **`run` 永不阻塞**,因此永不挂死;无人值守时请求一直挂 pending,模型每次重跑/`wait` 都拿到 pending,**没有任何静默放行**(fail-closed)。 +- **执行发生在重跑这条 `run` 上**,会**实时重新走 `Authorize`**:grant 只在该命令**仍是 `default-deny`** 时生效;若期间操作者新增了一条显式 `deny`,grant 立即失效。grant **永远越不过显式 deny**(见 §4)。 +- **resolution 文件只是通知提示,不是授权依据**(见 §7):授权永远在第 6 步的 `run`→`Authorize` 处从 grant store / host 规则**重新推导**;伪造一份 `responses/` 只能骗 `approval wait` 显示 approved,重跑仍会被 `Authorize` 拦下。 + +## 4. 授权判定 `Authorize`(分离引擎,保证显式 deny 不被遮蔽) + +`host_overrides` 正常情况下先于 global tier 判定,所以一条写进 `host_overrides` 的 approval allow 规则,**天然有遮蔽掉同 host 显式 deny 的风险**。为根除这一点,`Authorize` 采用「分离引擎 + 仅在灰区分支查 grant」: + +1. 深拷贝 `policy.Config`,**剔除所有 `host:` override 里来源标记为 `__agentssh_approval` 的规则**(只剔除 `host:` 键下的;group override 键不产生 approval 规则,见 §12),用这份「干净」配置跑**原封不动的** `policy.NewEngine` / `Evaluate`。`policy.Evaluate` 一行不改。 +2. 得到基础 `Decision`: + - `allow` → `Allow`(照常执行); + - `deny` 且非 `default-deny` → `HardDeny`(硬拦截); + - `default-deny` → 进入第 3 步。 +3. **只在灰区分支**按序查 grant:① session grant store(§6);② 被剔除出来的那批 host approval matcher。命中 → `AllowByGrant`;都不命中 → `NeedsApproval`。 + +因为 grant 是「**严格在 default-deny 之后**才参与」的兜底裁决,它**不可能遮蔽任何 tier 的显式 allow 或显式 deny**——即便 host_overrides 物理上排在前面。举例:操作者对 `systemctl restart prod-db` 写了显式 deny,且存在 host approval 前缀 `systemctl restart *`(若启用 prefix 档)。当命令是 `systemctl restart prod-db` 时,干净 base 引擎先命中那条 deny → `HardDeny`,grant 根本不被查;当命令是 `systemctl restart web` 时,base 引擎无 deny 命中 → default-deny → grant 命中 → allow(`web` 本就没被 deny,放行正确)。**逐条命令都给出正确答案,无需任何额外的「前缀 vs deny 重叠」探针**(v1.0 的 P0/P1 探针既不严谨又会过度拦,已删除)。 + +双保险(仅为「裸读 policy」时的可读性,不参与授权):host approval 规则仍以 `priority:0` 落盘,使 `policy show` 这类**原始视图**把 approval 行排在操作者 deny 之下。`run` 与 `agentssh policy test` **都走 `Authorize`**,保证「测出来的判定 == 运行时判定」;任何展示原始 policy 顺序的视图须明确标注「raw, 非运行时语义」。 + +## 5. 粒度 / 泛化算法(核心) + +把「一条具体命令」变成「一条可复用的 `cmd_regex` allow 规则」,目标是**相似命令一次同意后不再问**,同时**绝不把复合/可逃逸命令泛化成一张大网**。 + +调研结论:Claude Code 与 Codex 都收敛到「**保留 程序名 + 前导子命令,通配其余参数,遇到 shell 元字符就拒绝放宽**」。本设计采用同样的骨架,但因为 AgentSSH 的 grant 是**静默 + 持久**(无同步重弹窗兜底)、且 AgentSSH 是 **prod SSH 网关**(非本地开发工具),把安全护栏做得更硬、默认更保守。 + +### 5.1 引擎匹配方式决定了必须全锚定 + +引擎用**非锚定**的 `regexp.MatchString` 对**整条命令字符串**匹配(`policy.go:305`)。所以一条 `git status` 的非锚定正则会被 `git status; rm -rf /` 子串命中。**因此所有产出的正则必须 `\A … \z` 全锚定**(实测 Go RE2 支持 `\A`/`\z`;`\Z` 不存在、`$` 在默认模式等价 `\z` 但 `(?m)` 下变行敏感,所以统一只用 `\A`/`\z`)。这是硬约束。 + +> 旁注:README 里操作者手写的 `^systemctl status\b` 这类规则其实也有同样的复合命令绕过弱点(只锚了头)。本审批自动生成的 grant **比手写规则更安全**,因为它全锚定、空白只用 `[ \t]`(不含换行)、且尾部字符类不含任何 shell 元字符。 + +### 5.2 `approval.Generalize(cmd) → Matcher` + +`Matcher{ Kind: prefix|exact, Regex string, Prefix []string, Promotable bool, SourceCmd string }`,`Regex` 直接当作 `Rule.Match.CmdRegex` 落盘,走现有 `compileRegex`(`policy.go:418`),**不引入新匹配代码**。 + +- **STEP 0** — 含 NUL 字节 → **拒绝该审批申请**(NUL 不该出现在正常命令里,不进 once/session/host 任何档)。 +- **STEP 1 词法安全扫描** — 命令含下列任一 → **不可放宽,直接 exact**: + - shell 元字符:`; & | ( ) { } ` 反引号 ` $ < > ' " \ * ? [ ] ~ ! #`; + - **任何 C0 控制字节**(`\t \n \r \f \v` 等)与**任何非 ASCII 空白**; + - 开头形如 `^\s*\w+=\S`(环境变量前缀 `LD_PRELOAD=… cmd`)。 + *这一步保证后续「按单一 ASCII 空格切词 == shell 分词」,从而无需自己写 shell 词法器(规避 sudo Baron-Samedit 那类解析漏洞)。* +- **STEP 2 切词** — 用**字节级、仅 ASCII 空格 `0x20` 为分隔**的切词器(**不用 `strings.Fields`**,它按 Unicode 空白切,会与远端 shell 和生成的正则不一致),切成 `argv=[t0,t1,…]`。 +- **STEP 3 选前缀** — 一组**硬护栏:任何模式下都强制 exact**,与下面的「激进度」无关: + - `t0 ∈ 解释器/可逃逸类`(`sh bash dash zsh ksh env find xargs awk gawk sed perl python* ruby node php vi vim nano emacs less more man tar zip unzip ssh scp rsync nc socat tee watch flock setsid ionice nohup`)→ **exact**。 + - `t0 ∈ 特权类`(`sudo su doas`)→ **exact 且 `Promotable=false`**(不允许写成 host 持久规则;只能 once/session)。 + - `t0 ∈ 破坏性 leaf`(`rm rmdir dd mkfs* shred shutdown reboot halt poweroff kill pkill killall chmod chown mv truncate fdisk parted`)→ **exact**。 + - 否则按 `host_grant_mode`(§5.4 / §10)决定是否**放宽到前缀**。 +- **STEP 4 产出 RE2**(注意空白用 `[ \t]`,**绝不用 `\s`**): + - 放宽:`\A` + `strings.Join(map(QuoteMeta,P), "[ \\t]+")` + `(?:[ \t]+[A-Za-z0-9@%+=:,./_-]+)*[ \t]*\z` + - exact:`\A` + `QuoteMeta(cmd)` + `\z` + - **不变量(代码内自检,违则测试失败/panic)**:每条正则必以 `\A` 开头、`\z` 结尾;**正则体内不得出现 `\s`、字面换行、或 `.*`**;尾部只用允许表字符类 `[A-Za-z0-9@%+=:,./_-]`(`-` 置末尾为字面;实现时可写 `[A-Za-z0-9@%+=:,./_\-]` 更直观)。该字符类不含任何 shell 元字符与换行,故即便在 exact 档,`$(`、`|`、`>`、换行也只能逐字节命中。 + - **威胁模型边界声明**:尾类里 `% @ , : + =` 等并非 shell 命令分隔符,保留它们是为「防 shell 分隔符注入」,**不是**「防语义层的能力放宽」(如某命令把 `@host` 当作扩权参数);后者超出本算法范围,靠 host 档的子命令白名单与操作者裁决兜。 + +### 5.3 写 host 持久规则时的去重(不再做 deny 探针) + +host 提升前,扫该 host 现有规则,`Name` 相同或 `CmdRegex` 相同则跳过(防堆积)。**不再**做 v1.0 的 P0/P1「deny 遮蔽探针」——它既不严谨(对 exact deny 检不出、对 family deny 又过度拦),也多余(§4 的分离引擎已逐条命令保证显式 deny 永远先命中)。 + +### 5.4 默认激进度(v1.1:`safe-prefix`) + +`host_grant_mode` 三档: + +- **`exact`** — 一切 exact,零放宽(最严;相似命令仍会再问)。 +- **`safe-prefix`(v1.1 默认)** — **只**对「精选只读家族」放宽到前缀;**写类子命令、读任意文件的命令、未列入白名单的程序一律 exact**。即:`t0` 是多路命令且 `t1 ∈ 该命令的 read-mostly 白名单`(如 `systemctl:{status,is-active,is-enabled,show,cat,list-units}`、`git:{status,log,diff,show}`、`kubectl:{get,describe,logs}`、`journalctl`)→ `P=[t0,t1]`;或 `t0 ∈ 只读 leaf 白名单`(如 `ls df free uptime uname hostname ps`)→ `P=[t0]`;**其余 exact**。白名单内容见 §15 待定。 +- **`prefix`(类 CC/Codex,需操作者显式开启)** — 护栏之外尽量放宽到 程序名(+任意子命令),包括写类(`systemctl restart *`、`git push *`)与读任意(`cat *`)。**问得最少,但会放宽写类、且 policy.yaml 会漂移**——仅建议在你充分理解风险时开启。 + +> 取舍:CC/Codex 默认前缀且有同步重弹窗兜底;AgentSSH 的 host grant 静默+持久、且面向 prod,所以默认收到 `safe-prefix`。想要 CC 式顺滑可切 `prefix`,或对单条写类命令每次手动选 `host`。 + +### 5.5 示例(`safe-prefix` 默认下) + +| 输入命令 | STEP 结果 | 产出正则 | 范围 | +|---|---|---|---| +| `systemctl status nginx` | 多路 + read-mostly → `P=[systemctl,status]` | `\Asystemctl[ \t]+status(?:[ \t]+[A-Za-z0-9@%+=:,./_-]+)*[ \t]*\z` | prefix;之后 `systemctl status sshd` 不再问 | +| `systemctl restart nginx` | 多路但 `restart` 非 read-mostly | `\Asystemctl[ \t]+restart[ \t]+nginx\z`(exact) | **exact**(safe-prefix);仅 `prefix` 档才会放宽 | +| `ls -la /var` | 只读 leaf 白名单 → `P=[ls]` | `\Als(?:[ \t]+[A-Za-z0-9@%+=:,./_-]+)*[ \t]*\z` | prefix | +| `cat /etc/passwd` | 读任意文件,不在白名单 | `\Acat[ \t]+/etc/passwd\z`(exact) | **exact**(safe-prefix) | +| `git -c core.pager=… log` | 多路但 `t1=-c` | `\Agit[ \t]+-c[ \t]+core\.pager=…[ \t]+log\z`(exact) | exact(绝不 `git *`) | +| `sudo systemctl restart nginx` | `t0=sudo` 特权 | `\Asudo[ \t]+systemctl[ \t]+restart[ \t]+nginx\z`(exact) | once/session 限定,**host 档禁用** | +| `rm -rf /var/tmp/cache` | `t0=rm` 破坏性 | `\Arm[ \t]+-rf[ \t]+/var/tmp/cache\z`(exact) | exact,永不裸 `rm` | +| `cat /etc/passwd \| grep root` | STEP 1 命中 `\|` | `\Acat[ \t]+/etc/passwd[ \t]+\|[ \t]+grep[ \t]+root\z`(exact) | exact;`\| grep …` 永不搭便车 | +| `ls\nrm -rf /`(未来命令撞前缀) | — | 前缀正则用 `[ \t]` 不含 `\n` | **不命中**(实测:`\s` 版会命中=注入,故必须 `[ \t]`) | + +## 6. 授权范围与持久化 + +三种裁决: + +- **once** — 一次性,仅放行**这一条逐字节相同的命令**(绑 `approval_id` + exact cmd),被下一次匹配的 `Authorize` **在锁内消费一次**后即删,防两个并发重跑花同一张 once。不持久化为规则。 +- **session** — **本次 session 允许**,写入 session grant store(下),绑 `(session_id, host)`,默认 exact(`Generalize` 在 once/session 档固定 exact;只有 host 档才按 `host_grant_mode` 放宽)。TTL 默认 12h(绝对,自 `updated` 起),`agentssh session end ` 可立即清。**绝不写进 policy.yaml**。 +- **host** — **该 host 始终允许**,经现有 `policy.AddHostRule`(`store.go:237`)→ `saveValidatedPolicy` 写 `host_overrides["host:"]`,规则形如: + ``` + Rule{ Name:"approval/"+matcherSHA12, Priority:0, + Match:{CmdRegex: matcher.Regex}, Action:ActionAllow, Group:"__agentssh_approval" } + ``` + 来源标记用**保留名** `__agentssh_approval`(在 `CreateGroup`/`StampGroupOntoHost` 里拒绝该名,避免与用户规则组撞名)。可用 `RemoveHostGroup(host,"__agentssh_approval")`(`store.go:386`)一键批量移除。 +- **deny** — 一次性拒绝,**不持久化**(下次同命令仍会再次进入审批通道)。 + +### 6.1 数据模型(均在 `$AGENTSSH_HOME/approvals/` 下,0700/0600) + +- **审批 ID**:`ap_` + **≥96bit 随机**(hex),用 `O_EXCL` 创建 pending,ID 碰撞当硬错。(不要复用只有 3 字节随机的 `audit.NewReqID`——那对审计标签够、对授权状态太弱。) +- **session grant store** — `approvals/sessions/.json`(文件名哈希化,兼容 `base@host` 这类 id;原始 id 存文件内校验): + ```json + { "version":1, "session_id":"s_ab12", "host":"web-1", "updated":"…Z", + "grants":[ { "kind":"prefix", "regex":"\\Asystemctl[ \\t]+status(?:…)*\\z", + "prefix":["systemctl","status"], "source_cmd":"systemctl status nginx", + "host":"web-1", "granted_ts":"…", "expires_ts":"…", + "approval_id":"ap_…", "req_id":"…", "channel":"tui" } ] } + ``` + 读时忽略过期项、写时惰性清理;每条 grant 带 `host`,匹配时要求 `host==target.Name`。读改写用 `syscall.Flock(LOCK_EX)`(对齐 `audit.Append`)+ `fileutil.WriteFileAtomic(…,0o600)`,按 `regex` 去重。**裁决方只写、`run` 只读**。 +- **pending 请求** — `approvals/pending/.json`:`{id, req_id, session_id, host, cmd, cmd_sha256, candidate_matcher, matcher_sha256, kind, promotable, ts}`,`run` 用 `O_EXCL` 原子写。 +- **resolution(结果)** — `approvals/responses/.json`:`{id, req_digest, verdict:approved|denied, scope:once|session|host, ts}`,其中 `req_digest = sha256(id||req_id||session_id||host||cmd_sha256||matcher_sha256||scope)`。`approval status`/`wait` 读它并**校验 `req_digest` 与对应 pending 一致**,防陈旧 resolution 被串到另一个同名请求。**resolution 是通知,不是授权**(§3/§7);无 HMAC,故同 uid 仍可伪造它(见 §11)——但伪造它不构成授权绕过,因为执行走重跑 `Authorize`。 + +## 7. 通知机制(模型怎么知道批了) + +跨进程边界无法 push,落地为「模型来查」,三选一(`run` 都已立刻返回): + +- **`agentssh approval status `** — 秒回(读 resolution + 校验 req_digest)。**只读,agent 可调**。退出码:approved=0、denied=6、pending=7、未知/畸形 id=2。 +- **`agentssh approval wait [--timeout 10m]`** — 轮询 resolution 直到出结果或超时,操作者一按即返回。**只读、可超时打断,agent 可调**。退出码:approved=0、denied=6、**超时/无结果=7**(语义同「仍待审批」)、缺失/过期/畸形=2。轮询用退避;读到非最终临时文件名一律忽略。 +- **直接重跑 `run`** — 没批再拿 `exit 7`,批了即执行。零额外命令的兜底。 + +边界处理须明确:pending 不存在、pending 已过期、resolution 畸形、resolution 的 `req_digest` 与 pending 不符 → 视为「未批」并提示重新发起,绝不当 approved。 + +SKILL.md 指南:拿到 `run` 的 `exit 7` → 把 `id` + 原命令 + 候选范围转告操作者 → `approval wait` → 通过后**干净重跑一次**;**绝不**自己调 `approval grant`/改 policy/换写法绕过。`exit 6` 仍是**终态,永不重试**。 + +## 8. 审计扩展 + +新增事件:`approval_requested` / `approval_granted` / `approval_denied`。在 `Record`(`audit/record.go:27-49`)**与** `canonicalRecord`(`audit/record.go:331-349`,两者是独立 struct)**末尾各追加**四个 `,omitempty` 字段:`ApprovalID`、`ApprovalScope`(once|session|host)、`ApprovalMatcher`、`ApprovalChannel`(tui|cli|wait|exit)。 + +**hash 链安全**:`ComputeHash`(`audit/record.go:320`)序列化 `canonicalRecord`(`:351-370` 手写字段表);旧记录这四个字段为空 → `omitempty` 省略 → 规范 JSON 逐字节不变 → 旧日志 `audit verify` 仍通过。**两处必须同步**:只加到 `Record` 而漏 `canonicalRecord`,审批元数据会写进 JSON 却**不被 hash 保护**;加到 `canonicalRecord` 却漏 `omitempty`、或插在已有字段之前,会让旧日志断链。用「升级前 fixture 的 golden 链回归」+「逐字段 tamper 测试(改任一新字段 → `Verify` 失败)」守住。不得在 canonical record 里放无序 map 值。 + +生命周期:`approval_requested →(granted{scope,matcher,channel}→ started → completed)` 或 `→ approval_denied / 超时(记 denied)`。 + +## 9. CLI / TUI 命面 + +**agent 面(不变 + 两个只读查询)**:`run`、`hosts`、`approval status `、`approval wait `。**没有** agent 可调的「批准」动词。 + +**操作者面(operator-only,与现有 `policy` 同级约定,经 `resolveOperatorMaster`/TTY 守门、非 TTY 硬失败)**: +- `agentssh approval ls [--json]` — 列待审批。 +- `agentssh approval grant --once|--session|--host` — 裁决(也可在 TUI 按键)。 +- `agentssh approval deny `。 +- `agentssh session end ` — 立即清该 session 的 grant。 + +**TUI**:新增 `Approvals` 标签页(`entryApprovals`,注意修正 tab 轮转取模),`tea.Tick` 轮询 `pending/`,按键 `[o]nce/[s]ession/[h]ost/[d]eny` → `ApplyDecision`;裁决行展示 host · session · 原命令 · 候选 matcher · 可选范围。对**含 shell 元字符的 host-exact** 裁决给可见告警。无新增三方依赖。 + +## 10. 配置 + +- **默认关闭、显式开启**(向后兼容:关时灰区仍是今天的 `exit 6`,**逐字节同今**,不破坏现有脚本)。开关:`policy.Config.Approval.Enabled` 或环境变量 `AGENTSSH_APPROVAL`。开启后灰区 → `exit 7` 异步审批。 +- `policy.Config` 增可选段(默认值): + ```yaml + approval: + enabled: false # 总开关 + host_grant_mode: safe-prefix # safe-prefix(v1.1 默认) | exact | prefix(类 CC,需显式开) + session_ttl: 12h # session grant 绝对 TTL + wait_timeout: 10m # approval wait 默认超时 + ``` +- `config.Paths` 增 `ApprovalsDir / SessionsDir / PendingDir / ResponsesDir`(惰性 0700)。`EnvApproval = "AGENTSSH_APPROVAL"`。 +- 新增 `exit 7`:`const exitApprovalRequired = 7`(`main.go` 约 :30),`mergeExitCode` 优先级 `6 > 7 > 9 > 1 > 0`。 + +## 11. 自批准边界与部署形态(诚实声明) + +**本项目目标部署 = 单机、同一个 OS 用户、交互式**(操作者与 agent 跑在同一账号下)。这个前提下要讲清审批保证什么、不保证什么: + +- **能保证**:对一个**基本配合**的 agent(可能被 prompt-injection 带偏、幻觉出危险命令、或手滑),审批 + 显式 `deny` 硬拦 + default-deny + 可验证审计是一道**真实有效的护栏 + 完整留痕**。日常风险(误删 prod、被注入诱导跑破坏性命令)正属这一类——这正是 AgentSSH 的价值所在。 +- **不保证**:它**不是**防一个**蓄意越权**的恶意 agent 的沙箱。同一个 OS 用户下,文件权限只挡别的用户、不挡同用户;一个蓄意的同 uid agent 可以绕开整套机制——直接改 `policy.yaml` 加 allow、直接写 `sessions/` grant、甚至不经 AgentSSH 而用本机 ssh-agent 直连目标机。这不是审批特有的洞,而是「同 uid ⇒ agent 拥有你的全部权限」的必然:AgentSSH 的凭据隔离与 policy 强制在同 uid 下同样只是约定 + 审计,不是物理墙。 + +**唯一能把它变成物理硬墙的办法,是让 agent 跑在独立、受限的 OS 用户(或容器)里**:它对 `~/.agentssh/` 无写权限、没有 ssh-agent,只能经操作者侧 broker 触达主机,内核替你挡住一切伪造与直连。红队验证这是**唯一**真正跨过同 uid 边界的方案(密码学签名方案被「改 policy.yaml」「直连 ssh」两扇侧门绕过,投入产出比差,已否)。它适合服务器 / CI / 专用 agent 主机——**运行时体验透明**(agent 命令、operator TUI 都不变),只是要一次性建账号 + 跑 broker;对单机交互式不划算。本设计因此把 broker 列为**可选「hard 模式」指引**(见 §15),不作默认实现。 + +同 uid 默认档的落地取舍: +- `approval grant`/`deny` 做成操作者专用、非 TTY 硬失败;`run` 只读不写 grant;resolution 仅作通知。这些**不改变同 uid 的真相**,但减少误触、保证一切留痕。 +- **不**做密码学签名(理由见上)。 +- `--help` 与文档如实标注:同 uid 下审批是「护栏 + 审计」,不是防恶意 agent 的边界。 + +## 12. 代码改动落点 + +- **`internal/policy/policy.go`**:导出 `RuleDefaultDeny="default-deny"`。引擎逻辑零改动。 +- **`internal/policy/store.go`**:`CreateGroup` / `StampGroupOntoHost` 拒绝保留名 `__agentssh_approval`(以及任何 `__agentssh_` 前缀)。 +- **`internal/approval/`**(现仅 `doc.go`): + - `generalize.go`(`Generalize` + 字节级 ASCII-空格切词器 + 各 denylist/多路表/read-mostly 白名单 + 不变量自检) + - `matcher.go`(`Matcher`、`Compile`、`SHA256`) + - `session_store.go`(`Load/Grant/Match/GC/End`,flock + 原子写 + once 锁内消费) + - `request.go`(`PendingStore`、resolution 读写 + `req_digest` 校验、`Status`/`Wait`、≥96bit ID) + - `authorize.go`(`Authorize` + `splitPolicy` 深拷贝剔除 `host:` 下 `__agentssh_approval` 规则 → `policy.NewEngine`) + - `adjudicate.go`(`ApplyDecision`:session→Grant;host→去重 + `AddHostRule` + save;写审计;写 resolution) +- **`cmd/agentssh/main.go`**:`run` 流程把 `main.go:1694` 的 `if decision.Action==ActionDeny` 块换成 `switch approval.Authorize(...)`:`Allow`/`AllowByGrant`→原执行路径;`HardDeny`→原 deny+`exit 6`(不变);`NeedsApproval`→若审批关则今天的默认拒,否则写 pending + audit `approval_requested` + 返回 `exit 7`+JSON。**多 host/group:审批开启时先 preflight 全部目标的 `Authorize`,任一为 `NeedsApproval` 则不执行任何目标、整批返回 exit 7 + 每 host JSON**(避免半执行;见 §13)。`runResponse`(`main.go:808`)加 `Status/ApprovalID/ProposedMatcher/ScopesOffered`。新增 `approval`/`session end` 子命令(operator-only,`resolveOperatorMaster` 守门)。 +- **`internal/config/config.go`**:`Paths` 增审批目录;`EnvApproval`;`policy.Config` 增 `Approval` 段。 +- **`internal/audit/record.go`**:新增三个事件 + 四个 `,omitempty` 字段(`Record`(:27-49)与 `canonicalRecord`(:331-370)同步;`ComputeHash` :320)。 +- **`internal/tui/app.go`**:`Approvals` 标签页 + 轮询 + 裁决按键 + 元字符告警。 +- **`skills/agentssh-usage/SKILL.md`**:写明 `exit 7` 流程、`approval status/wait` 退出码、绝不自批;重申 `exit 6` 为终态。 +- **`docs/architecture/overview.md`**:§5/§10 修订——把「未来 out-of-band **同步**审批」改为「可选、默认关的**异步**审批」,保留硬 deny 底线;TUI 从「纯审计查看器」更新为「审计 + 审批裁决」;迁移注:不开 `approval.enabled`/`AGENTSSH_APPROVAL` 则脚本仍见 `exit 6`。 + +## 13. 安全权衡与残留风险 + +1. **同 uid 自批准**(核心):见 §11。同 uid 下审批是「护栏 + 审计」,不是沙箱;唯一硬化办法 = 让 agent 跑独立 OS 用户/容器 + broker(可选 hard 模式)。密码学签名方案经红队否决(被改 `policy.yaml` / 直连 ssh 两扇侧门绕过)。 +2. **非锚定 / `\s` 正则缺陷 = 立即注入**:`Generalize` 一旦漏锚或用 `\s`,`ls\nrm -rf /` 即可绕过(引擎子串匹配 + `\s` 吃换行,均已实测)。缓解:代码内不变量自检(缺锚、含 `\s`/换行、含 `.*` 即测试失败)+ 注入语料表驱动测试 + fuzz。**最高优先级正确性风险**。 +3. **`prefix` 档(需显式开)的过度放宽**:会放宽写类多路子命令(`systemctl restart *`、`docker run *`、`git push *`)与读任意文件 leaf(`cat *`)。**默认 `safe-prefix` 不放宽这些**;开 `prefix` 即接受该取舍。边界仍由 解释器/特权/破坏性 denylist + 分离引擎兜。 +4. **审计 hash 回归**:新字段必须 `,omitempty` 且 `Record`/`canonicalRecord` 同步;用 golden 链 + 逐字段 tamper 测试守住。 +5. **resolution 重放 / 陈旧 pending**:`req_digest` 绑定 + `O_EXCL` + `id` 归属校验;pending/responses 按 TTL 清扫。无 HMAC,故不防同 uid **伪造**(同 §11),但防住**意外串号/重放**。 +6. **多 host / group run 的部分审批**:已定为**审批开启时先 preflight 全部目标**——任一需审批则整批不执行、返回 exit 7 + 每 host JSON;非 JSON 输出在返回聚合 exit 前逐 host 打印 pending/denied/executed 状态,避免 harness 误读为整体成功。 + +## 14. 测试要点 + +- `Generalize`:护栏 denylist、元字符/控制字符/Unicode 空白拒绝、锚定 + 无 `\s`/`.*` 不变量、prefix/exact 分流、字节级切词器;表驱动 + 注入语料(`\n \r \t \f \v` / Unicode 空白 / `$()` / 管道 / 反斜杠续行 / 引号 / glob)+ fuzz;**专测 `ls\nrm -rf /` 不被任何前缀 grant 命中**。 +- `Authorize`:**host_overrides 物理在前 + global 有显式 deny → 必返 HardDeny**;session/host grant 命中;实时重判(运行前新增 deny 使 grant 失效);once 在并发重跑下只被消费一次。 +- session store:flock 并发、TTL 过期、`session end` 清除、`host` 绑定。 +- request/resolution:`req_digest` 不符当未批;`O_EXCL` 防覆盖;`approval wait/status` 各退出码与缺失/过期/畸形分支。 +- audit:golden 链回归(升级前 fixture 仍 verify 通过)+ 逐字段 tamper;三个新事件生命周期。 +- e2e:`exit 7` → `approval wait` → TUI 裁决 → 重跑执行;`exit 6` 不进审批通道;group 部分审批走 preflight 不半执行。 + +## 15. 待定项 + +- **`safe-prefix` 白名单**(默认档依赖):各多路命令的 read-mostly 子命令表(`systemctl:{status,is-active,is-enabled,show,cat,list-units}`、`git:{status,log,diff,show}`、`kubectl:{get,describe,logs}`、`journalctl` …)+ 只读 leaf 表(`ls df free uptime uname hostname ps` …)。`exact`/`prefix` 不依赖它。 +- **防自批「hard 模式」(可选,面向服务器/CI,单机默认不做)**:独立 OS 用户(或容器)+ 操作者侧 broker(PEERCRED 认证 socket,所有信任逻辑服务端以操作者身份跑;须校验 session-id `^s_[0-9a-f]+$` 防路径穿越、pending 创建即不可改、grant 消费串行化)。红队结论:这是同 uid 之外唯一的物理硬墙;密码学签名(master-HMAC / 非对称 / FIDO2)被「改 policy.yaml」「直连 ssh」两扇侧门绕过,已否。 +- **session TTL 语义**:默认 12h 绝对;是否加 `--sliding` 续期留待反馈。 +- **审批开启默认值**:本版默认 `false`(向后兼容);是否在某些发行默认开,待定。 diff --git a/docs/plans/tui-approvals-final.html b/docs/plans/tui-approvals-final.html new file mode 100644 index 0000000..64294bd --- /dev/null +++ b/docs/plans/tui-approvals-final.html @@ -0,0 +1,98 @@ + + + +AgentSSH · Approvals — consequence-first (optimized) + +
+

AgentSSH · Approvals — consequence-first (optimized)

+

One line per request · no per-row key column · the consequence is shown only for the focused row. ↑↓ or click to move the cursor — the footer updates live.

+ +
+
agentssh tui — Approvals
+
+
Hosts · Policy · Approvals4 pending · web-1 prod
+
+
+
+
+
+
+
+
+ +

The footer line is where "consequence-first" lives — for the request you are about to decide, it states plainly what [h] host-allow will free. sudo auto-explains "host unavailable"; a pipe explains "exact only, no generalize". Ordinary rows therefore need no markup, so the list stays one line per request.

+
+ + + diff --git a/docs/plans/tui-approvals-prototype.html b/docs/plans/tui-approvals-prototype.html new file mode 100644 index 0000000..1f68363 --- /dev/null +++ b/docs/plans/tui-approvals-prototype.html @@ -0,0 +1,171 @@ + + + +AgentSSH · Approvals TUI 原型 v2(简洁克制版) + +
+

AgentSSH · Approvals 裁决屏 — 六种 TUI 方案 · v2 简洁克制版

+

已按「简洁·克制·直接·用户友好」全部瘦身(≤18 行、单分隔线、行内短记号)。★ = 推荐。点标签或按 1–6 / ←→ 切换。

+ +
+
+
+
方案 1Approvals · 主从克制版(单行队列 + 最小详情)最适合:面前堆着多条 pending、要按 host/scope 一条接一条快速连续裁决时——一眼看到下一条、直接按键。
+
+
agentssh tui — Approvals
+
Hosts · Policy · [Approvals]                              4 pending
+
+>1 ap_7f3a  web-1  systemctl status nginx         o/s/h  | ap_7f3a  web-1
+ 2 ap_5c12  web-1  sudo systemctl restart … ·sudo o/s(h) | s_1a2b3c4d "fix 502"
+ 3 ap_9d04  db-1   cat /etc/passwd | grep … ·pipe o/s/h  | $ systemctl status nginx
+ 4 ap_a1b2  web-1  journalctl -n 100 -u ng…       o/s/h  | match: prefix
+
+decide >1 ap_7f3a     [o]nce  [s]ession  [h]ost  [d]eny      j/k move
+
+

克制要点

相比常规版砍掉了:legend/图例块、每条的双行排版、独立 warnings 段,以及第二条分隔线(只留主从那条竖线)。特例全压成行内短记号——sudo 禁 host = scope 芯片里把 h 灰显成括号 o/s(h) 并配 ·sudo;管道精确 = ·pipe;命令超长用 … 截断,完整命令只在右侧选中详情里给。仍然清楚的原因:每行的 o/s/h 芯片与底部 [o][s][h][d] 键位一一对应,不需文字解释就知道这条能按哪几个键;顶部一句 4 pending、底部一句 decide >1 ap_7f3a,操作者无需扫视即知「现在决定哪条、按什么」。

+
+ +
+
方案 2专注卡片(极简版)最适合在 deny 硬拦截之后、operator 要对一小批 pending 命令逐条秒级裁决、且需要横向扫一眼范围差异时。
+
+
agentssh tui — Approvals
+
 Hosts · Policy · [Approvals]                                     decide 1/4
+────────────────────────────────────────────────────────────────────────────
+> web-1  s_1a2b3c4d  systemctl status nginx            前缀
+
+  web-1  s_1a2b3c4d  sudo systemctl restart nginx      o   s   (h)  ·sudo
+  db-1   s_99ff0011  cat /etc/passwd | grep root       o   s    h   ·pipe
+  web-1  s_1a2b3c4d  journalctl -n 100 -u nginx …      o   s    h
+
+ [o]nce   [s]ession   [h]ost   [d]eny
+
+

克制要点

相比常规版砍掉了:队列预览框、策略/规则来源行、每条的解释三连、底部图例,以及 session 备注 tag(fix 502/backup),全屏只剩一条分隔线、零边框。仍清楚是因为:顶部「decide 1/4」+ ▸ 标记直接说决定哪条,底部四键就是按什么键(键即焦点卡的动作行,不再单列图例);特例不写整句,只用 ·sudo / ·pipe 和灰显 (h) 三个单记号,且 o/s/h 字母按列纵向对齐,4 条可直接横向比对——前缀=无记号、精确=带·、host 禁用=括号灰显。

+
+ +
+
方案 3Lean Approvals · 一行一条极简裁决表★ 推荐最适合待批积压、操作者已熟手键位、要一眼定位「该决定哪条」再连按 o/s/h/d 快速清栈时。
+
+
agentssh tui — Approvals
+
Hosts · Policy · [Approvals · 4]
+
+▸ ap_7f3a  web-1  systemctl status nginx        prefix      [o][s][h]
+  ap_5c12  web-1  sudo systemctl restart nginx  exact ·sudo [o][s] ·h
+  ap_9d04  db-1   cat /etc/passwd | grep root   exact ·pipe [o][s][h]
+  ap_a1b2  web-1  journalctl -n 100 -u nginx …  prefix      [o][s][h]
+────────────────────────────────────────────────────────────────────
+▸ ap_7f3a → grant [o]nce  [s]ession  [h]ost   ·   [d]eny    ↑↓ next
+
+

克制要点

相比常规版砍掉了:会话分组与意图标签列(fix 502/backup)、表头、legend/说明块、每条的多行展开、范围整句解释——整屏只剩一处分隔线。约束信息无损保留:列位即语义(候选 prefix/exact、范围 o/s/h),特例用极短记号——·sudo 配灰显 ·h(host 禁用,无括号即不可按)、·pipe 配 exact(只精确);▸ 行光标 + 底部单条键位让「决定哪条·按什么」一眼即明,空态收敛为一行「无待批 · 全部已处理」。

+
+ +
+
方案 4后果优先 · 极简版(每条一行影响面)★ 推荐最适合在 prod 网关上逐条快批、又必须一眼看清「批了 host 会顺带放行多大一片」时——把后果压成每条一行,不读正则也能决断。
+
+
agentssh tui — Approvals
+
AgentSSH   Hosts · Policy · [Approvals]                   4 pending · web-1 prod
+────────────────────────────────────────────────────────────────────────────────
+
+▌ ap_7f3a  web-1  systemctl status nginx                [o][s][h][d]
+                  +[h] frees  systemctl status *
+
+  ap_5c12  web-1  sudo systemctl restart nginx          [o][s](h)[d] ·sudo
+                  +[h] off · sudo, no host rule
+
+  ap_9d04  db-1   cat /etc/passwd | grep root           [o][s][h][d] ·pipe
+                  +[h] frees  only this exact line
+
+  ap_a1b2  web-1  journalctl -n 100 -u nginx …          [o][s][h][d]
+                  +[h] frees  journalctl *
+
+o once   s session   h host   d deny          j/k focus · q quit
+
+

克制要点

相比常规版砍掉了 blast-radius 说明块、锚定正则原文、FREES/STILL-ASKS 两段枚举、legend 图例和所有装饰边框(只留一根分隔线);每条 host 后果压成一行 +[h] frees …,sudo/管道特例只用 (h) 灰显 + ·sudo / ·pipe 记号,不写整句。仍清楚是因为后果行用统一 +[h] frees 前缀自解释、列对齐可横向比较,焦点 ▌ 与底部单行键位已给足「决定哪条、按什么键」。

+
+ +
+
方案 5Approvals · 会话折叠极简裁决屏最适合在密集 agent 请求里逐条快速裁决、只想知道「现在轮到哪条、按哪个键」时。
+
+
agentssh tui — Approvals
+
 Hosts   Policy   [ Approvals ]                                     4 待决
+──────────────────────────────────────────────────────────────────────────
+ s_1a2b3c4d · fix 502 · web-1                                       3 待决
+   claude-code · 近期 tail -f /var/log/nginx/error.log
+ ▸ ap_7f3a  systemctl status nginx
+   ap_5c12  sudo systemctl restart nginx                  ·sudo  [h]✗
+   ap_a1b2  journalctl -n 100 -u nginx …
+ s_99ff0011 · backup · db-1                                         1 待决
+   claude-code · 近期 pg_dump app > /backup/app.sql
+   ap_9d04  cat /etc/passwd | grep root                   ·pipe
+ o once · s session · h host · d deny              j/k 选 · q 退
+
+

克制要点

砍掉了并排审计面板、单条详情卡、图例/说明块、装饰边框,以及「每条都标 o/s/h/d」的列表噪声。仍清楚:▸ 指出当前该决定的那条,底部单行帮助条是唯一全局键位来源(o/s/h/d 含义在此),正常行因此不必复述;只有特例加最短记号——`·sudo` 后接灰显禁用的 `[h]✗` 表示 sudo 不可放到 host、`·pipe` 表示管道只能精确放行;整屏仅 tab 下一条分隔线,11 行 / 约 74 列,80 列窄终端也不换行。

+
+ +
+
方案 6时间流 · 一行一裁(Lean Approvals)最适合一屏盯着多条待批、想靠 o/s/h/d 肌肉记忆从上往下逐条秒裁时
+
+
agentssh tui — Approvals
+
Hosts · Policy · ‹Approvals›                                               4 pending
+
+> ap_a1b2 web-1  s_1a2b3c4d          journalctl -n 100 -u nginx …       [o][s][h][d]
+  ap_9d04 db-1   s_99ff0011 backup   cat /etc/passwd | grep root  ·pipe [o][s][h][d]
+  ap_5c12 web-1  s_1a2b3c4d          sudo systemctl restart nginx ·sudo [o][s][-][d]
+  ap_7f3a web-1  s_1a2b3c4d fix 502  systemctl status nginx             [o][s][h][d]
+
+────────────────────────────────────────────────────────────────────────────────────
+[o]nce  [s]ession  [h]ost  [d]eny                                            ↑↓ move
+
+

克制要点

砍掉:时间轴竖线/时刻戳、每条之间的分隔与边框、scope 候选的整句说明、底部 legend/图例——全屏只剩一条规则线。仍清楚:chip 永远固定在 o·s·h·d 四个列位右对齐成一竖列,所以被禁的 host 用 [-] 占位即可"自己报名"是哪一档;特例只用 ·sudo / ·pipe 两个短记号替代整句(sudo→host 禁、pipe→只精确),帮助条一行把键位贴回 chip,> 光标指明此刻该裁的那条。

+
+
同一批 4 条待审批数据。终端区为等宽 ASCII 原型(单色,真实 TUI 会上色)。·sudo=特权不可设 host;·pipe=含管道只能精确;灰显/括号 (h)/[-]=该范围不可选。
+ \ No newline at end of file diff --git a/internal/approval/adjudicate.go b/internal/approval/adjudicate.go new file mode 100644 index 0000000..384957b --- /dev/null +++ b/internal/approval/adjudicate.go @@ -0,0 +1,167 @@ +package approval + +import ( + "fmt" + "strings" + "time" + + "github.com/Praeviso/AgentSSH/internal/audit" + "github.com/Praeviso/AgentSSH/internal/policy" +) + +type ApplyOptions struct { + Pending PendingStore + Sessions SessionStore + Audit audit.Store + Bundle policy.Bundle + PolicyPath string + SessionTTL time.Duration + Channel string + SavePolicy func(policy.Config) error +} + +type ApplyResult struct { + Request PendingRequest `json:"request"` + Resolution Resolution `json:"resolution"` + Grant *Grant `json:"grant,omitempty"` + RuleName string `json:"rule_name,omitempty"` +} + +func ApplyDecision(opts ApplyOptions, id string, verdict Verdict, scope Scope) (ApplyResult, error) { + if opts.Channel == "" { + opts.Channel = ChannelCLI + } + req, err := opts.Pending.Get(id) + if err != nil { + return ApplyResult{}, err + } + if _, exists, err := opts.Pending.readResolution(id); err != nil { + return ApplyResult{}, err + } else if exists { + return ApplyResult{}, ErrAlreadyResolved + } + if existing, err := opts.Pending.Status(id); err == nil && existing.Status != "pending" { + return ApplyResult{}, ErrAlreadyResolved + } + result := ApplyResult{Request: req} + switch verdict { + case VerdictApproved: + if scope != ScopeOnce && scope != ScopeSession && scope != ScopeHost { + return ApplyResult{}, fmt.Errorf("approval grant scope is required") + } + if scope == ScopeHost { + ruleName, err := applyHostGrant(opts, req) + if err != nil { + return ApplyResult{}, err + } + result.RuleName = ruleName + } else { + grant, err := applySessionGrant(opts, req, scope) + if err != nil { + return ApplyResult{}, err + } + result.Grant = &grant + } + case VerdictDenied: + scope = "" + default: + return ApplyResult{}, fmt.Errorf("invalid approval verdict %q", verdict) + } + + if err := appendApprovalAudit(opts, req, verdict, scope); err != nil { + return ApplyResult{}, err + } + resolution, err := opts.Pending.Resolve(req, verdict, scope) + if err != nil { + return ApplyResult{}, err + } + result.Resolution = resolution + return result, nil +} + +func applySessionGrant(opts ApplyOptions, req PendingRequest, scope Scope) (Grant, error) { + matcher, err := Exact(req.Cmd) + if err != nil { + return Grant{}, err + } + ttl := opts.SessionTTL + if ttl <= 0 { + ttl = DefaultSessionTTL + } + return opts.Sessions.Grant(req.SessionID, req.Host, scope, matcher, req.ID, req.ReqID, ttl, opts.Channel) +} + +func applyHostGrant(opts ApplyOptions, req PendingRequest) (string, error) { + if !req.Candidate.Promotable { + return "", fmt.Errorf("approval %s cannot be promoted to host scope", req.ID) + } + if err := validateMatcherInvariant(req.Candidate.Regex); err != nil { + return "", err + } + next := opts.Bundle + if next.Policy.HostOverrides == nil { + next.Policy.HostOverrides = map[string]policy.HostOverride{} + } + key := policy.HostRulesKey(req.Host) + existing := next.Policy.HostOverrides[key] + ruleName := "approval/" + matcherSHA12(req.Candidate) + for _, rule := range existing.Rules { + if rule.Name == ruleName || rule.Match.CmdRegex == req.Candidate.Regex { + return ruleName, nil + } + } + rule := policy.Rule{ + Name: ruleName, + Priority: 0, + Match: policy.Match{CmdRegex: req.Candidate.Regex}, + Action: policy.ActionAllow, + Group: policy.ApprovalGroup, + } + updated, err := policy.AddHostRule(next, req.Host, rule) + if err != nil { + return "", err + } + if _, err := policy.NewEngine(updated.Policy, updated.Inventory); err != nil { + return "", err + } + if opts.SavePolicy != nil { + if err := opts.SavePolicy(updated.Policy); err != nil { + return "", err + } + } else { + if strings.TrimSpace(opts.PolicyPath) == "" { + return "", fmt.Errorf("policy path is required for host approval") + } + if err := policy.Save(opts.PolicyPath, updated.Policy); err != nil { + return "", err + } + } + return ruleName, nil +} + +func appendApprovalAudit(opts ApplyOptions, req PendingRequest, verdict Verdict, scope Scope) error { + if opts.Audit.Path == "" { + return nil + } + event := audit.EventApprovalDenied + action := string(policy.ActionDeny) + if verdict == VerdictApproved { + event = audit.EventApprovalGranted + action = string(policy.ActionAllow) + } + record := audit.Record{ + ReqID: req.ReqID, + SessionID: req.SessionID, + Event: event, + Host: req.Host, + Cmd: req.Cmd, + PolicyAction: action, + PolicyRule: policy.RuleDefaultDeny, + ApprovalID: req.ID, + ApprovalScope: string(scope), + ApprovalMatcher: req.Candidate.Regex, + ApprovalChannel: opts.Channel, + } + _, err := opts.Audit.Append(record) + return err +} diff --git a/internal/approval/authorize.go b/internal/approval/authorize.go new file mode 100644 index 0000000..7d31029 --- /dev/null +++ b/internal/approval/authorize.go @@ -0,0 +1,158 @@ +package approval + +import ( + "errors" + "fmt" + "strings" + + "github.com/Praeviso/AgentSSH/internal/inventory" + "github.com/Praeviso/AgentSSH/internal/policy" +) + +type AuthorizationStatus string + +const ( + AuthAllow AuthorizationStatus = "allow" + AuthAllowByGrant AuthorizationStatus = "allow_by_grant" + AuthHardDeny AuthorizationStatus = "hard_deny" + AuthNeedsApproval AuthorizationStatus = "needs_approval" +) + +type Authorization struct { + Status AuthorizationStatus + Decision policy.Decision + GrantScope Scope + GrantMatcher string + 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) +} + +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) +} + +func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string, consumeOnce bool) (Authorization, error) { + clean, hostMatchers := splitPolicy(cfg, host) + engine, err := policy.NewEngine(clean, inv) + if err != nil { + return Authorization{}, err + } + decision, err := engine.Evaluate(host, command) + if err != nil { + return Authorization{}, err + } + if decision.Action == policy.ActionAllow { + return Authorization{Status: AuthAllow, Decision: decision}, nil + } + if decision.Rule != policy.RuleDefaultDeny { + return Authorization{Status: AuthHardDeny, Decision: decision}, nil + } + if !runtime.Enabled { + return Authorization{Status: AuthNeedsApproval, Decision: decision}, nil + } + var grant Grant + var ok bool + if consumeOnce { + grant, ok, err = sessionStore.Match(sessionID, host, command) + } else { + grant, ok, err = sessionStore.Peek(sessionID, host, command) + } + if err != nil { + return Authorization{}, err + } + if ok { + return Authorization{ + Status: AuthAllowByGrant, + Decision: policy.Decision{Action: policy.ActionAllow, Rule: "approval/session/" + grant.ApprovalID}, + GrantScope: grant.Scope, + 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 + } + } + matcher, err := Generalize(command, runtime.HostGrantMode) + if errors.Is(err, ErrNULCommand) { + return Authorization{Status: AuthHardDeny, Decision: decision}, nil + } + if err != nil { + return Authorization{}, err + } + return Authorization{Status: AuthNeedsApproval, Decision: decision, ApprovalMatcher: matcher}, nil +} + +func splitPolicy(cfg policy.Config, host string) (policy.Config, []Matcher) { + clean := copyPolicyConfig(cfg) + if clean.HostOverrides == nil { + return clean, nil + } + var hostMatchers []Matcher + for key, override := range cfg.HostOverrides { + if !strings.HasPrefix(key, "host:") { + continue + } + filtered := make([]policy.Rule, 0, len(override.Rules)) + for _, rule := range override.Rules { + if rule.Group != policy.ApprovalGroup { + filtered = append(filtered, rule) + continue + } + if key == policy.HostRulesKey(host) { + hostMatchers = append(hostMatchers, Matcher{ + Kind: MatcherExact, + Regex: rule.Match.CmdRegex, + Promotable: true, + }) + } + } + if len(filtered) == len(override.Rules) { + continue + } + nextOverride := clean.HostOverrides[key] + nextOverride.Rules = filtered + clean.HostOverrides[key] = nextOverride + } + return clean, hostMatchers +} + +func copyPolicyConfig(cfg policy.Config) policy.Config { + next := cfg + next.Rules = append([]policy.Rule(nil), cfg.Rules...) + if cfg.RuleGroups != nil { + next.RuleGroups = make(map[string]policy.RuleGroup, len(cfg.RuleGroups)) + for key, value := range cfg.RuleGroups { + value.Rules = append([]policy.Rule(nil), value.Rules...) + next.RuleGroups[key] = value + } + } + if cfg.HostOverrides != nil { + next.HostOverrides = make(map[string]policy.HostOverride, len(cfg.HostOverrides)) + for key, value := range cfg.HostOverrides { + value.Rules = append([]policy.Rule(nil), value.Rules...) + next.HostOverrides[key] = value + } + } + next.Output.Redact = append([]string(nil), cfg.Output.Redact...) + return next +} + +func CheckHostGrantRule(rule policy.Rule) error { + if rule.Group != policy.ApprovalGroup { + return fmt.Errorf("rule is not an approval host rule") + } + return validateMatcherInvariant(rule.Match.CmdRegex) +} diff --git a/internal/approval/authorize_test.go b/internal/approval/authorize_test.go new file mode 100644 index 0000000..0e98ddd --- /dev/null +++ b/internal/approval/authorize_test.go @@ -0,0 +1,88 @@ +package approval + +import ( + "testing" + "time" + + "github.com/Praeviso/AgentSSH/internal/inventory" + "github.com/Praeviso/AgentSSH/internal/policy" +) + +func TestAuthorizeApprovalGrantCannotShadowExplicitDeny(t *testing.T) { + cfg := policy.Config{ + Rules: []policy.Rule{{ + Name: "deny-restart", + Priority: 100, + Match: policy.Match{CmdRegex: `\Asystemctl[ \t]+restart[ \t]+prod-db\z`}, + Action: policy.ActionDeny, + }}, + HostOverrides: map[string]policy.HostOverride{ + policy.HostRulesKey("web-1"): {Rules: []policy.Rule{{ + Name: "approval/abc", + Match: policy.Match{CmdRegex: `\Asystemctl[ \t]+restart(?:[ \t]+[A-Za-z0-9@%+=:,./_-]+)*[ \t]*\z`}, + Action: policy.ActionAllow, + Group: policy.ApprovalGroup, + }}}, + }, + } + auth, err := Authorize(cfg, inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, SessionStore{Dir: t.TempDir()}, RuntimeConfig{HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "systemctl restart prod-db") + if err != nil { + t.Fatalf("Authorize: %v", err) + } + if auth.Status != AuthHardDeny { + t.Fatalf("status = %#v, want hard deny", auth) + } +} + +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 { + t.Fatal(err) + } + auth, err := Authorize(policy.Config{}, inv, store, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "systemctl restart nginx") + if err != nil { + t.Fatalf("Authorize session: %v", err) + } + if auth.Status != AuthAllowByGrant || auth.GrantScope != ScopeSession { + t.Fatalf("session auth = %#v", auth) + } + + cfg := policy.Config{HostOverrides: map[string]policy.HostOverride{ + policy.HostRulesKey("web-1"): {Rules: []policy.Rule{{ + Name: "approval/host", + Match: policy.Match{CmdRegex: `\Als(?:[ \t]+[A-Za-z0-9@%+=:,./_-]+)*[ \t]*\z`}, + Action: policy.ActionAllow, + Group: policy.ApprovalGroup, + }}}, + }} + auth, err = Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s2", "web-1", "ls /var") + if err != nil { + t.Fatalf("Authorize host: %v", err) + } + if auth.Status != AuthAllowByGrant || auth.GrantScope != ScopeHost { + t.Fatalf("host auth = %#v", auth) + } +} + +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 { + t.Fatal(err) + } + cfg := policy.Config{Rules: []policy.Rule{{ + Name: "deny-id", + Match: policy.Match{CmdRegex: `\Aid\z`}, + Action: policy.ActionDeny, + }}} + auth, err := Authorize(cfg, inv, store, RuntimeConfig{Enabled: true, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "id") + if err != nil { + t.Fatalf("Authorize: %v", err) + } + if auth.Status != AuthHardDeny { + t.Fatalf("auth = %#v, want hard deny", auth) + } +} diff --git a/internal/approval/config.go b/internal/approval/config.go new file mode 100644 index 0000000..737aa31 --- /dev/null +++ b/internal/approval/config.go @@ -0,0 +1,71 @@ +package approval + +import ( + "fmt" + "strings" + "time" + + "github.com/Praeviso/AgentSSH/internal/policy" +) + +const ( + DefaultSessionTTL = 12 * time.Hour + DefaultWaitTimeout = 10 * time.Minute +) + +type RuntimeConfig struct { + Enabled bool + HostGrantMode HostGrantMode + SessionTTL time.Duration + WaitTimeout time.Duration +} + +func RuntimeConfigFromPolicy(cfg policy.Approval, envValue string) (RuntimeConfig, error) { + out := RuntimeConfig{ + Enabled: cfg.Enabled, + HostGrantMode: HostGrantSafePrefix, + SessionTTL: DefaultSessionTTL, + WaitTimeout: DefaultWaitTimeout, + } + if cfg.HostGrantMode != "" { + switch HostGrantMode(cfg.HostGrantMode) { + case HostGrantExact, HostGrantSafePrefix, HostGrantPrefix: + out.HostGrantMode = HostGrantMode(cfg.HostGrantMode) + default: + return out, fmt.Errorf("invalid approval.host_grant_mode %q; expected exact, safe-prefix, or prefix", cfg.HostGrantMode) + } + } + if cfg.SessionTTL != "" { + duration, err := time.ParseDuration(cfg.SessionTTL) + if err != nil || duration <= 0 { + return out, fmt.Errorf("invalid approval.session_ttl %q", cfg.SessionTTL) + } + out.SessionTTL = duration + } + if cfg.WaitTimeout != "" { + duration, err := time.ParseDuration(cfg.WaitTimeout) + if err != nil || duration <= 0 { + return out, fmt.Errorf("invalid approval.wait_timeout %q", cfg.WaitTimeout) + } + out.WaitTimeout = duration + } + if envValue != "" { + enabled, err := parseBoolEnv(envValue) + if err != nil { + return out, err + } + out.Enabled = enabled + } + return out, nil +} + +func parseBoolEnv(value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on", "enabled": + return true, nil + case "0", "false", "no", "off", "disabled": + return false, nil + default: + return false, fmt.Errorf("invalid AGENTSSH_APPROVAL value %q; expected true/false", value) + } +} diff --git a/internal/approval/doc.go b/internal/approval/doc.go index b082d4d..0647e82 100644 --- a/internal/approval/doc.go +++ b/internal/approval/doc.go @@ -1,6 +1,6 @@ -// Package approval is intentionally empty in the MVP. +// Package approval implements AgentSSH's optional async approval backend. // -// AgentSSH does not implement synchronous approval, pending queues, or blocking -// approval UI in M0. The package is reserved for a future optional out-of-band -// approval mode described in the architecture. +// It is deliberately out-of-band: run requests that need approval return +// immediately, operators adjudicate later, and execution happens only when the +// agent reruns the command through the normal policy path. package approval diff --git a/internal/approval/generalize.go b/internal/approval/generalize.go new file mode 100644 index 0000000..e8bec7d --- /dev/null +++ b/internal/approval/generalize.go @@ -0,0 +1,273 @@ +package approval + +import ( + "errors" + "fmt" + "strings" + "unicode" + "unicode/utf8" +) + +type HostGrantMode string + +const ( + HostGrantExact HostGrantMode = "exact" + HostGrantSafePrefix HostGrantMode = "safe-prefix" + HostGrantPrefix HostGrantMode = "prefix" +) + +var ErrNULCommand = errors.New("approval command contains NUL") + +const tailTokenClass = `[A-Za-z0-9@%+=:,./_-]+` + +var interpreterOrEscapable = map[string]struct{}{ + "sh": {}, "bash": {}, "dash": {}, "zsh": {}, "ksh": {}, "env": {}, "find": {}, "xargs": {}, + "awk": {}, "gawk": {}, "sed": {}, "perl": {}, "ruby": {}, "node": {}, "php": {}, + "vi": {}, "vim": {}, "nano": {}, "emacs": {}, "less": {}, "more": {}, "man": {}, + "tar": {}, "zip": {}, "unzip": {}, "ssh": {}, "scp": {}, "rsync": {}, "nc": {}, "socat": {}, + "tee": {}, "watch": {}, "flock": {}, "setsid": {}, "ionice": {}, "nohup": {}, +} + +var privilegedCommands = map[string]struct{}{ + "sudo": {}, "su": {}, "doas": {}, +} + +var destructiveLeafCommands = map[string]struct{}{ + "rm": {}, "rmdir": {}, "dd": {}, "shred": {}, "shutdown": {}, "reboot": {}, "halt": {}, + "poweroff": {}, "kill": {}, "pkill": {}, "killall": {}, "chmod": {}, "chown": {}, "mv": {}, + "truncate": {}, "fdisk": {}, "parted": {}, +} + +var safeReadonlyLeaf = map[string]struct{}{ + "ls": {}, "df": {}, "free": {}, "uptime": {}, "uname": {}, "hostname": {}, "ps": {}, + "whoami": {}, "id": {}, "date": {}, +} + +var safeReadonlySubcommands = map[string]map[string]struct{}{ + "systemctl": {"status": {}, "is-active": {}, "is-enabled": {}, "show": {}, "cat": {}, "list-units": {}}, + "git": {"status": {}, "log": {}, "diff": {}, "show": {}}, + "kubectl": {"get": {}, "describe": {}, "logs": {}}, +} + +var multiCommand = map[string]struct{}{ + "systemctl": {}, "git": {}, "kubectl": {}, "docker": {}, "helm": {}, "service": {}, +} + +func Generalize(command string, mode HostGrantMode) (Matcher, error) { + if strings.Contains(command, "\x00") { + return Matcher{}, ErrNULCommand + } + if mode == "" { + mode = HostGrantSafePrefix + } + forceExact := scanForExactOnly(command) + tokens := splitASCIIWords(command) + if len(tokens) == 0 { + return exactMatcher(command, true), nil + } + head := commandBase(tokens[0]) + promotable := true + if _, ok := privilegedCommands[head]; ok { + promotable = false + forceExact = true + } + if isInterpreterOrEscapable(head) || isDestructiveLeaf(head) || hasEnvPrefix(command) { + forceExact = true + } + if forceExact || mode == HostGrantExact { + return exactMatcher(command, promotable), nil + } + + prefix := prefixForMode(tokens, head, mode) + if len(prefix) == 0 || !tailTokensSafe(tokens[len(prefix):]) { + return exactMatcher(command, promotable), nil + } + return prefixMatcher(command, prefix, promotable), nil +} + +func Exact(command string) (Matcher, error) { + if strings.Contains(command, "\x00") { + return Matcher{}, ErrNULCommand + } + return exactMatcher(command, true), nil +} + +func exactMatcher(command string, promotable bool) Matcher { + return mustValidateMatcher(Matcher{ + Kind: MatcherExact, + Regex: `\A` + quoteBytesForRegexp(command) + `\z`, + Promotable: promotable, + SourceCmd: command, + }) +} + +func prefixMatcher(command string, prefix []string, promotable bool) Matcher { + parts := make([]string, 0, len(prefix)) + for _, token := range prefix { + parts = append(parts, quoteBytesForRegexp(token)) + } + return mustValidateMatcher(Matcher{ + Kind: MatcherPrefix, + Regex: `\A` + strings.Join(parts, `[ \t]+`) + `(?:[ \t]+` + tailTokenClass + `)*[ \t]*\z`, + Prefix: append([]string(nil), prefix...), + Promotable: promotable, + SourceCmd: command, + }) +} + +func scanForExactOnly(command string) bool { + for i := 0; i < len(command); i++ { + b := command[i] + if b < 0x20 || b == 0x7f { + return true + } + if strings.ContainsRune(";&|(){}`$<>'\"\\*?[]~!#", rune(b)) { + return true + } + if b >= 0x80 { + for _, r := range command[i:] { + if unicode.IsSpace(r) { + return true + } + } + break + } + } + return false +} + +func splitASCIIWords(command string) []string { + var tokens []string + start := -1 + for i := 0; i < len(command); i++ { + if command[i] == ' ' { + if start >= 0 { + tokens = append(tokens, command[start:i]) + start = -1 + } + continue + } + if start < 0 { + start = i + } + } + if start >= 0 { + tokens = append(tokens, command[start:]) + } + return tokens +} + +func hasEnvPrefix(command string) bool { + trimmed := strings.TrimLeft(command, " ") + if trimmed == "" { + return false + } + first := trimmed + if idx := strings.IndexByte(trimmed, ' '); idx >= 0 { + first = trimmed[:idx] + } + eq := strings.IndexByte(first, '=') + if eq <= 0 || eq == len(first)-1 { + return false + } + for i := 0; i < eq; i++ { + b := first[i] + if (b < 'A' || b > 'Z') && (b < 'a' || b > 'z') && (b < '0' || b > '9') && b != '_' { + return false + } + } + return true +} + +func commandBase(token string) string { + token = strings.TrimSpace(token) + if idx := strings.LastIndexByte(token, '/'); idx >= 0 { + token = token[idx+1:] + } + return strings.ToLower(token) +} + +func isInterpreterOrEscapable(head string) bool { + if _, ok := interpreterOrEscapable[head]; ok { + return true + } + return strings.HasPrefix(head, "python") +} + +func isDestructiveLeaf(head string) bool { + if _, ok := destructiveLeafCommands[head]; ok { + return true + } + return strings.HasPrefix(head, "mkfs") +} + +func prefixForMode(tokens []string, head string, mode HostGrantMode) []string { + switch mode { + case HostGrantSafePrefix: + if _, ok := safeReadonlyLeaf[head]; ok { + return []string{tokens[0]} + } + if subcommands, ok := safeReadonlySubcommands[head]; ok && len(tokens) >= 2 { + if _, ok := subcommands[strings.ToLower(tokens[1])]; ok { + return []string{tokens[0], tokens[1]} + } + } + if head == "journalctl" { + return []string{tokens[0]} + } + return nil + case HostGrantPrefix: + if _, ok := multiCommand[head]; ok && len(tokens) >= 2 { + return []string{tokens[0], tokens[1]} + } + return []string{tokens[0]} + default: + return nil + } +} + +func tailTokensSafe(tokens []string) bool { + for _, token := range tokens { + if token == "" { + return false + } + for i := 0; i < len(token); i++ { + if !isTailByte(token[i]) { + return false + } + } + } + return true +} + +func isTailByte(b byte) bool { + return (b >= 'A' && b <= 'Z') || + (b >= 'a' && b <= 'z') || + (b >= '0' && b <= '9') || + b == '@' || b == '%' || b == '+' || b == '=' || b == ':' || b == ',' || + b == '.' || b == '/' || b == '_' || b == '-' +} + +func quoteBytesForRegexp(value string) string { + var builder strings.Builder + for i := 0; i < len(value); { + b := value[i] + if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || + b == '@' || b == '%' || b == '+' || b == '=' || b == ':' || b == ',' || b == '/' || b == '_' || b == '-' { + builder.WriteByte(b) + i++ + continue + } + if b >= 0x80 { + r, size := utf8.DecodeRuneInString(value[i:]) + if r != utf8.RuneError || size > 1 { + _, _ = fmt.Fprintf(&builder, `\x{%X}`, r) + i += size + continue + } + } + _, _ = fmt.Fprintf(&builder, `\x%02X`, b) + i++ + } + return builder.String() +} diff --git a/internal/approval/generalize_test.go b/internal/approval/generalize_test.go new file mode 100644 index 0000000..c3adb7e --- /dev/null +++ b/internal/approval/generalize_test.go @@ -0,0 +1,133 @@ +package approval + +import ( + "regexp" + "strings" + "testing" +) + +func TestGeneralizeSafePrefixTable(t *testing.T) { + tests := []struct { + command string + mode HostGrantMode + kind MatcherKind + prefix []string + promo bool + }{ + {"systemctl status nginx", HostGrantSafePrefix, MatcherPrefix, []string{"systemctl", "status"}, true}, + {"systemctl restart nginx", HostGrantSafePrefix, MatcherExact, nil, true}, + {"ls -la /var", HostGrantSafePrefix, MatcherPrefix, []string{"ls"}, true}, + {"cat /etc/passwd", HostGrantSafePrefix, MatcherExact, nil, true}, + {"sudo systemctl restart nginx", HostGrantSafePrefix, MatcherExact, nil, false}, + {"rm -rf /var/tmp/cache", HostGrantSafePrefix, MatcherExact, nil, true}, + {"systemctl restart nginx", HostGrantPrefix, MatcherPrefix, []string{"systemctl", "restart"}, true}, + } + for _, tt := range tests { + t.Run(tt.command, func(t *testing.T) { + got, err := Generalize(tt.command, tt.mode) + if err != nil { + t.Fatalf("Generalize: %v", err) + } + if got.Kind != tt.kind || got.Promotable != tt.promo { + t.Fatalf("matcher = %#v", got) + } + if strings.Join(got.Prefix, "\x00") != strings.Join(tt.prefix, "\x00") { + t.Fatalf("prefix = %#v, want %#v", got.Prefix, tt.prefix) + } + assertMatcherInvariant(t, got.Regex) + if matched, err := got.Match(tt.command); err != nil || !matched { + t.Fatalf("matcher should match source command: matched=%v err=%v regex=%s", matched, err, got.Regex) + } + }) + } +} + +func TestGeneralizeInjectionCorpusForcesExactOrRejects(t *testing.T) { + tests := []string{ + "ls\nrm -rf /", + "ls\rrm -rf /", + "ls\t-la", + "ls\f-la", + "ls\v-la", + "ls\u00a0-la", + "echo $(id)", + "cat /etc/passwd | grep root", + "echo hi \\", + "echo 'hi'", + `echo "hi"`, + "ls *", + "LD_PRELOAD=x ls", + } + for _, command := range tests { + t.Run(command, func(t *testing.T) { + matcher, err := Generalize(command, HostGrantSafePrefix) + if err != nil { + t.Fatalf("Generalize: %v", err) + } + if matcher.Kind != MatcherExact { + t.Fatalf("matcher kind = %s, want exact: %#v", matcher.Kind, matcher) + } + assertMatcherInvariant(t, matcher.Regex) + if matched, err := matcher.Match(command); err != nil || !matched { + t.Fatalf("exact matcher should match source: matched=%v err=%v regex=%s", matched, err, matcher.Regex) + } + }) + } +} + +func TestGeneralizeRejectsNUL(t *testing.T) { + if _, err := Generalize("ls\x00id", HostGrantSafePrefix); err != ErrNULCommand { + t.Fatalf("err = %v, want ErrNULCommand", err) + } +} + +func TestPrefixMatcherDoesNotMatchNewlineInjection(t *testing.T) { + matcher, err := Generalize("ls /var", HostGrantSafePrefix) + if err != nil { + t.Fatalf("Generalize: %v", err) + } + if matcher.Kind != MatcherPrefix { + t.Fatalf("kind = %s, want prefix", matcher.Kind) + } + for _, attack := range []string{"ls\nrm -rf /", "ls\rrm -rf /", "ls\t;rm", "ls $(id)", "ls | id"} { + matched, err := matcher.Match(attack) + if err != nil { + t.Fatalf("Match attack %q: %v", attack, err) + } + if matched { + t.Fatalf("prefix matcher %s matched attack %q", matcher.Regex, attack) + } + } +} + +func TestMatcherInvariantRejectsUnsafeRegexes(t *testing.T) { + for _, pattern := range []string{ + `^ls$`, + `\Als\s+foo\z`, + "\\Als\nfoo\\z", + `\Als.*\z`, + `\Als`, + } { + if err := validateMatcherInvariant(pattern); err == nil { + t.Fatalf("validateMatcherInvariant(%q) = nil, want error", pattern) + } + } +} + +func TestSplitASCIIWordsDoesNotUseUnicodeWhitespace(t *testing.T) { + got := splitASCIIWords("a\tb c\u00a0d e") + want := []string{"a\tb", "c\u00a0d", "e"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("splitASCIIWords = %#v, want %#v", got, want) + } +} + +func assertMatcherInvariant(t *testing.T, pattern string) { + t.Helper() + if err := validateMatcherInvariant(pattern); err != nil { + t.Fatalf("unsafe matcher invariant: %v", err) + } + if _, err := regexp.Compile(pattern); err != nil { + t.Fatalf("generated regex does not compile: %v pattern=%q", err, pattern) + } +} diff --git a/internal/approval/matcher.go b/internal/approval/matcher.go new file mode 100644 index 0000000..3961434 --- /dev/null +++ b/internal/approval/matcher.go @@ -0,0 +1,93 @@ +package approval + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" +) + +type MatcherKind string + +const ( + MatcherExact MatcherKind = "exact" + MatcherPrefix MatcherKind = "prefix" +) + +type Scope string + +const ( + ScopeOnce Scope = "once" + ScopeSession Scope = "session" + ScopeHost Scope = "host" +) + +type Verdict string + +const ( + VerdictApproved Verdict = "approved" + VerdictDenied Verdict = "denied" +) + +const ( + ChannelCLI = "cli" +) + +// Matcher is the reusable command matcher that can be stored in session grants +// or generated host rules. +type Matcher struct { + Kind MatcherKind `json:"kind"` + Regex string `json:"regex"` + Prefix []string `json:"prefix,omitempty"` + Promotable bool `json:"promotable"` + SourceCmd string `json:"source_cmd"` +} + +func (m Matcher) Match(command string) (bool, error) { + if err := validateMatcherInvariant(m.Regex); err != nil { + return false, err + } + expr, err := regexp.Compile(m.Regex) + if err != nil { + return false, err + } + return expr.MatchString(command), nil +} + +func (m Matcher) SHA256() string { + sum := sha256.Sum256([]byte(string(m.Kind) + "\x00" + m.Regex + "\x00" + strings.Join(m.Prefix, "\x00") + "\x00" + m.SourceCmd)) + return hex.EncodeToString(sum[:]) +} + +func matcherSHA12(m Matcher) string { + sum := m.SHA256() + if len(sum) < 12 { + return sum + } + return sum[:12] +} + +func validateMatcherInvariant(pattern string) error { + switch { + case !strings.HasPrefix(pattern, `\A`): + return fmt.Errorf("approval matcher is missing \\A anchor: %q", pattern) + case !strings.HasSuffix(pattern, `\z`): + return fmt.Errorf("approval matcher is missing \\z anchor: %q", pattern) + case strings.Contains(pattern, `\s`): + return fmt.Errorf("approval matcher must not contain \\s: %q", pattern) + case strings.Contains(pattern, "\n") || strings.Contains(pattern, "\r"): + return fmt.Errorf("approval matcher must not contain literal newlines: %q", pattern) + case strings.Contains(pattern, ".*"): + return fmt.Errorf("approval matcher must not contain .*: %q", pattern) + default: + return nil + } +} + +func mustValidateMatcher(m Matcher) Matcher { + if err := validateMatcherInvariant(m.Regex); err != nil { + panic(err) + } + return m +} diff --git a/internal/approval/request.go b/internal/approval/request.go new file mode 100644 index 0000000..887fd19 --- /dev/null +++ b/internal/approval/request.go @@ -0,0 +1,313 @@ +package approval + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +type PendingStore struct { + PendingDir string + ResponsesDir string + Now func() time.Time +} + +type PendingRequest struct { + Version int `json:"version"` + ID string `json:"id"` + ReqID string `json:"req_id"` + SessionID string `json:"session_id"` + Host string `json:"host"` + Cmd string `json:"cmd"` + CmdSHA256 string `json:"cmd_sha256"` + Candidate Matcher `json:"candidate_matcher"` + MatcherSHA256 string `json:"matcher_sha256"` + Kind string `json:"kind"` + Promotable bool `json:"promotable"` + TS string `json:"ts"` + ProposedScopes []Scope `json:"proposed_scope"` +} + +type Resolution struct { + Version int `json:"version"` + ID string `json:"id"` + ReqDigest string `json:"req_digest"` + Verdict Verdict `json:"verdict"` + Scope Scope `json:"scope,omitempty"` + TS string `json:"ts"` +} + +type StatusResult struct { + ID string `json:"id"` + Verdict Verdict `json:"verdict,omitempty"` + Scope Scope `json:"scope,omitempty"` + Status string `json:"status"` + Request *PendingRequest `json:"request,omitempty"` +} + +var ( + ErrInvalidID = errors.New("invalid approval id") + ErrPendingNotFound = errors.New("approval request not found") + ErrAlreadyResolved = errors.New("approval request already resolved") +) + +func NewID() (string, error) { + var bytes [12]byte + if _, err := rand.Read(bytes[:]); err != nil { + return "", fmt.Errorf("generate approval id: %w", err) + } + return "ap_" + hex.EncodeToString(bytes[:]), nil +} + +func (s PendingStore) Create(req PendingRequest) (PendingRequest, error) { + if req.ID == "" { + id, err := NewID() + if err != nil { + return PendingRequest{}, err + } + req.ID = id + } + if !validApprovalID(req.ID) { + return PendingRequest{}, ErrInvalidID + } + if err := os.MkdirAll(s.PendingDir, 0o700); err != nil { + return PendingRequest{}, fmt.Errorf("create pending approval directory: %w", err) + } + now := s.now().UTC() + req.Version = 1 + req.CmdSHA256 = shaHex(req.Cmd) + req.MatcherSHA256 = req.Candidate.SHA256() + req.Kind = string(req.Candidate.Kind) + req.Promotable = req.Candidate.Promotable + if req.TS == "" { + req.TS = now.Format(time.RFC3339) + } + if len(req.ProposedScopes) == 0 { + req.ProposedScopes = proposedScopes(req.Candidate) + } + data, err := json.MarshalIndent(req, "", " ") + if err != nil { + return PendingRequest{}, fmt.Errorf("marshal pending approval: %w", err) + } + path := pendingPath(s.PendingDir, req.ID) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + return PendingRequest{}, fmt.Errorf("approval id collision %s: %w", req.ID, err) + } + if err != nil { + return PendingRequest{}, fmt.Errorf("create pending approval: %w", err) + } + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() + return PendingRequest{}, fmt.Errorf("write pending approval: %w", err) + } + if err := file.Close(); err != nil { + return PendingRequest{}, fmt.Errorf("close pending approval: %w", err) + } + return req, nil +} + +func (s PendingStore) Get(id string) (PendingRequest, error) { + if !validApprovalID(id) { + return PendingRequest{}, ErrInvalidID + } + data, err := os.ReadFile(pendingPath(s.PendingDir, id)) + if errors.Is(err, os.ErrNotExist) { + return PendingRequest{}, ErrPendingNotFound + } + if err != nil { + return PendingRequest{}, fmt.Errorf("read pending approval: %w", err) + } + var req PendingRequest + if err := json.Unmarshal(data, &req); err != nil { + return PendingRequest{}, fmt.Errorf("parse pending approval: %w", err) + } + if req.ID != id || req.MatcherSHA256 != req.Candidate.SHA256() || req.CmdSHA256 != shaHex(req.Cmd) { + return PendingRequest{}, fmt.Errorf("pending approval %s digest mismatch", id) + } + return req, nil +} + +func (s PendingStore) List() ([]PendingRequest, error) { + entries, err := os.ReadDir(s.PendingDir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("list pending approvals: %w", err) + } + var out []PendingRequest + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + req, err := s.Get(id) + if err != nil { + continue + } + if status, err := s.Status(id); err == nil && status.Status != "pending" { + continue + } + out = append(out, req) + } + sort.Slice(out, func(i, j int) bool { + if out[i].TS != out[j].TS { + return out[i].TS < out[j].TS + } + return out[i].ID < out[j].ID + }) + return out, nil +} + +func (s PendingStore) Resolve(req PendingRequest, verdict Verdict, scope Scope) (Resolution, error) { + if !validApprovalID(req.ID) { + return Resolution{}, ErrInvalidID + } + if err := os.MkdirAll(s.ResponsesDir, 0o700); err != nil { + return Resolution{}, fmt.Errorf("create approval response directory: %w", err) + } + resolution := Resolution{ + Version: 1, + ID: req.ID, + ReqDigest: RequestDigest(req, scope), + Verdict: verdict, + Scope: scope, + TS: s.now().UTC().Format(time.RFC3339), + } + data, err := json.MarshalIndent(resolution, "", " ") + if err != nil { + return Resolution{}, fmt.Errorf("marshal approval response: %w", err) + } + path := responsePath(s.ResponsesDir, req.ID) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if errors.Is(err, os.ErrExist) { + return Resolution{}, ErrAlreadyResolved + } + if err != nil { + return Resolution{}, fmt.Errorf("create approval response: %w", err) + } + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() + return Resolution{}, fmt.Errorf("write approval response: %w", err) + } + if err := file.Close(); err != nil { + return Resolution{}, fmt.Errorf("close approval response: %w", err) + } + return resolution, nil +} + +func (s PendingStore) Status(id string) (StatusResult, error) { + req, err := s.Get(id) + if err != nil { + return StatusResult{}, err + } + resolution, ok, err := s.readResolution(id) + if err != nil { + return StatusResult{}, err + } + if !ok || resolution.ReqDigest != RequestDigest(req, resolution.Scope) { + return StatusResult{ID: id, Status: "pending", Request: &req}, nil + } + switch resolution.Verdict { + case VerdictApproved: + return StatusResult{ID: id, Status: "approved", Verdict: resolution.Verdict, Scope: resolution.Scope, Request: &req}, nil + case VerdictDenied: + return StatusResult{ID: id, Status: "denied", Verdict: resolution.Verdict, Scope: resolution.Scope, Request: &req}, nil + default: + return StatusResult{ID: id, Status: "pending", Request: &req}, nil + } +} + +func (s PendingStore) Wait(id string, timeout time.Duration) (StatusResult, error) { + deadline := time.Now().Add(timeout) + sleep := 50 * time.Millisecond + for { + status, err := s.Status(id) + if err != nil { + return StatusResult{}, err + } + if status.Status == "approved" || status.Status == "denied" { + return status, nil + } + if !time.Now().Before(deadline) { + return status, nil + } + time.Sleep(sleep) + if sleep < 500*time.Millisecond { + sleep *= 2 + } + } +} + +func (s PendingStore) readResolution(id string) (Resolution, bool, error) { + data, err := os.ReadFile(responsePath(s.ResponsesDir, id)) + if errors.Is(err, os.ErrNotExist) { + return Resolution{}, false, nil + } + if err != nil { + return Resolution{}, false, fmt.Errorf("read approval response: %w", err) + } + var resolution Resolution + if err := json.Unmarshal(data, &resolution); err != nil { + return Resolution{}, true, nil + } + if resolution.ID != id { + return Resolution{}, true, nil + } + return resolution, true, nil +} + +func RequestDigest(req PendingRequest, scope Scope) string { + parts := []string{req.ID, req.ReqID, req.SessionID, req.Host, req.CmdSHA256, req.MatcherSHA256, string(scope)} + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:]) +} + +func proposedScopes(matcher Matcher) []Scope { + scopes := []Scope{ScopeOnce, ScopeSession} + if matcher.Promotable { + scopes = append(scopes, ScopeHost) + } + return scopes +} + +func validApprovalID(id string) bool { + if !strings.HasPrefix(id, "ap_") || len(id) < len("ap_")+24 { + return false + } + for _, r := range id[len("ap_"):] { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } + } + return true +} + +func pendingPath(dir string, id string) string { + return filepath.Join(dir, id+".json") +} + +func responsePath(dir string, id string) string { + return filepath.Join(dir, id+".json") +} + +func shaHex(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func (s PendingStore) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} diff --git a/internal/approval/request_test.go b/internal/approval/request_test.go new file mode 100644 index 0000000..ec001e0 --- /dev/null +++ b/internal/approval/request_test.go @@ -0,0 +1,96 @@ +package approval + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestPendingResponseDigestAndOExcl(t *testing.T) { + root := t.TempDir() + store := PendingStore{PendingDir: filepath.Join(root, "pending"), ResponsesDir: filepath.Join(root, "responses")} + matcher, err := Generalize("systemctl status nginx", HostGrantSafePrefix) + if err != nil { + t.Fatal(err) + } + req, err := store.Create(PendingRequest{ + ReqID: "r1", + SessionID: "s_test", + Host: "web-1", + Cmd: "systemctl status nginx", + Candidate: matcher, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if len(req.ID) < len("ap_")+24 { + t.Fatalf("approval id too short: %q", req.ID) + } + if _, err := store.Create(req); err == nil { + t.Fatal("duplicate O_EXCL create succeeded") + } + resolution, err := store.Resolve(req, VerdictApproved, ScopeSession) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if resolution.ReqDigest != RequestDigest(req, ScopeSession) { + t.Fatalf("digest = %q want %q", resolution.ReqDigest, RequestDigest(req, ScopeSession)) + } + status, err := store.Status(req.ID) + if err != nil { + t.Fatalf("Status: %v", err) + } + if status.Status != "approved" || status.Scope != ScopeSession { + t.Fatalf("status = %#v", status) + } +} + +func TestResolutionDigestMismatchIsPending(t *testing.T) { + root := t.TempDir() + store := PendingStore{PendingDir: filepath.Join(root, "pending"), ResponsesDir: filepath.Join(root, "responses")} + matcher, err := Exact("id") + if err != nil { + t.Fatal(err) + } + req, err := store.Create(PendingRequest{ReqID: "r1", SessionID: "s", Host: "web-1", Cmd: "id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(store.ResponsesDir, 0o700); err != nil { + t.Fatal(err) + } + bad := Resolution{Version: 1, ID: req.ID, ReqDigest: "bad", Verdict: VerdictApproved, Scope: ScopeSession, TS: time.Now().UTC().Format(time.RFC3339)} + data, _ := json.Marshal(bad) + if err := os.WriteFile(responsePath(store.ResponsesDir, req.ID), data, 0o600); err != nil { + t.Fatal(err) + } + status, err := store.Status(req.ID) + if err != nil { + t.Fatalf("Status: %v", err) + } + if status.Status != "pending" { + t.Fatalf("status = %#v, want pending", status) + } +} + +func TestWaitExitSemanticsInputs(t *testing.T) { + root := t.TempDir() + store := PendingStore{PendingDir: filepath.Join(root, "pending"), ResponsesDir: filepath.Join(root, "responses")} + if _, err := store.Status("bad"); err != ErrInvalidID { + t.Fatalf("bad id err = %v, want ErrInvalidID", err) + } + matcher, _ := Exact("id") + req, err := store.Create(PendingRequest{ReqID: "r1", SessionID: "s", Host: "web-1", Cmd: "id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + status, err := store.Wait(req.ID, 10*time.Millisecond) + if err != nil { + t.Fatalf("Wait: %v", err) + } + if status.Status != "pending" { + t.Fatalf("wait status = %#v", status) + } +} diff --git a/internal/approval/session_store.go b/internal/approval/session_store.go new file mode 100644 index 0000000..d975bb2 --- /dev/null +++ b/internal/approval/session_store.go @@ -0,0 +1,287 @@ +package approval + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/Praeviso/AgentSSH/internal/fileutil" +) + +type SessionStore struct { + Dir string + Now func() time.Time +} + +type Grant struct { + Scope Scope `json:"scope"` + Kind MatcherKind `json:"kind"` + Regex string `json:"regex"` + Prefix []string `json:"prefix,omitempty"` + SourceCmd string `json:"source_cmd"` + Host string `json:"host"` + GrantedTS string `json:"granted_ts"` + ExpiresTS string `json:"expires_ts"` + ApprovalID string `json:"approval_id"` + ReqID string `json:"req_id"` + Channel string `json:"channel"` +} + +type sessionFile struct { + Version int `json:"version"` + SessionID string `json:"session_id"` + Host string `json:"host"` + Updated string `json:"updated"` + Grants []Grant `json:"grants"` +} + +var ErrSessionHostMismatch = errors.New("approval session is bound to a different host") + +func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher Matcher, 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) + } + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return Grant{}, fmt.Errorf("create approval session directory: %w", err) + } + 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, + } + err := s.withLockedSession(sessionID, func(doc *sessionFile) error { + if doc.Version == 0 { + doc.Version = 1 + } + if doc.SessionID == "" { + doc.SessionID = sessionID + } + if doc.SessionID != sessionID { + return fmt.Errorf("session store file mismatch: %q != %q", doc.SessionID, sessionID) + } + if doc.Host == "" { + doc.Host = host + } + if doc.Host != host { + return ErrSessionHostMismatch + } + 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 { + continue + } + out = append(out, existing) + } + doc.Grants = append(out, grant) + doc.Updated = now.UTC().Format(time.RFC3339) + return nil + }) + return grant, err +} + +func (s SessionStore) Match(sessionID string, host string, command string) (Grant, bool, error) { + return s.match(sessionID, host, command, true) +} + +func (s SessionStore) Peek(sessionID string, host string, command string) (Grant, bool, error) { + return s.match(sessionID, host, command, false) +} + +func (s SessionStore) match(sessionID string, host string, command string, consumeOnce bool) (Grant, bool, error) { + if sessionID == "" { + return Grant{}, false, nil + } + now := s.now() + var matched Grant + var ok bool + err := 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) + } + if doc.Host != "" && doc.Host != host { + return nil + } + live := filterLiveGrants(doc.Grants, now) + changed := len(live) != len(doc.Grants) + remaining := make([]Grant, 0, len(live)) + for _, grant := range live { + if ok { + remaining = append(remaining, grant) + continue + } + if grant.Host != host { + remaining = append(remaining, grant) + continue + } + matcher := grant.matcher() + matches, err := matcher.Match(command) + if err != nil { + return err + } + if !matches { + remaining = append(remaining, grant) + continue + } + matched = grant + ok = true + if grant.Scope == ScopeOnce && consumeOnce { + changed = true + continue + } + remaining = append(remaining, grant) + } + if changed { + doc.Grants = remaining + doc.Updated = now.UTC().Format(time.RFC3339) + } + return nil + }) + return matched, ok, err +} + +func (s SessionStore) End(sessionID string) error { + if sessionID == "" { + return nil + } + if err := os.MkdirAll(s.Dir, 0o700); err != nil { + return fmt.Errorf("create approval session directory: %w", err) + } + path := sessionPath(s.Dir, sessionID) + lock, err := lockSessionPath(path) + if err != nil { + return err + } + defer unlockAndClose(lock) + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove approval session: %w", err) + } + return nil +} + +func (s SessionStore) withLockedSession(sessionID string, fn func(*sessionFile) error) error { + if sessionID == "" { + return fmt.Errorf("approval session id is required") + } + path := sessionPath(s.Dir, sessionID) + lock, err := lockSessionPath(path) + if err != nil { + return err + } + defer unlockAndClose(lock) + doc, err := readSessionFile(path) + if err != nil { + return err + } + before, _ := json.Marshal(doc) + if err := fn(&doc); err != nil { + return err + } + after, _ := json.Marshal(doc) + if string(before) == string(after) { + return nil + } + data, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return fmt.Errorf("marshal approval session: %w", err) + } + if err := fileutil.WriteFileAtomic(path, append(data, '\n'), 0o600, "session-*.json"); err != nil { + return fileutil.LabelAtomicError(err, "approval session") + } + return nil +} + +func readSessionFile(path string) (sessionFile, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return sessionFile{}, nil + } + if err != nil { + return sessionFile{}, fmt.Errorf("read approval session: %w", err) + } + if len(data) == 0 { + return sessionFile{}, nil + } + var doc sessionFile + if err := json.Unmarshal(data, &doc); err != nil { + return sessionFile{}, fmt.Errorf("parse approval session: %w", err) + } + return doc, nil +} + +func lockSessionPath(path string) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create approval session directory: %w", err) + } + lockPath := path + ".lock" + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open approval session lock: %w", err) + } + if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { + _ = file.Close() + return nil, fmt.Errorf("lock approval session: %w", err) + } + return file, nil +} + +func unlockAndClose(file *os.File) { + _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) + _ = file.Close() +} + +func sessionPath(dir string, sessionID string) string { + sum := sha256.Sum256([]byte(sessionID)) + return filepath.Join(dir, hex.EncodeToString(sum[:])+".json") +} + +func filterLiveGrants(grants []Grant, now time.Time) []Grant { + out := make([]Grant, 0, len(grants)) + for _, grant := range grants { + if grant.ExpiresTS == "" { + out = append(out, grant) + continue + } + expires, err := time.Parse(time.RFC3339, grant.ExpiresTS) + if err != nil || !now.Before(expires) { + continue + } + out = append(out, grant) + } + return out +} + +func (s SessionStore) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func (g Grant) matcher() Matcher { + return Matcher{ + Kind: g.Kind, + Regex: g.Regex, + Prefix: append([]string(nil), g.Prefix...), + Promotable: true, + SourceCmd: g.SourceCmd, + } +} diff --git a/internal/approval/session_store_test.go b/internal/approval/session_store_test.go new file mode 100644 index 0000000..c8edfc1 --- /dev/null +++ b/internal/approval/session_store_test.go @@ -0,0 +1,69 @@ +package approval + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestSessionStoreGrantMatchTTLHostAndEnd(t *testing.T) { + now := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) + store := SessionStore{Dir: t.TempDir(), Now: func() time.Time { return now }} + matcher, err := Exact("systemctl restart nginx") + if err != nil { + t.Fatal(err) + } + 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 { + 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 { + 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 { + 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 { + 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 { + t.Fatalf("ended session match ok=%v err=%v", ok, err) + } +} + +func TestSessionStoreOnceConcurrentConsumesOnce(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 { + t.Fatalf("Grant: %v", err) + } + var matched int32 + var wg sync.WaitGroup + for i := 0; i < 24; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, ok, err := store.Match("s_once", "web-1", "id"); err != nil { + t.Errorf("Match: %v", err) + } else if ok { + atomic.AddInt32(&matched, 1) + } + }() + } + wg.Wait() + if got := atomic.LoadInt32(&matched); got != 1 { + t.Fatalf("once grant matched %d times, want 1", got) + } +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index f153fd3..778d57d 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -87,6 +87,63 @@ func TestVerifyDetectsChangedDeletedInsertedRecords(t *testing.T) { }) } +func TestApprovalFieldsAreHashProtectedAndOldRecordsVerify(t *testing.T) { + store := NewStore(filepath.Join(t.TempDir(), "audit.log")) + old, err := store.Append(Record{ReqID: "old", Event: EventStarted, Host: "web-1"}) + if err != nil { + t.Fatalf("append old: %v", err) + } + approval, err := store.Append(Record{ + ReqID: "r1", + Event: EventApprovalRequested, + Host: "web-1", + ApprovalID: "ap_0123456789abcdef01234567", + ApprovalScope: "session", + ApprovalMatcher: `\Aid\z`, + ApprovalChannel: "exit", + }) + if err != nil { + t.Fatalf("append approval: %v", err) + } + records := mustRead(t, store) + records[0].Hash = ComputeHash(records[0]) + if records[0].Hash != old.Hash { + t.Fatalf("old hash changed after approval fields were added: %s != %s", records[0].Hash, old.Hash) + } + result, err := store.Verify() + if err != nil { + t.Fatalf("verify: %v", err) + } + if !result.OK { + t.Fatalf("verify = %#v", result) + } + + fields := []struct { + name string + edit func(*Record) + }{ + {"approval_id", func(r *Record) { r.ApprovalID = "ap_ffffffffffffffffffffffff" }}, + {"approval_scope", func(r *Record) { r.ApprovalScope = "host" }}, + {"approval_matcher", func(r *Record) { r.ApprovalMatcher = `\Als\z` }}, + {"approval_channel", func(r *Record) { r.ApprovalChannel = "cli" }}, + } + for _, tt := range fields { + t.Run(tt.name, func(t *testing.T) { + tampered := append([]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 != approval.Seq || result.Reason != "hash" { + t.Fatalf("tamper result = %#v", result) + } + writeRecords(t, store.Path, records) + }) + } +} + func TestTruncateBrokenRemovesBrokenTailAndBacksUp(t *testing.T) { store := testStoreWithRecords(t) records := mustRead(t, store) diff --git a/internal/audit/record.go b/internal/audit/record.go index ccd026b..b8e2c7c 100644 --- a/internal/audit/record.go +++ b/internal/audit/record.go @@ -18,10 +18,13 @@ import ( type Event string const ( - EventStarted Event = "started" - EventCompleted Event = "completed" - EventFailed Event = "failed" - EventDenied Event = "denied" + EventStarted Event = "started" + EventCompleted Event = "completed" + EventFailed Event = "failed" + EventDenied Event = "denied" + EventApprovalRequested Event = "approval_requested" + EventApprovalGranted Event = "approval_granted" + EventApprovalDenied Event = "approval_denied" ) // Record is one append-only JSONL audit entry. @@ -46,6 +49,10 @@ type Record struct { DurationMS int64 `json:"duration_ms"` PrevHash string `json:"prev_hash"` Hash string `json:"hash"` + ApprovalID string `json:"approval_id,omitempty"` + ApprovalScope string `json:"approval_scope,omitempty"` + ApprovalMatcher string `json:"approval_matcher,omitempty"` + ApprovalChannel string `json:"approval_channel,omitempty"` } const ZeroHash = "0000000000000000000000000000000000000000000000000000000000000000" @@ -346,6 +353,10 @@ type canonicalRecord struct { Redactions int `json:"redactions"` DurationMS int64 `json:"duration_ms"` PrevHash string `json:"prev_hash"` + ApprovalID string `json:"approval_id,omitempty"` + ApprovalScope string `json:"approval_scope,omitempty"` + ApprovalMatcher string `json:"approval_matcher,omitempty"` + ApprovalChannel string `json:"approval_channel,omitempty"` } func canonicalJSON(record Record) ([]byte, error) { @@ -367,5 +378,9 @@ func canonicalJSON(record Record) ([]byte, error) { Redactions: record.Redactions, DurationMS: record.DurationMS, PrevHash: record.PrevHash, + ApprovalID: record.ApprovalID, + ApprovalScope: record.ApprovalScope, + ApprovalMatcher: record.ApprovalMatcher, + ApprovalChannel: record.ApprovalChannel, }) } diff --git a/internal/config/config.go b/internal/config/config.go index 6d86856..0baa42a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,8 @@ import ( const ( // EnvHome overrides the default ~/.agentssh configuration directory. EnvHome = "AGENTSSH_HOME" + // EnvApproval enables the optional async approval flow when set truthy. + EnvApproval = "AGENTSSH_APPROVAL" // DefaultDirName is the configuration directory under the user's home. DefaultDirName = ".agentssh" ) @@ -75,6 +77,10 @@ type Paths struct { PolicyFile string AuditFile string SecretsFile string + ApprovalsDir string + SessionsDir string + PendingDir string + ResponsesDir string } // Config is the parsed local configuration set. @@ -185,12 +191,17 @@ func seedFileIfMissing(path, content string) error { // NewPaths derives the MVP file layout from a configuration home directory. func NewPaths(home string) Paths { + approvalsDir := filepath.Join(home, "approvals") return Paths{ Home: home, InventoryFile: filepath.Join(home, "inventory.yaml"), PolicyFile: filepath.Join(home, "policy.yaml"), AuditFile: filepath.Join(home, "audit.log"), SecretsFile: filepath.Join(home, "secrets.enc"), + ApprovalsDir: approvalsDir, + SessionsDir: filepath.Join(approvalsDir, "sessions"), + PendingDir: filepath.Join(approvalsDir, "pending"), + ResponsesDir: filepath.Join(approvalsDir, "responses"), } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2f27782..790e270 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -57,6 +57,12 @@ func TestLoadValid(t *testing.T) { if cfg.Paths.AuditFile != filepath.Join(dir, "audit.log") { t.Fatalf("audit path = %q", cfg.Paths.AuditFile) } + if cfg.Paths.ApprovalsDir != filepath.Join(dir, "approvals") || + cfg.Paths.SessionsDir != filepath.Join(dir, "approvals", "sessions") || + cfg.Paths.PendingDir != filepath.Join(dir, "approvals", "pending") || + cfg.Paths.ResponsesDir != filepath.Join(dir, "approvals", "responses") { + t.Fatalf("approval paths = %#v", cfg.Paths) + } } func TestEnsureHomeCreatesAndSeeds(t *testing.T) { diff --git a/internal/policy/policy.go b/internal/policy/policy.go index 711b6fd..2d2e592 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -18,6 +18,16 @@ const ( ActionDeny Action = "deny" ) +const ( + // RuleDefaultDeny is the immutable fallback rule name returned when no rule + // matches. Approval code uses this symbol to distinguish gray-area commands + // from explicit deny rules. + RuleDefaultDeny = "default-deny" + // ApprovalGroup is the reserved provenance marker for generated approval + // host rules. It is not a user-authorable rule group name. + ApprovalGroup = "__agentssh_approval" +) + // Decision is the result of evaluating a command against policy. type Decision struct { Action Action `json:"action"` @@ -41,9 +51,20 @@ type Config struct { RuleGroups map[string]RuleGroup `yaml:"rule_groups" json:"rule_groups"` HostOverrides map[string]HostOverride `yaml:"host_overrides" json:"host_overrides"` Output Output `yaml:"output" json:"output"` + Approval Approval `yaml:"approval,omitempty" json:"approval,omitempty"` hostOrder []string } +// Approval is the optional async approval policy block. Durations are stored as +// strings so policy.yaml stays operator-readable ("12h", "10m"); the approval +// package parses and defaults them at runtime. +type Approval struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + HostGrantMode string `yaml:"host_grant_mode,omitempty" json:"host_grant_mode,omitempty"` + SessionTTL string `yaml:"session_ttl,omitempty" json:"session_ttl,omitempty"` + WaitTimeout string `yaml:"wait_timeout,omitempty" json:"wait_timeout,omitempty"` +} + // RuleGroup is an authoring-only preset. The engine ignores it; callers stamp a // snapshot copy onto a concrete host override when they want it to take effect. type RuleGroup struct { @@ -154,6 +175,7 @@ func (c *Config) UnmarshalYAML(value *yaml.Node) error { RuleGroups map[string]RuleGroup `yaml:"rule_groups"` HostOverrides map[string]HostOverride `yaml:"host_overrides"` Output Output `yaml:"output"` + Approval Approval `yaml:"approval"` } var raw configYAML if err := value.Decode(&raw); err != nil { @@ -165,6 +187,7 @@ func (c *Config) UnmarshalYAML(value *yaml.Node) error { RuleGroups: raw.RuleGroups, HostOverrides: raw.HostOverrides, Output: raw.Output, + Approval: raw.Approval, hostOrder: hostOverrideOrder(value), } return nil @@ -178,9 +201,16 @@ func (c Config) MarshalYAML() (any, error) { appendNode(root, "rule_groups", mustYAMLNode(c.RuleGroups)) appendNode(root, "host_overrides", c.hostOverridesNode()) appendNode(root, "output", mustYAMLNode(c.Output)) + if !approvalZero(c.Approval) { + appendNode(root, "approval", mustYAMLNode(c.Approval)) + } return root, nil } +func approvalZero(value Approval) bool { + return !value.Enabled && value.HostGrantMode == "" && value.SessionTTL == "" && value.WaitTimeout == "" +} + func appendNode(mapping *yaml.Node, key string, value *yaml.Node) { mapping.Content = append(mapping.Content, scalarNode(key), value) } @@ -313,7 +343,7 @@ func (e *compiledEngine) Evaluate(host string, command string) (Decision, error) } } - return Decision{Action: ActionDeny, Rule: "default-deny"}, nil + return Decision{Action: ActionDeny, Rule: RuleDefaultDeny}, nil } func (e *compiledEngine) hostRulesFor(hostName string) []compiledRule { diff --git a/internal/policy/store.go b/internal/policy/store.go index ea98f7d..0307dc6 100644 --- a/internal/policy/store.go +++ b/internal/policy/store.go @@ -21,6 +21,7 @@ var ( ErrGroupNotFound = errors.New("policy rule group not found") ErrHostNotFound = errors.New("policy host rules host not found") ErrNoHostRules = errors.New("policy host has no rules") + ErrReservedGroup = errors.New("policy rule group name is reserved") ) // HostRuleSet bundles the policy rules owned by one concrete host. It is stored @@ -271,6 +272,9 @@ func CreateGroup(bundle Bundle, name string) (Bundle, error) { if name == "" { return bundle, fmt.Errorf("policy rule group name is required") } + if isReservedGroupName(name) { + return bundle, ErrReservedGroup + } if _, ok := bundle.Policy.RuleGroups[name]; ok { return bundle, ErrGroupExists } @@ -360,6 +364,9 @@ func StampGroupOntoHost(bundle Bundle, host string, groupName string) (Bundle, e if host == "" { return bundle, fmt.Errorf("policy host name is required") } + if isReservedGroupName(groupName) { + return bundle, ErrReservedGroup + } if _, ok := bundle.Inventory.Hosts[host]; !ok { return bundle, ErrHostNotFound } @@ -436,6 +443,10 @@ func removeRulesFromGroup(rules []Rule, groupName string) []Rule { return out } +func isReservedGroupName(name string) bool { + return name == ApprovalGroup || strings.HasPrefix(name, "__agentssh_") +} + func ruleIndex(rules []Rule, name string) (int, error) { found := -1 for i, rule := range rules { diff --git a/internal/policy/store_test.go b/internal/policy/store_test.go index 1026b27..161d6a0 100644 --- a/internal/policy/store_test.go +++ b/internal/policy/store_test.go @@ -312,6 +312,23 @@ func TestRuleGroupCRUD(t *testing.T) { } } +func TestRuleGroupRejectsReservedApprovalNames(t *testing.T) { + for _, name := range []string{ApprovalGroup, "__agentssh_custom"} { + t.Run(name, func(t *testing.T) { + if _, err := CreateGroup(Bundle{}, name); !errors.Is(err, ErrReservedGroup) { + t.Fatalf("CreateGroup err = %v, want ErrReservedGroup", err) + } + bundle := Bundle{ + Policy: Config{RuleGroups: map[string]RuleGroup{name: {}}}, + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, + } + if _, err := StampGroupOntoHost(bundle, "web-1", name); !errors.Is(err, ErrReservedGroup) { + t.Fatalf("StampGroupOntoHost err = %v, want ErrReservedGroup", err) + } + }) + } +} + func TestStampGroupOntoHostCopiesWithProvenance(t *testing.T) { bundle := Bundle{ Policy: Config{RuleGroups: map[string]RuleGroup{ diff --git a/skills/agentssh-usage/SKILL.md b/skills/agentssh-usage/SKILL.md index 2eecdbd..012163d 100644 --- a/skills/agentssh-usage/SKILL.md +++ b/skills/agentssh-usage/SKILL.md @@ -42,11 +42,39 @@ 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.** A `deny` is final — do not retry the same command, reword it, or look for a syntax that slips past. An `allow` may still trigger an operator confirmation; wait for it. +- **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. - **One session per task.** Don't reuse a previous task's `AGENTSSH_SESSION`, 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. - **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 · `9` connection failed. A `9` means fix connectivity/inventory, not retry blindly. +- **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. + +## Async approval flow + +When optional approval is enabled, a gray-area command that hits `default-deny` returns immediately with exit `7` and a JSON body like: + +```json +{ + "status": "approval_pending", + "approval_id": "ap_0123456789abcdef01234567", + "host": "web-1", + "cmd": "systemctl status nginx" +} +``` + +Your flow is: + +1. Tell the operator the `approval_id`, host, session id, and exact command. +2. Wait for a result: + + ```bash + agentssh approval wait --timeout 10m + ``` + +3. If approved, rerun the original `agentssh run ... -- ` cleanly. Execution only happens on the rerun, after AgentSSH rechecks current policy. +4. If denied or exit `6`, stop. Do not rewrite the command to bypass policy. + +`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`. ## Command reference @@ -56,6 +84,8 @@ agentssh session new # mint a fresh session id f agentssh session ls # recent sessions (id / label / span / command count) agentssh run [--session ] [--session-label ] [--json] -- 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 audit ls [--session ] | show | verify # browse / inspect / verify the hash chain agentssh policy test --host '' # check how a command would be judged, without running it agentssh tui # interactive audit + policy viewer From 1d7ec334d16bc11e843640445dabe2a28909c17b Mon Sep 17 00:00:00 2001 From: Kritoooo Date: Tue, 30 Jun 2026 21:36:18 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(tui):=20Approvals=20tab=20=E2=80=94=20?= =?UTF-8?q?consequence-first=20gray-zone=20adjudication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Approvals top-level tab to `agentssh tui` so the operator can adjudicate gray-zone approval requests without leaving the console (previously CLI-only via `agentssh approval`). Design (docs/plans/tui-approvals-final.html): one line per request, no per-row key column, and a single "consequence-first" footer that — for the focused request — states in plain English what an [h] host-allow would free (`systemctl status *`), or why host is unavailable (sudo) / exact-only (pipe). The list polls pending/ on a 1.5s tick so the queue and a status-bar "N pending" badge stay live from any tab. Keys o/s/h/d decide via the same approval.ApplyDecision wiring as the CLI (reloads inventory+policy from disk; the TUI is the operator console so no extra master/TTY gate). Host scope on a privileged request is refused in place without writing. English UI throughout. internal/tui/app.go gains the third entry tab (tab/1-3 cycle, %3), routing, polling, help, and the badge. New ChannelTUI constant. overview.md §5/§10 updated: the optional async approval is shipped (default-off, exit 7, TUI adjudicates); TUI is no longer audit-only. Tests: render + cursor-driven consequence + privileged-host refusal + disabled-off-note. gofmt/vet clean, go test -race ./... all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/architecture/overview.md | 8 +- internal/approval/matcher.go | 1 + internal/tui/app.go | 95 +++++-- internal/tui/approvals_section.go | 341 +++++++++++++++++++++++++ internal/tui/approvals_section_test.go | 111 ++++++++ 5 files changed, 527 insertions(+), 29 deletions(-) create mode 100644 internal/tui/approvals_section.go create mode 100644 internal/tui/approvals_section_test.go diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 8c32952..e03855b 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -2,7 +2,7 @@ > 状态:草案 v1 · 更新于 2026-06-16 · 配套:`docs/prds/agentssh.md` > -> 审批模型(已定):**AgentSSH 不做同步审批**。交互式确认委托给 agent harness;AgentSSH 只保留 `deny` 硬拦截 + 全量审计。详见 §5。 +> 审批模型:硬 `deny` 不可覆盖 + 全量审计始终是底线。在此之上提供**可选(默认关)的异步审批**——灰区(`default-deny`)命令可由 agent 申请,`run` 返回 exit 7 **不阻塞**,操作者在 TUI(或 `agentssh approval` CLI)裁决 once/session/host。**不做同步阻塞审批**。详见 §5 与 `docs/plans/approval-system.md`。 ## 1. 组件总览 @@ -45,7 +45,7 @@ - **Executor**:经 SSH 执行,捕获 stdout/stderr/exit。 - **Output Filter**:回流给 Agent 前脱敏 + 截断。 - **Audit**:每次请求落 hash 链日志(allow 与 deny 都记)。 -- **TUI**:人类的**审计查看**界面,不含审批(详见 `docs/DESIGN.md`)。 +- **TUI**:人类的**审计查看 + 灰区审批裁决**界面(详见 `docs/DESIGN.md` 与 `docs/plans/approval-system.md`)。 > 注:同步审批(pending 队列、阻塞 run、人工裁决)**不在本架构内**。它属于 agent harness 的职责;AgentSSH 的安全底线是 resource-side 的 `deny` + 事后审计。理由见 §5。 @@ -161,7 +161,7 @@ run web-1 -- sudo systemctl restart nginx - **交互式确认**(人盯着 allow/deny):与 agent harness 的权限系统**重复**。harness 已拦截 Bash 调用、看得到完整 `agentssh run … -- `。再造一套 TUI 审批 = 双重弹窗。→ **委托给 harness。** - **硬边界 `deny`**(谁都不能临场放行):**不可委托**。harness 在 Agent 侧信任边界内,在 headless / 自动批准 / `agentssh run:*` 被加白名单时全部失效;且 harness 只看到命令字符串,不知道目标是 prod、不知道命中了哪条危险规则。→ **留在 CLI,做成不可覆盖的 deny。** -决策前提:**主要交互式、人盯着**(见 PRD)。无人值守下若需要对灰区也强制人工裁决,引入「out-of-band 同步审批」作为**可选模式**(未来):pending 请求落 `~/.agentssh/approvals/`,人在 TUI 裁决,run 阻塞等待。MVP 不实现,但包结构为其预留(§10)。 +决策前提:**主要交互式、人盯着**(见 PRD)。灰区(`default-deny`)的可选人工裁决**已实现为异步、非阻塞模式**(默认关,`approval.enabled` / `AGENTSSH_APPROVAL` 开启):pending 请求落 `~/.agentssh/approvals/`,`run` **立即返回 exit 7 不阻塞**,操作者在 TUI 的 Approvals 标签(或 `agentssh approval` CLI)裁决 once/session/host,模型轮询 `approval status/wait` 或重跑后继续。硬 `deny` 仍不可覆盖,显式 deny 规则结构上进不了审批通道。详见 `docs/plans/approval-system.md`。 ## 6. 审计日志 @@ -259,7 +259,7 @@ agentssh/ session/ # 会话解析(--session / env,必须声明)、id 生成、聚合 output/ # 脱敏 + 截断 tui/ # bubbletea:审计查看器 - approval/ # (预留,MVP 空)未来 out-of-band 同步审批 + approval/ # 灰区异步审批:Generalize/Authorize/grant store/裁决(默认关) skills/ # AgentSSH 使用手册(SKILL.md):最佳实践,供 Agent 参考 docs/ ``` diff --git a/internal/approval/matcher.go b/internal/approval/matcher.go index 3961434..1523d74 100644 --- a/internal/approval/matcher.go +++ b/internal/approval/matcher.go @@ -32,6 +32,7 @@ const ( const ( ChannelCLI = "cli" + ChannelTUI = "tui" ) // Matcher is the reusable command matcher that can be stored in session grants diff --git a/internal/tui/app.go b/internal/tui/app.go index 7c35005..56fc9e8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/Praeviso/AgentSSH/internal/approval" "github.com/Praeviso/AgentSSH/internal/audit" "github.com/Praeviso/AgentSSH/internal/config" "github.com/Praeviso/AgentSSH/internal/inventory" @@ -77,13 +78,18 @@ type entryTab int const ( entryHosts entryTab = iota entryPolicy + entryApprovals ) func (t entryTab) title() string { - if t == entryPolicy { + switch t { + case entryPolicy: return "Policy" + case entryApprovals: + return "Approvals" + default: + return "Hosts" } - return "Hosts" } // detailPane is the active sub-view inside a host's detail screen. @@ -126,18 +132,19 @@ func toastCmd(text string) tea.Cmd { } type appModel struct { - paths config.Paths - renderer *lipgloss.Renderer - styles appStyles - hosts hostsSection // host card grid + add/discover/confirm overlays + host CRUD - sessions model // audit/session viewer (filtered to the selected host in detail) - policy policySection // global policy viewer (scoped to the selected host in detail) - help help.Model - toast string - toastID int - w, h int - ready bool - firstRun bool // show the one-time welcome banner until the first keypress + paths config.Paths + renderer *lipgloss.Renderer + styles appStyles + hosts hostsSection // host card grid + add/discover/confirm overlays + host CRUD + sessions model // audit/session viewer (filtered to the selected host in detail) + policy policySection // global policy viewer (scoped to the selected host in detail) + approvals approvalsSection // gray-zone approval queue (operator adjudication) + help help.Model + toast string + toastID int + w, h int + ready bool + firstRun bool // show the one-time welcome banner until the first keypress screen screen entryTab entryTab @@ -207,6 +214,7 @@ func newAppModel(paths config.Paths, renderer *lipgloss.Renderer) appModel { store := audit.NewStore(paths.AuditFile) records, _ := store.ReadAll() hosts := hostMetaFromInventory(inv) + runtime, _ := approval.RuntimeConfigFromPolicy(pol.Approval, os.Getenv(config.EnvApproval)) return appModel{ paths: paths, @@ -217,7 +225,8 @@ func newAppModel(paths config.Paths, renderer *lipgloss.Renderer) appModel { sessions: newModel(records, hosts, newStyles(renderer), func() (audit.VerifyResult, error) { return store.Verify() }), - policy: newPolicySection(paths.PolicyFile, inv, pol, st, firstErr(invErr, polErr)), + policy: newPolicySection(paths.PolicyFile, inv, pol, st, firstErr(invErr, polErr)), + approvals: newApprovalsSection(paths, st, runtime), } } @@ -257,7 +266,7 @@ func hostMetaFromInventory(inv inventory.Inventory) map[string]HostMeta { } func (m appModel) Init() tea.Cmd { - return tea.Batch(m.hosts.Init(), m.sessions.Init(), m.policy.Init()) + return tea.Batch(m.hosts.Init(), m.sessions.Init(), m.policy.Init(), m.approvals.Init()) } func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -293,6 +302,10 @@ func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Async Hosts results and the spinner tick must reach the Hosts section // regardless of screen; runID/host guards make stale delivery safe. return m.updateHosts(msg) + case approvalsTickMsg, approvalsLoadedMsg: + // Poll the approval queue regardless of the active tab so the tab badge + // and queue stay live; the section re-arms the tick. + return m.updateApprovals(msg) case tea.KeyMsg: if msg.Type == tea.KeyCtrlC { return m, tea.Quit @@ -343,13 +356,13 @@ func (m appModel) updateEntryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "q": return m, tea.Quit case "tab": - m.entryTab = (m.entryTab + 1) % 2 + m.entryTab = (m.entryTab + 1) % 3 if m.entryTab == entryPolicy { m.policy = m.policy.withHost("") } return m.propagateSize() case "shift+tab": - m.entryTab = (m.entryTab + 1) % 2 + m.entryTab = (m.entryTab + 2) % 3 if m.entryTab == entryPolicy { m.policy = m.policy.withHost("") } @@ -361,8 +374,14 @@ func (m appModel) updateEntryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.entryTab = entryPolicy m.policy = m.policy.withHost("") return m.propagateSize() + case "3": + m.entryTab = entryApprovals + return m.propagateSize() } } + if m.entryTab == entryApprovals { + return m.updateApprovals(msg) + } if m.entryTab == entryPolicy { return m.updatePolicy(msg) } @@ -564,6 +583,12 @@ func (m appModel) propagateSize() (appModel, tea.Cmd) { m.policy = next } cmds = append(cmds, cmd) + + as, cmd := m.approvals.Update(tea.WindowSizeMsg{Width: m.w, Height: body}) + if next, ok := as.(approvalsSection); ok { + m.approvals = next + } + cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } @@ -616,6 +641,8 @@ func (m appModel) View() string { if m.ready { if m.screen == screenDetail { body = m.renderDetail() + } else if m.entryTab == entryApprovals { + body = m.approvals.View() } else if m.entryTab == entryPolicy { // The pane is already scoped to host "" whenever this tab is active (the // tab-switch handlers call withHost("")). Render it as-is — calling @@ -696,8 +723,8 @@ func (m appModel) renderStatusBar() string { } else { n := len(m.hosts.names) crumb := m.styles.crumb.Render("AgentSSH") - tabs := make([]string, 0, 2) - for _, tab := range []entryTab{entryHosts, entryPolicy} { + tabs := make([]string, 0, 3) + for _, tab := range []entryTab{entryHosts, entryPolicy, entryApprovals} { label := fmt.Sprintf("%d %s", int(tab)+1, tab.title()) if tab == m.entryTab { tabs = append(tabs, m.styles.activeTab.Render(label)) @@ -707,6 +734,9 @@ func (m appModel) renderStatusBar() string { } count := m.styles.dim.Render(fmt.Sprintf(" · %d %s", n, plural(n, "host", "hosts"))) bar = lipgloss.JoinHorizontal(lipgloss.Top, crumb, " ", lipgloss.JoinHorizontal(lipgloss.Top, tabs...), count) + if p := m.approvals.pendingCount(); p > 0 { + bar += m.styles.confirm.Render(fmt.Sprintf(" · %d pending", p)) + } if m.hosts.filterActive() { bar += m.styles.dim.Render(fmt.Sprintf(" · %d shown", m.hosts.visibleCount())) } @@ -764,11 +794,14 @@ func (m appModel) renderFooter() string { // the ? overlay (FullHelp) carries every binding, grouped into columns. func (m appModel) activeHelpKeyMap() help.KeyMap { if m.screen != screenDetail { - nav := []key.Binding{hk("tab/1-2", "tabs")} + nav := []key.Binding{hk("tab/1-3", "tabs")} var section help.KeyMap - if m.entryTab == entryPolicy { + switch m.entryTab { + case entryPolicy: section = m.policy.helpKeyMap() - } else { + case entryApprovals: + section = m.approvals.helpKeyMap() + default: section = m.hosts.helpKeyMap() } return mergeHelp(section, nav) @@ -821,10 +854,22 @@ func mergeHelp(section help.KeyMap, nav []key.Binding) helpMap { } func (m appModel) entryCapturing() bool { - if m.entryTab == entryPolicy { + switch m.entryTab { + case entryPolicy: return m.policy.capturing() + case entryApprovals: + return m.approvals.capturing() + default: + return m.hosts.capturing() } - return m.hosts.capturing() +} + +func (m appModel) updateApprovals(msg tea.Msg) (tea.Model, tea.Cmd) { + updated, cmd := m.approvals.Update(msg) + if as, ok := updated.(approvalsSection); ok { + m.approvals = as + } + return m, cmd } func (m appModel) fullWidthLine(value string) string { diff --git a/internal/tui/approvals_section.go b/internal/tui/approvals_section.go new file mode 100644 index 0000000..4712748 --- /dev/null +++ b/internal/tui/approvals_section.go @@ -0,0 +1,341 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "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/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +// approvalsSection is the operator's adjudication surface for gray-zone +// (default-deny) command requests. The agent's `run` returns exit 7 and writes +// a pending request; this tab lists those requests one line each and lets the +// operator decide each with o/s/h/d. "Consequence-first": the footer shows, for +// the focused request, exactly what an [h] host-allow would free. +type approvalsSection struct { + paths config.Paths + styles appStyles + runtime approval.RuntimeConfig + pending []approval.PendingRequest + cursor int + err error + result string + w, h int +} + +const approvalsPollInterval = 1500 * time.Millisecond + +// approvalsTickMsg drives periodic polling of the pending queue. +type approvalsTickMsg struct{} + +// approvalsLoadedMsg carries a freshly listed pending queue. +type approvalsLoadedMsg struct { + pending []approval.PendingRequest + err error +} + +func newApprovalsSection(paths config.Paths, st appStyles, runtime approval.RuntimeConfig) approvalsSection { + return approvalsSection{paths: paths, styles: st, runtime: runtime} +} + +// capturing is always false: this tab has no text input or modal, so the shell +// keeps ownership of tab/esc/q. +func (s approvalsSection) capturing() bool { return false } + +func (s approvalsSection) Init() tea.Cmd { + return tea.Batch(s.loadCmd(), approvalsTickCmd()) +} + +func approvalsTickCmd() tea.Cmd { + return tea.Tick(approvalsPollInterval, func(time.Time) tea.Msg { return approvalsTickMsg{} }) +} + +func (s approvalsSection) pendingStore() approval.PendingStore { + return approval.PendingStore{PendingDir: s.paths.PendingDir, ResponsesDir: s.paths.ResponsesDir} +} + +func (s approvalsSection) loadCmd() tea.Cmd { + store := s.pendingStore() + return func() tea.Msg { + reqs, err := store.List() + return approvalsLoadedMsg{pending: reqs, err: err} + } +} + +// pendingCount is read by the shell to badge the tab from any screen. +func (s approvalsSection) pendingCount() int { return len(s.pending) } + +func (s approvalsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.w, s.h = msg.Width, msg.Height + return s, nil + case approvalsTickMsg: + // Keep polling regardless of the active tab so the queue and the tab badge + // stay live; re-arm the tick. + return s, tea.Batch(s.loadCmd(), approvalsTickCmd()) + case approvalsLoadedMsg: + s.pending = msg.pending + s.err = msg.err + s.clampCursor() + return s, nil + case tea.KeyMsg: + return s.updateKey(msg) + } + return s, nil +} + +func (s approvalsSection) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "j", "down": + s.moveCursor(1) + case "k", "up": + s.moveCursor(-1) + case "g", "home": + s.cursor = 0 + s.clearStatus() + case "G", "end": + s.cursor = maxInt(len(s.pending)-1, 0) + s.clearStatus() + case "o": + return s.decide(approval.VerdictApproved, approval.ScopeOnce) + case "s": + return s.decide(approval.VerdictApproved, approval.ScopeSession) + case "h": + return s.decide(approval.VerdictApproved, approval.ScopeHost) + case "d": + return s.decide(approval.VerdictDenied, "") + case "r": + s.clearStatus() + return s, s.loadCmd() + } + return s, nil +} + +func (s *approvalsSection) moveCursor(delta int) { + s.clearStatus() + n := len(s.pending) + if n == 0 { + s.cursor = 0 + return + } + s.cursor += delta + if s.cursor < 0 { + s.cursor = 0 + } + if s.cursor >= n { + s.cursor = n - 1 + } +} + +func (s *approvalsSection) clampCursor() { + if s.cursor >= len(s.pending) { + s.cursor = maxInt(len(s.pending)-1, 0) + } + if s.cursor < 0 { + s.cursor = 0 + } +} + +func (s *approvalsSection) clearStatus() { + s.err = nil + s.result = "" +} + +func (s approvalsSection) selected() (approval.PendingRequest, bool) { + if s.cursor < 0 || s.cursor >= len(s.pending) { + return approval.PendingRequest{}, false + } + return s.pending[s.cursor], true +} + +// decide applies an operator verdict to the focused request, mirroring the CLI +// `approval grant/deny` wiring exactly (it reloads inventory+policy from disk so +// a host grant never clobbers a concurrent edit). The TUI is the operator +// console by definition, so it needs no extra master/TTY gate. +func (s approvalsSection) decide(verdict approval.Verdict, scope approval.Scope) (tea.Model, tea.Cmd) { + req, ok := s.selected() + if !ok { + return s, nil + } + if verdict == approval.VerdictApproved && scope == approval.ScopeHost && !req.Candidate.Promotable { + s.err = nil + s.result = "host scope unavailable — privileged command; use once or session" + return s, nil + } + inv, _ := inventory.Load(s.paths.InventoryFile) + pol, _ := policy.Load(s.paths.PolicyFile) + _, err := approval.ApplyDecision(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.ID, verdict, scope) + if err != nil { + s.err = err + s.result = "" + return s, s.loadCmd() + } + // Optimistically drop the decided request; the reload confirms. + s.pending = append(append([]approval.PendingRequest(nil), s.pending[:s.cursor]...), s.pending[s.cursor+1:]...) + s.clampCursor() + s.err = nil + s.result = "" + return s, tea.Batch(s.loadCmd(), toastCmd(decisionToast(req, verdict, scope))) +} + +func decisionToast(req approval.PendingRequest, verdict approval.Verdict, scope approval.Scope) string { + short := shortApprovalID(req.ID) + if verdict == approval.VerdictDenied { + return fmt.Sprintf("denied %s", short) + } + return fmt.Sprintf("approved %s · %s", short, scope) +} + +func (s approvalsSection) helpKeyMap() help.KeyMap { + if !s.runtime.Enabled || len(s.pending) == 0 { + return helpMap{short: []key.Binding{hk("r", "refresh")}} + } + return helpMap{ + short: []key.Binding{hk("o", "once"), hk("s", "session"), hk("h", "host"), hk("d", "deny")}, + full: [][]key.Binding{ + {hk("j/k", "move"), hk("g/G", "home/end"), hk("r", "refresh")}, + {hk("o", "once"), hk("s", "session"), hk("h", "host"), hk("d", "deny")}, + }, + } +} + +func (s approvalsSection) View() string { + inner := s.w + if inner < 8 { + inner = 8 + } + if !s.runtime.Enabled { + return s.styles.dim.Render(truncate("Approvals are off. Enable with approval.enabled: true in policy.yaml or AGENTSSH_APPROVAL=1.", inner)) + + "\n" + s.styles.dim.Render(truncate("Until enabled, gray-zone commands are denied (exit 6) — unchanged.", inner)) + } + if len(s.pending) == 0 { + body := s.styles.dim.Render("No pending approvals. Waiting for requests…") + if s.err != nil { + body += "\n" + truncate(s.styles.err.Render(s.err.Error()), inner) + } + return body + } + + var b strings.Builder + visible := s.visibleRows() + start, end := scrollWindow(s.cursor, len(s.pending), visible) + for i := start; i < end; i++ { + b.WriteString(s.renderRow(s.pending[i], i == s.cursor, inner)) + b.WriteString("\n") + } + if end < len(s.pending) || start > 0 { + b.WriteString(s.styles.dim.Render(fmt.Sprintf("%d-%d of %d", start+1, end, len(s.pending)))) + b.WriteString("\n") + } + b.WriteString(s.styles.dim.Render(strings.Repeat("─", inner))) + b.WriteString("\n") + if req, ok := s.selected(); ok { + b.WriteString(truncate(s.consequenceLine(req), inner)) + } + if s.err != nil { + b.WriteString("\n" + truncate(s.styles.err.Render(s.err.Error()), inner)) + } else if s.result != "" { + b.WriteString("\n" + truncate(s.styles.confirm.Render(s.result), inner)) + } + return s.styles.background.MaxWidth(s.w).Render(b.String()) +} + +// renderRow draws one pending request on a single line: cursor · id · host · +// command · a short kind marker (priv / exact). The focused row is brighter. +func (s approvalsSection) renderRow(req approval.PendingRequest, selected bool, inner int) string { + marker := " " + if selected { + marker = s.styles.cursor.Render("▌ ") + } + id := s.styles.dim.Render(shortApprovalID(req.ID)) + host := padCell(req.Host, 12) + kind := s.rowMarker(req) + + // Reserve columns for id (12), host (12), marker prefix (2), kind (~6) and gaps. + cmdWidth := inner - 2 - 12 - 1 - 12 - 1 - 7 + if cmdWidth < 8 { + cmdWidth = 8 + } + cmd := truncate(req.Cmd, cmdWidth) + if selected { + cmd = s.styles.header.Render(cmd) + } + line := marker + id + " " + s.styles.dim.Render(host) + " " + cmd + if kind != "" { + line += " " + kind + } + return truncate(line, inner) +} + +func (s approvalsSection) rowMarker(req approval.PendingRequest) string { + switch { + case !req.Candidate.Promotable: + return s.styles.confirm.Render("priv") + case req.Candidate.Kind == approval.MatcherPrefix: + return "" + default: + return s.styles.dim.Render("exact") + } +} + +// consequenceLine is the "consequence-first" payload: for the focused request it +// states plainly what an [h] host-allow would free, or why host is unavailable. +func (s approvalsSection) consequenceLine(req approval.PendingRequest) string { + id := s.styles.cursor.Render(shortApprovalID(req.ID)) + sep := s.styles.dim.Render(" · ") + c := req.Candidate + switch { + case !c.Promotable: + return id + sep + s.styles.deny.Render("[h]") + s.styles.dim.Render(" unavailable — privileged command; use once or session") + case c.Kind == approval.MatcherPrefix: + glob := strings.Join(c.Prefix, " ") + " *" + return id + sep + s.styles.ok.Render("[h]") + s.styles.dim.Render(" host-allow frees ") + + s.styles.header.Render(glob) + s.styles.dim.Render(" — won't ask again") + default: + return id + sep + s.styles.ok.Render("[h]") + s.styles.dim.Render(" host-allow → this exact command only (won't generalize)") + } +} + +func (s approvalsSection) visibleRows() int { + // Body height minus the separator, the consequence line, and a possible + // status/overflow line. + v := s.h - 3 + if v < 1 { + v = 1 + } + return v +} + +func shortApprovalID(id string) string { + if len(id) <= 11 { + return id + } + return id[:11] + "…" +} + +func padCell(value string, width int) string { + if len(value) >= width { + return truncate(value, width) + } + return value + strings.Repeat(" ", width-len(value)) +} diff --git a/internal/tui/approvals_section_test.go b/internal/tui/approvals_section_test.go new file mode 100644 index 0000000..174fe34 --- /dev/null +++ b/internal/tui/approvals_section_test.go @@ -0,0 +1,111 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/Praeviso/AgentSSH/internal/approval" +) + +func mkApprovalReq(t *testing.T, id, host, session, cmd string) approval.PendingRequest { + t.Helper() + matcher, err := approval.Generalize(cmd, approval.HostGrantSafePrefix) + if err != nil { + t.Fatalf("generalize %q: %v", cmd, err) + } + return approval.PendingRequest{ + ID: id, + Host: host, + SessionID: session, + Cmd: cmd, + Candidate: matcher, + Promotable: matcher.Promotable, + } +} + +func sampleApprovalQueue(t *testing.T) []approval.PendingRequest { + t.Helper() + return []approval.PendingRequest{ + mkApprovalReq(t, "ap_7f3a1b2c3d4e5f6071829300", "web-1", "s_1a2b3c4d", "systemctl status nginx"), + mkApprovalReq(t, "ap_5c12aabbccddeeff00112233", "web-1", "s_1a2b3c4d", "sudo systemctl restart nginx"), + mkApprovalReq(t, "ap_9d04ffeeddccbbaa99887766", "db-1", "s_99ff0011", "cat /etc/passwd | grep root"), + } +} + +// loadedApprovalsApp returns a sized app on the Approvals tab with the sample +// queue injected (approval enabled via env). +func loadedApprovalsApp(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") // switch to Approvals + next, _ := m.Update(approvalsLoadedMsg{pending: sampleApprovalQueue(t)}) + return next.(appModel) +} + +func TestApprovalsTabRendersConsequenceFirst(t *testing.T) { + m := loadedApprovalsApp(t) + view := m.View() + t.Logf("\n%s", view) + + // Active tab + ambient pending badge in the status bar. + if !strings.Contains(view, "3 Approvals") { + t.Errorf("Approvals tab not shown:\n%s", view) + } + if !strings.Contains(view, "3 pending") { + t.Errorf("pending badge missing:\n%s", view) + } + // One line per request: commands visible. + for _, want := range []string{"systemctl status nginx", "sudo systemctl restart nginx", "cat /etc/passwd"} { + if !strings.Contains(view, want) { + t.Errorf("request %q missing from list:\n%s", want, view) + } + } + // Focused row (first, a safe prefix) drives the consequence footer. + if !strings.Contains(view, "host-allow frees systemctl status *") { + t.Errorf("prefix consequence missing for focused row:\n%s", view) + } +} + +func TestApprovalsConsequenceFollowsCursor(t *testing.T) { + m := loadedApprovalsApp(t) + + // Move to the sudo request: host scope must read as unavailable (privileged). + m = press(t, m, "j") + view := m.View() + if !strings.Contains(view, "unavailable") || !strings.Contains(view, "privileged") { + t.Errorf("sudo row should show host unavailable:\n%s", view) + } + + // Move to the piped request: host-allow stays exact (no generalize). + m = press(t, m, "j") + view = m.View() + if !strings.Contains(view, "this exact command only") { + t.Errorf("piped row should show exact-only host consequence:\n%s", view) + } +} + +func TestApprovalsHostOnPrivilegedRefusesWithoutWrite(t *testing.T) { + m := loadedApprovalsApp(t) + m = press(t, m, "j") // focus the sudo (non-promotable) request + m = press(t, m, "h") // attempt host scope + view := m.View() + if !strings.Contains(view, "host scope unavailable") { + t.Errorf("host on privileged request should be refused in-place:\n%s", view) + } + // The queue is unchanged (nothing was written/resolved). + if got := m.approvals.pendingCount(); got != 3 { + t.Errorf("refused decision must not drop the request: pending=%d", got) + } +} + +func TestApprovalsDisabledShowsOffNote(t *testing.T) { + m := buildAppWith(t, "version: 1\nhosts: {}\n", "version: 1\n") + m = sized(t, m, 92, 20) + m = press(t, m, "3") + view := m.View() + if !strings.Contains(view, "Approvals are off") { + t.Errorf("disabled approvals should show the off note:\n%s", view) + } +} From e13b677b50e9018ac0983c704274f98e1e762cd7 Mon Sep 17 00:00:00 2001 From: Kritoooo Date: Tue, 30 Jun 2026 23:16:45 -0700 Subject: [PATCH 3/5] fix(approval): resolve 15 code-review findings in gray-zone approval system Address correctness and safety issues found in the async gray-zone approval review (git diff main...HEAD). Security-critical: - F1-F3: harden safe-prefix host generalization. Remove journalctl safe-prefix case; force exact for git --output/-o and kubectl identity options (--as, --as-group, --token, --kubeconfig, --server) and other side-effecting flags; pin git/kubectl first positional so grants do not widen across resources/revisions. - F4: make the O_EXCL response resolve the first mutating step in ApplyDecision; only the winner applies grants and audit side effects. Correctness: - F5: abort TUI approval decisions on inventory/policy load errors instead of saving an empty policy. - F6: gate approval TUI verdict keys, queue polling, and pending badge on runtime.Enabled. - F7: session end removes derived base@host approval files. - F8: when approval is disabled, evaluate the full policy including persisted __agentssh_approval host rules (do not strip them). - F9: include Matcher.Promotable in the matcher SHA-256 digest. - F10: corrupt/empty/ID-mismatch response files return ErrCorruptResolution instead of wedging status/adjudication. - F11: drop single-host session-file binding; grants carry Host. - F12: dedupe pending on SessionID+Host+CmdSHA256; reap resolved pending/response files past TTL. - F13: invalid optional approval runtime config warns (English) and degrades to disabled instead of exiting. - F14: policy test is a static policy-engine check, no session reads. - F15: approval-pending human output is English. Mechanical: - audit record for not_run preflight-blocked targets - add cmd to runResponse and populate it (incl. approval-pending) - add approval.ChannelExit constant for exit-channel audit records All F1-F15 plus mechanical items covered by tests; go build ./... and go test ./... pass. See docs/plans/approval-review-fixes.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/agentssh/main.go | 54 ++++-- cmd/agentssh/main_test.go | 208 +++++++++++++++++++++++- docs/plans/approval-review-fixes.md | 121 ++++++++++++++ internal/approval/adjudicate.go | 35 ++-- internal/approval/adjudicate_test.go | 105 ++++++++++++ internal/approval/authorize.go | 21 ++- internal/approval/authorize_test.go | 29 +++- internal/approval/generalize.go | 56 ++++++- internal/approval/generalize_test.go | 56 +++++++ internal/approval/matcher.go | 8 +- internal/approval/request.go | 83 +++++++++- internal/approval/request_test.go | 130 ++++++++++++++- internal/approval/session_store.go | 48 ++++-- internal/approval/session_store_test.go | 51 ++++++ internal/audit/audit_test.go | 65 ++++---- internal/tui/approvals_section.go | 51 +++++- internal/tui/approvals_section_test.go | 74 +++++++++ internal/tui/harness_test.go | 4 + skills/agentssh-usage/SKILL.md | 2 +- 19 files changed, 1105 insertions(+), 96 deletions(-) create mode 100644 docs/plans/approval-review-fixes.md create mode 100644 internal/approval/adjudicate_test.go diff --git a/cmd/agentssh/main.go b/cmd/agentssh/main.go index 20c681c..b138e6f 100644 --- a/cmd/agentssh/main.go +++ b/cmd/agentssh/main.go @@ -887,6 +887,7 @@ type runResponse struct { ReqID string `json:"req_id"` SessionID string `json:"session_id"` Host string `json:"host"` + Cmd string `json:"cmd,omitempty"` Status string `json:"status"` ExitCode int `json:"exit_code"` DurationMS int64 `json:"duration_ms"` @@ -1652,7 +1653,7 @@ func runInventoryTest(cmd *cobra.Command, name string) error { resolved, err := inventory.NewResolver(cfg.Inventory).Resolve(name) if err != nil { if inventory.IsUnknown(err) { - return newUsageError("%v\n 查看全部: agentssh hosts", err) + return newUsageError("%v\n list all hosts: agentssh hosts", err) } return newUsageError("%v\n choose a concrete host, not an empty group", err) } @@ -1736,15 +1737,12 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag if err != nil { return classifyConfigError(err) } - runtime, err := approvalRuntime(cfg) - if err != nil { - return newUsageError("%v", err) - } + runtime := approvalRuntimeWithWarning(cmd, cfg) resolved, err := inventory.NewResolver(cfg.Inventory).Resolve(targetName) if err != nil { if inventory.IsUnknown(err) { - return newUsageError("%v\n 查看全部: agentssh hosts", err) + return newUsageError("%v\n list all hosts: agentssh hosts", err) } // e.g. a group whose tags match no hosts. return newUsageError("%v\n list hosts and tags with: agentssh hosts", err) @@ -1867,6 +1865,7 @@ func runDirect(cmd *cobra.Command, targetName string, remoteCommand string, flag ReqID: reqID, SessionID: sessionCtx.ID, Host: target.Name, + Cmd: remoteCommand, Status: status, ExitCode: result.ExitCode, DurationMS: result.Duration.Milliseconds(), @@ -1910,6 +1909,20 @@ func approvalRuntime(cfg *config.Config) (approval.RuntimeConfig, error) { return approval.RuntimeConfigFromPolicy(cfg.Policy.Approval, os.Getenv(config.EnvApproval)) } +func approvalRuntimeWithWarning(cmd *cobra.Command, cfg *config.Config) approval.RuntimeConfig { + runtime, err := approvalRuntime(cfg) + if err == nil { + return runtime + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "WARNING: invalid approval configuration: %v; treating approval as disabled for this run.\n", err) + return approval.RuntimeConfig{ + Enabled: false, + HostGrantMode: approval.HostGrantSafePrefix, + SessionTTL: approval.DefaultSessionTTL, + WaitTimeout: approval.DefaultWaitTimeout, + } +} + func buildRunPlans(cfg *config.Config, resolved inventory.ResolvedTarget, remoteCommand string, flags runFlags, runtime approval.RuntimeConfig, sessionStore approval.SessionStore, preflight bool) ([]runPlan, error) { sessionResolver := session.Resolver{} plans := make([]runPlan, 0, len(resolved.Targets)) @@ -1971,10 +1984,15 @@ func handleApprovalPreflightBlock(cmd *cobra.Command, pending approval.PendingSt responses = append(responses, response) 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 { + return err + } response := runResponse{ ReqID: plan.ReqID, SessionID: plan.SessionCtx.ID, Host: plan.Target.Name, + Cmd: remoteCommand, Status: "not_run", ExitCode: exitApprovalRequired, PolicyAction: string(plan.Auth.Decision.Action), @@ -2010,6 +2028,7 @@ func appendDeniedRun(cmd *cobra.Command, store audit.Store, plan runPlan, remote ReqID: plan.ReqID, SessionID: plan.SessionCtx.ID, Host: plan.Target.Name, + Cmd: remoteCommand, Status: "denied", ExitCode: exitCode, PolicyAction: string(plan.Auth.Decision.Action), @@ -2036,7 +2055,7 @@ func appendApprovalPending(cmd *cobra.Command, pending approval.PendingStore, st record := baseAuditRecord(plan.ReqID, plan.SessionCtx, audit.EventApprovalRequested, plan.Target.Name, remoteCommand, plan.Auth.Decision, &exit, "", 0) record.ApprovalID = req.ID record.ApprovalMatcher = req.Candidate.Regex - record.ApprovalChannel = "exit" + record.ApprovalChannel = approval.ChannelExit if _, err := store.Append(record); err != nil { return runResponse{}, err } @@ -2044,6 +2063,7 @@ func appendApprovalPending(cmd *cobra.Command, pending approval.PendingStore, st ReqID: plan.ReqID, SessionID: plan.SessionCtx.ID, Host: plan.Target.Name, + Cmd: remoteCommand, Status: "approval_pending", ExitCode: exitApprovalRequired, PolicyAction: string(plan.Auth.Decision.Action), @@ -2165,14 +2185,14 @@ func printSSHErrorHint(cmd *cobra.Command, result executor.Result) { } func printDenyHuman(cmd *cobra.Command, host string, command string, decision policy.Decision) { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "✗ denied by policy · %s · 命中规则 %q\n", host, decision.Rule) - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s 命中了 deny 规则或没有命中 allow 规则;此拦截无法临场放行。\n", command) - _, _ = fmt.Fprintln(cmd.ErrOrStderr(), " 如确需放宽,请人类修改 ~/.agentssh/policy.yaml。") + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "✗ denied by policy · %s · matched rule %q\n", host, decision.Rule) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " %s matched a deny rule or no allow rule; this block cannot be approved inline.\n", command) + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), " If the policy should change, ask an operator to edit ~/.agentssh/policy.yaml.") } func printApprovalPendingHuman(cmd *cobra.Command, host string, req approval.PendingRequest) { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "! approval required · %s · %s\n", host, req.ID) - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " 已提交审批;等待操作者裁决后重跑原命令。候选 matcher: %s\n", req.Candidate.Regex) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), " submitted for approval — wait for the operator's decision, then re-run the original command. candidate matcher: %s\n", req.Candidate.Regex) } func printNotRunHuman(cmd *cobra.Command, host string) { @@ -2827,12 +2847,17 @@ func runPolicyTest(cmd *cobra.Command, host string, command string) error { if err != nil { return newUsageError("%v", err) } - sessionID := os.Getenv(session.EnvSession) - auth, err := approval.PreflightAuthorize(cfg.Policy, cfg.Inventory, approval.SessionStore{Dir: cfg.Paths.SessionsDir}, runtime, sessionID, host, command) + engine, err := policy.NewEngine(cfg.Policy, cfg.Inventory) 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) } - decision := auth.Decision + decision, err := engine.Evaluate(host, command) + if err != nil { + return err + } + if decision.Rule == policy.RuleDefaultDeny && runtime.Enabled { + decision = policy.Decision{Action: policy.Action("needs-approval"), Rule: policy.RuleDefaultDeny} + } if host != "" { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s · rule=%s · host=%s\n", decision.Action, decision.Rule, host) return nil @@ -2978,6 +3003,7 @@ func runStatus(cmd *cobra.Command, reqID string, jsonOutput bool) error { ReqID: latest.ReqID, SessionID: latest.SessionID, Host: latest.Host, + Cmd: latest.Cmd, Status: status, ExitCode: exitCode, DurationMS: latest.DurationMS, diff --git a/cmd/agentssh/main_test.go b/cmd/agentssh/main_test.go index a779d60..2ef7a9f 100644 --- a/cmd/agentssh/main_test.go +++ b/cmd/agentssh/main_test.go @@ -20,6 +20,7 @@ import ( "testing" "time" + "github.com/Praeviso/AgentSSH/internal/approval" "github.com/Praeviso/AgentSSH/internal/audit" "github.com/Praeviso/AgentSSH/internal/config" "github.com/Praeviso/AgentSSH/internal/discovery" @@ -727,6 +728,55 @@ output: } } +func TestPolicyTestIsStaticAndIgnoresSessionGrant(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + matcher, err := approval.Exact("id") + 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 { + t.Fatal(err) + } + + stdout, stderr, err := runCommandForTest(t, "policy", "test", "--host", "web-1", "id") + if err != nil { + t.Fatalf("policy test err=%v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "needs-approval") || strings.Contains(stdout, "approval/session") { + t.Fatalf("policy test should be static gray, got %q", stdout) + } + + cfg := readPolicyFile(t, home) + cfg.HostOverrides = map[string]policy.HostOverride{ + policy.HostRulesKey("web-1"): {Rules: []policy.Rule{{ + Name: "approval/host", + Match: policy.Match{CmdRegex: `\Aid\z`}, + Action: policy.ActionAllow, + Group: policy.ApprovalGroup, + }}}, + } + if err := policy.Save(filepath.Join(home, "policy.yaml"), cfg); err != nil { + t.Fatal(err) + } + stdout, stderr, err = runCommandForTest(t, "policy", "test", "--host", "web-1", "id") + if err != nil { + t.Fatalf("policy test persisted host grant err=%v stdout=%s stderr=%s", err, stdout, stderr) + } + if !strings.Contains(stdout, "allow") || !strings.Contains(stdout, "host:web-1/rules[0]") { + t.Fatalf("policy test should include persisted host grants, got %q", stdout) + } +} + func TestApprovalSessionGrantE2E(t *testing.T) { home := t.TempDir() writeTestInventory(t, home) @@ -759,6 +809,9 @@ output: if pending.Status != "approval_pending" || pending.ApprovalID == "" || !strings.Contains(pending.ApprovalMatcher, `\Asystemctl`) { t.Fatalf("pending response = %#v", pending) } + if pending.Cmd != "systemctl restart nginx" { + t.Fatalf("pending cmd = %q", pending.Cmd) + } code, statusOut, _ := runExit(t, "approval", "status", pending.ApprovalID) if code != exitApprovalRequired || !strings.Contains(statusOut, `"status": "pending"`) { @@ -836,6 +889,55 @@ output: } } +func TestApprovalPendingHumanOutputIsEnglish(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + withFakeExecutor(t, fakeExecutor{}) + + code, _, stderr := runExit(t, "run", "web-1", "--", "id") + if code != exitApprovalRequired { + t.Fatalf("exit=%d want 7 stderr=%s", code, stderr) + } + for _, want := range []string{"submitted for approval", "operator's decision", "candidate matcher"} { + if !strings.Contains(stderr, want) { + t.Fatalf("approval stderr missing %q:\n%s", want, stderr) + } + } + for _, r := range stderr { + if r >= 0x4e00 && r <= 0x9fff { + t.Fatalf("approval stderr contains CJK rune %q:\n%s", r, stderr) + } + } +} + +func TestDeniedHumanOutputIsEnglish(t *testing.T) { + setupHome(t) + withFakeExecutor(t, fakeExecutor{}) + code, _, stderr := runExit(t, "run", "web-1", "--", "rm", "-rf", "/") + if code != exitPolicyDenied { + t.Fatalf("exit=%d want 6 stderr=%s", code, stderr) + } + for _, want := range []string{"matched rule", "cannot be approved inline", "ask an operator"} { + if !strings.Contains(stderr, want) { + t.Fatalf("deny stderr missing %q:\n%s", want, stderr) + } + } + for _, r := range stderr { + if r >= 0x4e00 && r <= 0x9fff { + t.Fatalf("deny stderr contains CJK rune %q:\n%s", r, stderr) + } + } +} + func TestApprovalGroupPreflightDoesNotHalfExecute(t *testing.T) { home := t.TempDir() writeTestInventory(t, home) @@ -877,8 +979,18 @@ output: t.Fatalf("statusByHost = %#v responses=%#v", statusByHost, responses) } records := mustReadAudit(t, home) - if len(records) != 1 || records[0].Event != audit.EventApprovalRequested || records[0].Host != "web-2" { - t.Fatalf("audit records = %#v", records) + if len(records) != 2 { + t.Fatalf("audit records = %#v, want not_run + approval_requested", records) + } + byHost := map[string]audit.Record{} + for _, record := range records { + byHost[record.Host] = record + } + if byHost["web-1"].Event != audit.EventDenied || byHost["web-1"].ExitCode == nil || *byHost["web-1"].ExitCode != exitApprovalRequired { + t.Fatalf("web-1 not_run audit = %#v", byHost["web-1"]) + } + if byHost["web-2"].Event != audit.EventApprovalRequested { + t.Fatalf("web-2 approval audit = %#v", byHost["web-2"]) } } @@ -914,6 +1026,98 @@ output: } } +func TestApprovalPendingDedupesDeniedCommand(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true +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", "--", "id") + if code != exitApprovalRequired { + t.Fatalf("first run exit=%d stdout=%s", code, stdout) + } + var first runResponse + if err := json.Unmarshal([]byte(stdout), &first); err != nil { + t.Fatal(err) + } + code, stdout, _ = runExit(t, "run", "web-1", "--json", "--", "id") + if code != exitApprovalRequired { + t.Fatalf("second run exit=%d stdout=%s", code, stdout) + } + var second runResponse + if err := json.Unmarshal([]byte(stdout), &second); err != nil { + t.Fatal(err) + } + if second.ApprovalID != first.ApprovalID { + t.Fatalf("approval id = %s, want reused %s", second.ApprovalID, first.ApprovalID) + } + entries, err := os.ReadDir(filepath.Join(home, "approvals", "pending")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("pending files = %d, want 1", len(entries)) + } +} + +func TestInvalidApprovalConfigDisablesApprovalButAllowsExplicitAllow(t *testing.T) { + home := t.TempDir() + writeTestInventory(t, home) + writePolicy(t, home, ` +version: 1 +approval: + enabled: true + session_ttl: 12hours +rules: + - name: allow-id + match: { cmd_regex: '\Aid\z' } + action: allow +output: + max_bytes: 1024 +`) + t.Setenv("AGENTSSH_HOME", home) + t.Setenv("AGENTSSH_SESSION", "s_test") + var calls int32 + withFakeExecutor(t, fakeExecutor{calls: &calls}) + + code, stdout, stderr := runExit(t, "run", "web-1", "--json", "--", "id") + if code != exitOK { + t.Fatalf("allowed run exit=%d stdout=%s stderr=%s", code, stdout, stderr) + } + if !strings.Contains(stderr, "WARNING: invalid approval configuration") { + t.Fatalf("missing approval warning stderr=%q", stderr) + } + if atomic.LoadInt32(&calls) != 1 { + t.Fatalf("executor calls=%d want 1", calls) + } + var response runResponse + if err := json.Unmarshal([]byte(stdout), &response); err != nil { + t.Fatal(err) + } + if response.Status != "completed" || response.Cmd != "id" { + t.Fatalf("response = %#v", response) + } + + code, stdout, stderr = runExit(t, "run", "web-1", "--json", "--", "whoami") + if code != exitPolicyDenied { + t.Fatalf("gray run exit=%d want 6 stdout=%s stderr=%s", code, stdout, stderr) + } + if !strings.Contains(stderr, "WARNING: invalid approval configuration") { + t.Fatalf("missing second approval warning stderr=%q", stderr) + } + if entries, err := os.ReadDir(filepath.Join(home, "approvals", "pending")); err == nil && len(entries) > 0 { + t.Fatalf("pending approvals created while invalid config disabled approval: %#v", entries) + } +} + func TestPolicyGroupCommands(t *testing.T) { home := t.TempDir() writeInventory(t, home, ` diff --git a/docs/plans/approval-review-fixes.md b/docs/plans/approval-review-fixes.md new file mode 100644 index 0000000..9872d9a --- /dev/null +++ b/docs/plans/approval-review-fixes.md @@ -0,0 +1,121 @@ +# Approval Review Fixes + +Date: 2026-07-01 + +Requested branch: `fix/approval-review-findings` + +Actual git state in this environment: branch creation and commits could not be +created because `.git` is read-only under the managed sandbox. The attempted +commands failed with: + +- `git checkout -b fix/approval-review-findings`: `unable to create directory for .git/refs/heads/fix/approval-review-findings` +- `git commit ...`: `Unable to create .../.git/index.lock: Read-only file system` + +All source fixes below are present in the working tree. + +## Findings Addressed + +- F1: removed `journalctl` safe-prefix host generalization so grants for a + specific journalctl invocation stay exact. +- F2: hardened safe-prefix tails so git `--output` and git `-o` variants force + exact matching. +- F3: hardened kubectl safe-prefix tails so identity-bearing options such as + `--as`, `--as-group`, `--token`, `--kubeconfig`, and `--server` force exact + matching. +- F4: reordered `ApplyDecision` so the response file `O_EXCL` resolve is the + first mutating claim; only the winner applies grants and audit side effects. +- F5: made TUI approval decisions abort on inventory or policy load errors + instead of saving an empty policy. +- F6: disabled approval TUI verdict keys, queue polling, and pending badges when + approval runtime is disabled. +- F7: made `session end ` remove derived `base@host` approval session + files. +- F8: when approval is disabled, authorization now evaluates the full policy, + including persisted `__agentssh_approval` host rules. +- F9: included `Matcher.Promotable` in matcher SHA-256 integrity checks. +- F10: corrupt approval response files now return `ErrCorruptResolution` instead + of wedging status or adjudication forever. +- F11: removed single-host session-file binding so one session file can safely + contain host-filtered grants for multiple hosts. +- F12: pending creation reuses an unresolved request for the same + `SessionID+Host+CmdSHA256`, and resolved pending/response files older than + the TTL are reaped on create/list. +- F13: invalid optional approval runtime config now emits an English warning and + treats approval as disabled for `run`, preserving explicitly allowed commands + while default-denying gray commands. +- F14: `policy test` is now a static policy-engine check; it does not consult + session grants and still includes persisted approval host rules. +- F15: approval-pending human output is now English. + +## Mechanical Items + +- Added an audit record for `not_run` preflight-blocked targets. +- Added `cmd` to `runResponse`, populated it in run JSON responses including + approval-pending responses, and updated the agent usage skill wording for + static `policy test`. +- Added `approval.ChannelExit` and used it for exit-channel approval audit + records; audit tests assert the same exported constant. + +## Judgment Calls + +- F8 decision: persisted `__agentssh_approval` host rules remain effective when + approval is disabled. The authorization path intentionally builds the engine + from the full policy in disabled mode and does not strip generated approval + rules. +- F1-F3 decision: implemented kubectl/git first-positional pinning where clean. + For example, `kubectl get pods` generalizes to a prefix that includes `pods`, + preventing it from freeing `kubectl get secret`. The same pattern applies to + `git diff HEAD`. + +## Validation + +Latest full validation after code changes: + +```bash +GOCACHE=/tmp/agentssh-gocache GOMODCACHE=/tmp/agentssh-gomodcache go build ./... +GOCACHE=/tmp/agentssh-gocache GOMODCACHE=/tmp/agentssh-gomodcache go test ./... +``` + +Result: both commands passed. + +## Commit Plan + +No actual commits were created because the sandbox exposed `.git` as read-only. +If committed outside this sandbox, use the following sequence, with every commit +message ending in the required co-author line: + +1. `fix approval safe-prefix escalation hardening` +2. `fix approval decision atomicity` +3. `fix tui approval disabled and load-error guards` +4. `fix approval session host and derived-end cleanup` +5. `fix approval disabled host-rule authorization` +6. `fix approval integrity and corrupt response handling` +7. `fix approval pending dedupe and resolved reaping` +8. `fix approval runtime degrade and static policy test` +9. `fix approval UI text and mechanical audit/json/channel items` +10. `document approval review fix decisions` + +Required commit trailer: + +```text +Co-Authored-By: Claude Opus 4.8 (1M context) +``` + +Final commit body decision record: + +```text +Decision record: +- When approval is disabled, persisted __agentssh_approval host rules remain + effective by evaluating the full policy instead of stripping generated rules. +- Safe-prefix hardening includes git/kubectl first-positional pinning where + clean, so kubectl get pods and git diff HEAD no longer widen across their + first resource/revision positional. + +Co-Authored-By: Claude Opus 4.8 (1M context) +``` + +## Unfixed Findings + +None. All F1-F15 and the three mechanical items are addressed in the working +tree. The only incomplete requested workflow item is git branch/commit creation, +which was blocked by the read-only `.git` filesystem in this environment. diff --git a/internal/approval/adjudicate.go b/internal/approval/adjudicate.go index 384957b..c9a7865 100644 --- a/internal/approval/adjudicate.go +++ b/internal/approval/adjudicate.go @@ -40,15 +40,36 @@ func ApplyDecision(opts ApplyOptions, id string, verdict Verdict, scope Scope) ( } else if exists { return ApplyResult{}, ErrAlreadyResolved } - if existing, err := opts.Pending.Status(id); err == nil && existing.Status != "pending" { - return ApplyResult{}, ErrAlreadyResolved - } result := ApplyResult{Request: req} switch verdict { case VerdictApproved: if scope != ScopeOnce && scope != ScopeSession && scope != ScopeHost { return ApplyResult{}, fmt.Errorf("approval grant scope is required") } + if scope == ScopeHost { + if !req.Candidate.Promotable { + return ApplyResult{}, fmt.Errorf("approval %s cannot be promoted to host scope", req.ID) + } + if err := validateMatcherInvariant(req.Candidate.Regex); err != nil { + return ApplyResult{}, err + } + } else if _, err := Exact(req.Cmd); err != nil { + return ApplyResult{}, err + } + case VerdictDenied: + scope = "" + default: + return ApplyResult{}, fmt.Errorf("invalid approval verdict %q", verdict) + } + + resolution, err := opts.Pending.Resolve(req, verdict, scope) + if err != nil { + return ApplyResult{}, err + } + result.Resolution = resolution + + switch verdict { + case VerdictApproved: if scope == ScopeHost { ruleName, err := applyHostGrant(opts, req) if err != nil { @@ -63,19 +84,11 @@ func ApplyDecision(opts ApplyOptions, id string, verdict Verdict, scope Scope) ( result.Grant = &grant } case VerdictDenied: - scope = "" - default: - return ApplyResult{}, fmt.Errorf("invalid approval verdict %q", verdict) } if err := appendApprovalAudit(opts, req, verdict, scope); err != nil { return ApplyResult{}, err } - resolution, err := opts.Pending.Resolve(req, verdict, scope) - if err != nil { - return ApplyResult{}, err - } - result.Resolution = resolution return result, nil } diff --git a/internal/approval/adjudicate_test.go b/internal/approval/adjudicate_test.go new file mode 100644 index 0000000..42ec9ca --- /dev/null +++ b/internal/approval/adjudicate_test.go @@ -0,0 +1,105 @@ +package approval + +import ( + "errors" + "path/filepath" + "sync" + "testing" + + "github.com/Praeviso/AgentSSH/internal/inventory" + "github.com/Praeviso/AgentSSH/internal/policy" +) + +func TestApplyDecisionAtomicClaimBeforeHostSideEffects(t *testing.T) { + root := t.TempDir() + pending := PendingStore{PendingDir: filepath.Join(root, "pending"), ResponsesDir: filepath.Join(root, "responses")} + matcher, err := Generalize("ls /var", HostGrantSafePrefix) + if err != nil { + t.Fatal(err) + } + req, err := pending.Create(PendingRequest{ + ReqID: "r1", + SessionID: "s1", + Host: "web-1", + Cmd: "ls /var", + Candidate: matcher, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + var mu sync.Mutex + var saved []policy.Config + opts := ApplyOptions{ + Pending: pending, + Bundle: policy.Bundle{ + Inventory: inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}}, + }, + SavePolicy: func(next policy.Config) error { + mu.Lock() + defer mu.Unlock() + saved = append(saved, next) + return nil + }, + } + + type outcome struct { + result ApplyResult + err error + } + results := make(chan outcome, 2) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + result, err := ApplyDecision(opts, req.ID, VerdictApproved, ScopeHost) + results <- outcome{result: result, err: err} + }() + go func() { + defer wg.Done() + result, err := ApplyDecision(opts, req.ID, VerdictDenied, "") + results <- outcome{result: result, err: err} + }() + wg.Wait() + close(results) + + var wins []ApplyResult + var alreadyResolved int + for out := range results { + if out.err == nil { + wins = append(wins, out.result) + continue + } + if errors.Is(out.err, ErrAlreadyResolved) { + alreadyResolved++ + continue + } + t.Fatalf("unexpected ApplyDecision error: %v", out.err) + } + if len(wins) != 1 || alreadyResolved != 1 { + t.Fatalf("wins=%d alreadyResolved=%d", len(wins), alreadyResolved) + } + status, err := pending.Status(req.ID) + if err != nil { + t.Fatalf("Status: %v", err) + } + + mu.Lock() + defer mu.Unlock() + switch status.Verdict { + case VerdictApproved: + if len(saved) != 1 { + t.Fatalf("approved resolution saved policies = %d, want 1", len(saved)) + } + rules := saved[0].HostOverrides[policy.HostRulesKey("web-1")].Rules + if len(rules) != 1 || rules[0].Group != policy.ApprovalGroup || rules[0].Action != policy.ActionAllow { + t.Fatalf("saved host rules = %#v", rules) + } + case VerdictDenied: + if len(saved) != 0 { + t.Fatalf("denied resolution saved policies = %d, want 0", len(saved)) + } + default: + t.Fatalf("status = %#v", status) + } +} diff --git a/internal/approval/authorize.go b/internal/approval/authorize.go index 7d31029..a7ee0f3 100644 --- a/internal/approval/authorize.go +++ b/internal/approval/authorize.go @@ -35,6 +35,24 @@ func PreflightAuthorize(cfg policy.Config, inv inventory.Inventory, sessionStore } func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionStore, runtime RuntimeConfig, sessionID string, host string, command string, consumeOnce bool) (Authorization, error) { + if !runtime.Enabled { + engine, err := policy.NewEngine(cfg, inv) + if err != nil { + return Authorization{}, err + } + decision, err := engine.Evaluate(host, command) + if err != nil { + return Authorization{}, err + } + if decision.Action == policy.ActionAllow { + return Authorization{Status: AuthAllow, Decision: decision}, nil + } + if decision.Rule != policy.RuleDefaultDeny { + return Authorization{Status: AuthHardDeny, Decision: decision}, nil + } + return Authorization{Status: AuthNeedsApproval, Decision: decision}, nil + } + clean, hostMatchers := splitPolicy(cfg, host) engine, err := policy.NewEngine(clean, inv) if err != nil { @@ -50,9 +68,6 @@ func authorize(cfg policy.Config, inv inventory.Inventory, sessionStore SessionS if decision.Rule != policy.RuleDefaultDeny { return Authorization{Status: AuthHardDeny, Decision: decision}, nil } - if !runtime.Enabled { - return Authorization{Status: AuthNeedsApproval, Decision: decision}, nil - } var grant Grant var ok bool if consumeOnce { diff --git a/internal/approval/authorize_test.go b/internal/approval/authorize_test.go index 0e98ddd..4c72db6 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{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") if err != nil { t.Fatalf("Authorize: %v", err) } @@ -86,3 +86,30 @@ func TestAuthorizeNewDenyInvalidatesExistingGrant(t *testing.T) { t.Fatalf("auth = %#v, want hard deny", auth) } } + +func TestAuthorizeDisabledApprovalUsesPersistedHostRules(t *testing.T) { + inv := inventory.Inventory{Hosts: map[string]inventory.Host{"web-1": {}}} + cfg := policy.Config{HostOverrides: map[string]policy.HostOverride{ + policy.HostRulesKey("web-1"): {Rules: []policy.Rule{{ + Name: "approval/host", + Match: policy.Match{CmdRegex: `\Als(?:[ \t]+[A-Za-z0-9@%+=:,./_-]+)*[ \t]*\z`}, + Action: policy.ActionAllow, + Group: policy.ApprovalGroup, + }}}, + }} + auth, err := Authorize(cfg, inv, SessionStore{Dir: t.TempDir()}, RuntimeConfig{Enabled: false, HostGrantMode: HostGrantSafePrefix}, "s", "web-1", "ls /var") + if err != nil { + t.Fatalf("Authorize persisted host rule while disabled: %v", err) + } + if auth.Status != AuthAllow || auth.Decision.Action != policy.ActionAllow { + 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") + if err != nil { + t.Fatalf("Authorize gray while disabled: %v", err) + } + if auth.Status != AuthNeedsApproval || auth.Decision.Rule != policy.RuleDefaultDeny { + t.Fatalf("gray auth = %#v, want default-deny needs approval sentinel", auth) + } +} diff --git a/internal/approval/generalize.go b/internal/approval/generalize.go index e8bec7d..a88d4c7 100644 --- a/internal/approval/generalize.go +++ b/internal/approval/generalize.go @@ -79,7 +79,8 @@ func Generalize(command string, mode HostGrantMode) (Matcher, error) { } prefix := prefixForMode(tokens, head, mode) - if len(prefix) == 0 || !tailTokensSafe(tokens[len(prefix):]) { + tail := tokens[len(prefix):] + if len(prefix) == 0 || !tailTokensSafe(tail) || (mode == HostGrantSafePrefix && !safePrefixTailTokensSafe(head, tail)) { return exactMatcher(command, promotable), nil } return prefixMatcher(command, prefix, promotable), nil @@ -209,12 +210,16 @@ func prefixForMode(tokens []string, head string, mode HostGrantMode) []string { } if subcommands, ok := safeReadonlySubcommands[head]; ok && len(tokens) >= 2 { if _, ok := subcommands[strings.ToLower(tokens[1])]; ok { - return []string{tokens[0], tokens[1]} + prefix := []string{tokens[0], tokens[1]} + // Multi-tool read-only subcommands still have semantic positionals + // (for example, kubectl resource names). Pin a leading positional + // when present so a host grant does not silently widen across them. + if (head == "git" || head == "kubectl") && len(tokens) >= 3 && !strings.HasPrefix(tokens[2], "-") { + prefix = append(prefix, tokens[2]) + } + return prefix } } - if head == "journalctl" { - return []string{tokens[0]} - } return nil case HostGrantPrefix: if _, ok := multiCommand[head]; ok && len(tokens) >= 2 { @@ -240,6 +245,47 @@ func tailTokensSafe(tokens []string) bool { return true } +func safePrefixTailTokensSafe(head string, tokens []string) bool { + for _, token := range tokens { + if strings.HasPrefix(token, "-") && strings.Contains(token, "=") { + return false + } + if dangerousSafePrefixOption(head, token) { + return false + } + } + return true +} + +func dangerousSafePrefixOption(head string, token string) bool { + for _, opt := range dangerousSafePrefixOptions(head) { + if token == opt || strings.HasPrefix(token, opt+"=") { + return true + } + } + return false +} + +func dangerousSafePrefixOptions(head string) []string { + opts := []string{ + "--output", + "--as", + "--as-group", + "--token", + "--kubeconfig", + "--server", + "--vacuum-time", + "--vacuum-size", + "--vacuum-files", + "--rotate", + "--flush", + } + if head == "git" { + opts = append(opts, "-o") + } + return opts +} + func isTailByte(b byte) bool { return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || diff --git a/internal/approval/generalize_test.go b/internal/approval/generalize_test.go index c3adb7e..5530ec0 100644 --- a/internal/approval/generalize_test.go +++ b/internal/approval/generalize_test.go @@ -17,6 +17,9 @@ func TestGeneralizeSafePrefixTable(t *testing.T) { {"systemctl status nginx", HostGrantSafePrefix, MatcherPrefix, []string{"systemctl", "status"}, true}, {"systemctl restart nginx", HostGrantSafePrefix, MatcherExact, nil, true}, {"ls -la /var", HostGrantSafePrefix, MatcherPrefix, []string{"ls"}, true}, + {"git diff HEAD", HostGrantSafePrefix, MatcherPrefix, []string{"git", "diff", "HEAD"}, true}, + {"kubectl get pods", HostGrantSafePrefix, MatcherPrefix, []string{"kubectl", "get", "pods"}, true}, + {"journalctl -u nginx", HostGrantSafePrefix, MatcherExact, nil, true}, {"cat /etc/passwd", HostGrantSafePrefix, MatcherExact, nil, true}, {"sudo systemctl restart nginx", HostGrantSafePrefix, MatcherExact, nil, false}, {"rm -rf /var/tmp/cache", HostGrantSafePrefix, MatcherExact, nil, true}, @@ -42,6 +45,59 @@ func TestGeneralizeSafePrefixTable(t *testing.T) { } } +func TestGeneralizeSafePrefixConfirmedEscalationsDoNotMatch(t *testing.T) { + tests := []struct { + name string + benign string + exploit string + }{ + { + name: "journalctl destructive flag", + benign: "journalctl -u nginx", + exploit: "journalctl --vacuum-time=1s", + }, + { + name: "git output overwrite", + benign: "git diff HEAD", + exploit: "git diff --output=/etc/cron.d/x HEAD", + }, + { + name: "kubectl impersonated secret read", + benign: "kubectl get pods", + exploit: "kubectl get secret --as=system:masters -o yaml", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + benign, err := Generalize(tt.benign, HostGrantSafePrefix) + if err != nil { + t.Fatalf("Generalize benign: %v", err) + } + assertMatcherInvariant(t, benign.Regex) + matched, err := benign.Match(tt.exploit) + if err != nil { + t.Fatalf("Match exploit with benign matcher: %v", err) + } + if matched { + t.Fatalf("benign matcher %s matched exploit %q", benign.Regex, tt.exploit) + } + + exploit, err := Generalize(tt.exploit, HostGrantSafePrefix) + if err != nil { + t.Fatalf("Generalize exploit: %v", err) + } + if exploit.Kind != MatcherExact { + t.Fatalf("exploit matcher kind = %s, want exact: %#v", exploit.Kind, exploit) + } + assertMatcherInvariant(t, exploit.Regex) + matched, err = exploit.Match(tt.exploit) + if err != nil || !matched { + t.Fatalf("exploit exact matcher should match source: matched=%v err=%v regex=%s", matched, err, exploit.Regex) + } + }) + } +} + func TestGeneralizeInjectionCorpusForcesExactOrRejects(t *testing.T) { tests := []string{ "ls\nrm -rf /", diff --git a/internal/approval/matcher.go b/internal/approval/matcher.go index 1523d74..88bb7e6 100644 --- a/internal/approval/matcher.go +++ b/internal/approval/matcher.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "regexp" + "strconv" "strings" ) @@ -31,8 +32,9 @@ const ( ) const ( - ChannelCLI = "cli" - ChannelTUI = "tui" + ChannelCLI = "cli" + ChannelTUI = "tui" + ChannelExit = "exit" ) // Matcher is the reusable command matcher that can be stored in session grants @@ -57,7 +59,7 @@ func (m Matcher) Match(command string) (bool, error) { } func (m Matcher) SHA256() string { - sum := sha256.Sum256([]byte(string(m.Kind) + "\x00" + m.Regex + "\x00" + strings.Join(m.Prefix, "\x00") + "\x00" + m.SourceCmd)) + sum := sha256.Sum256([]byte(string(m.Kind) + "\x00" + m.Regex + "\x00" + strings.Join(m.Prefix, "\x00") + "\x00" + m.SourceCmd + "\x00" + strconv.FormatBool(m.Promotable))) return hex.EncodeToString(sum[:]) } diff --git a/internal/approval/request.go b/internal/approval/request.go index 887fd19..d351cd6 100644 --- a/internal/approval/request.go +++ b/internal/approval/request.go @@ -20,6 +20,8 @@ type PendingStore struct { Now func() time.Time } +const resolvedReapTTL = 24 * time.Hour + type PendingRequest struct { Version int `json:"version"` ID string `json:"id"` @@ -54,9 +56,10 @@ type StatusResult struct { } var ( - ErrInvalidID = errors.New("invalid approval id") - ErrPendingNotFound = errors.New("approval request not found") - ErrAlreadyResolved = errors.New("approval request already resolved") + ErrInvalidID = errors.New("invalid approval id") + ErrPendingNotFound = errors.New("approval request not found") + ErrAlreadyResolved = errors.New("approval request already resolved") + ErrCorruptResolution = errors.New("corrupt approval resolution") ) func NewID() (string, error) { @@ -68,6 +71,12 @@ func NewID() (string, error) { } 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 { + return PendingRequest{}, err + } else if ok { + return existing, nil + } if req.ID == "" { id, err := NewID() if err != nil { @@ -137,6 +146,7 @@ func (s PendingStore) Get(id string) (PendingRequest, error) { } func (s PendingStore) List() ([]PendingRequest, error) { + _ = s.reapResolved(resolvedReapTTL) entries, err := os.ReadDir(s.PendingDir) if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -258,14 +268,77 @@ func (s PendingStore) readResolution(id string) (Resolution, bool, error) { } var resolution Resolution if err := json.Unmarshal(data, &resolution); err != nil { - return Resolution{}, true, nil + return Resolution{}, false, fmt.Errorf("%w for %s", ErrCorruptResolution, id) } if resolution.ID != id { - return Resolution{}, true, nil + return Resolution{}, false, fmt.Errorf("%w for %s", ErrCorruptResolution, id) } return resolution, true, nil } +func (s PendingStore) findUnresolved(sessionID string, host string, cmdSHA256 string) (PendingRequest, bool, error) { + entries, err := os.ReadDir(s.PendingDir) + if errors.Is(err, os.ErrNotExist) { + return PendingRequest{}, false, nil + } + if err != nil { + return PendingRequest{}, false, fmt.Errorf("list pending approvals: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + req, err := s.Get(id) + if err != nil { + continue + } + if req.SessionID != sessionID || req.Host != host || req.CmdSHA256 != cmdSHA256 { + continue + } + if _, ok, err := s.readResolution(id); err != nil { + continue + } else if !ok { + return req, true, nil + } + } + return PendingRequest{}, false, nil +} + +func (s PendingStore) reapResolved(ttl time.Duration) error { + if ttl <= 0 { + return nil + } + entries, err := os.ReadDir(s.PendingDir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("list pending approvals: %w", err) + } + cutoff := s.now().Add(-ttl) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".json") + if _, ok, err := s.readResolution(id); err != nil || !ok { + continue + } + pendingPath := pendingPath(s.PendingDir, id) + info, err := os.Stat(pendingPath) + if err != nil { + continue + } + if info.ModTime().After(cutoff) { + continue + } + _ = os.Remove(pendingPath) + _ = os.Remove(responsePath(s.ResponsesDir, id)) + } + return nil +} + func RequestDigest(req PendingRequest, scope Scope) string { parts := []string{req.ID, req.ReqID, req.SessionID, req.Host, req.CmdSHA256, req.MatcherSHA256, string(scope)} sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) diff --git a/internal/approval/request_test.go b/internal/approval/request_test.go index ec001e0..7b788c9 100644 --- a/internal/approval/request_test.go +++ b/internal/approval/request_test.go @@ -2,8 +2,10 @@ package approval import ( "encoding/json" + "errors" "os" "path/filepath" + "strings" "testing" "time" ) @@ -28,7 +30,10 @@ func TestPendingResponseDigestAndOExcl(t *testing.T) { if len(req.ID) < len("ap_")+24 { t.Fatalf("approval id too short: %q", req.ID) } - if _, err := store.Create(req); err == nil { + collision := req + collision.Cmd = "whoami" + collision.Candidate, _ = Exact("whoami") + if _, err := store.Create(collision); err == nil { t.Fatal("duplicate O_EXCL create succeeded") } resolution, err := store.Resolve(req, VerdictApproved, ScopeSession) @@ -94,3 +99,126 @@ func TestWaitExitSemanticsInputs(t *testing.T) { t.Fatalf("wait status = %#v", status) } } + +func TestPendingMatcherDigestCoversPromotable(t *testing.T) { + root := t.TempDir() + store := PendingStore{PendingDir: filepath.Join(root, "pending"), ResponsesDir: filepath.Join(root, "responses")} + matcher, err := Exact("sudo id") + if err != nil { + t.Fatal(err) + } + matcher.Promotable = false + req, err := store.Create(PendingRequest{ReqID: "r1", SessionID: "s", Host: "web-1", Cmd: "sudo id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + + path := pendingPath(store.PendingDir, req.ID) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var tampered PendingRequest + if err := json.Unmarshal(data, &tampered); err != nil { + t.Fatal(err) + } + tampered.Candidate.Promotable = true + tampered.Promotable = true + data, err = json.MarshalIndent(tampered, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatal(err) + } + if _, err := store.Get(req.ID); err == nil || !strings.Contains(err.Error(), "digest mismatch") { + t.Fatalf("Get tampered promotable err = %v, want digest mismatch", err) + } +} + +func TestCorruptResolutionSurfacesError(t *testing.T) { + root := t.TempDir() + store := PendingStore{PendingDir: filepath.Join(root, "pending"), ResponsesDir: filepath.Join(root, "responses")} + matcher, err := Exact("id") + if err != nil { + t.Fatal(err) + } + req, err := store.Create(PendingRequest{ReqID: "r1", SessionID: "s", Host: "web-1", Cmd: "id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(store.ResponsesDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(responsePath(store.ResponsesDir, req.ID), nil, 0o600); err != nil { + t.Fatal(err) + } + if _, err := store.Status(req.ID); !errors.Is(err, ErrCorruptResolution) { + t.Fatalf("Status err = %v, want ErrCorruptResolution", err) + } + if _, err := ApplyDecision(ApplyOptions{Pending: store}, req.ID, VerdictDenied, ""); !errors.Is(err, ErrCorruptResolution) { + t.Fatalf("ApplyDecision err = %v, want ErrCorruptResolution", err) + } +} + +func TestPendingCreateDedupesUnresolvedRequest(t *testing.T) { + root := t.TempDir() + store := PendingStore{PendingDir: filepath.Join(root, "pending"), ResponsesDir: filepath.Join(root, "responses")} + matcher, err := Exact("id") + if err != nil { + t.Fatal(err) + } + req, err := store.Create(PendingRequest{ReqID: "r1", SessionID: "s", Host: "web-1", Cmd: "id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + again, err := store.Create(PendingRequest{ReqID: "r2", SessionID: "s", Host: "web-1", Cmd: "id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + if again.ID != req.ID || again.ReqID != req.ReqID { + t.Fatalf("dedupe returned %#v, want original %#v", again, req) + } + otherHost, err := store.Create(PendingRequest{ReqID: "r3", SessionID: "s", Host: "web-2", Cmd: "id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + if otherHost.ID == req.ID { + t.Fatalf("dedupe crossed host boundary: %#v", otherHost) + } +} + +func TestPendingStoreReapsOldResolvedFiles(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + store := PendingStore{ + PendingDir: filepath.Join(root, "pending"), + ResponsesDir: filepath.Join(root, "responses"), + Now: func() time.Time { return now }, + } + matcher, err := Exact("id") + if err != nil { + t.Fatal(err) + } + req, err := store.Create(PendingRequest{ReqID: "r1", SessionID: "s", Host: "web-1", Cmd: "id", Candidate: matcher}) + if err != nil { + t.Fatal(err) + } + if _, err := store.Resolve(req, VerdictDenied, ""); err != nil { + t.Fatal(err) + } + old := now.Add(-48 * time.Hour) + for _, path := range []string{pendingPath(store.PendingDir, req.ID), responsePath(store.ResponsesDir, req.ID)} { + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + } + if _, err := store.List(); err != nil { + t.Fatal(err) + } + for _, path := range []string{pendingPath(store.PendingDir, req.ID), responsePath(store.ResponsesDir, req.ID)} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("%s still exists after reap: %v", path, err) + } + } +} diff --git a/internal/approval/session_store.go b/internal/approval/session_store.go index d975bb2..f4ff00a 100644 --- a/internal/approval/session_store.go +++ b/internal/approval/session_store.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "syscall" "time" @@ -36,13 +37,11 @@ type Grant struct { type sessionFile struct { Version int `json:"version"` SessionID string `json:"session_id"` - Host string `json:"host"` + Host string `json:"host,omitempty"` Updated string `json:"updated"` Grants []Grant `json:"grants"` } -var ErrSessionHostMismatch = errors.New("approval session is bound to a different host") - func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher Matcher, 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) @@ -74,12 +73,6 @@ func (s SessionStore) Grant(sessionID string, host string, scope Scope, matcher if doc.SessionID != sessionID { return fmt.Errorf("session store file mismatch: %q != %q", doc.SessionID, sessionID) } - if doc.Host == "" { - doc.Host = host - } - if doc.Host != host { - return ErrSessionHostMismatch - } doc.Grants = filterLiveGrants(doc.Grants, now) out := doc.Grants[:0] for _, existing := range doc.Grants { @@ -117,9 +110,6 @@ func (s SessionStore) match(sessionID string, host string, command string, consu if doc.SessionID != sessionID { return fmt.Errorf("session store file mismatch: %q != %q", doc.SessionID, sessionID) } - if doc.Host != "" && doc.Host != host { - return nil - } live := filterLiveGrants(doc.Grants, now) changed := len(live) != len(doc.Grants) remaining := make([]Grant, 0, len(live)) @@ -165,7 +155,39 @@ func (s SessionStore) End(sessionID string) error { if err := os.MkdirAll(s.Dir, 0o700); err != nil { return fmt.Errorf("create approval session directory: %w", err) } - path := sessionPath(s.Dir, sessionID) + if err := removeSessionFile(sessionPath(s.Dir, sessionID)); err != nil { + return err + } + entries, err := os.ReadDir(s.Dir) + if err != nil { + return fmt.Errorf("list approval sessions: %w", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + path := filepath.Join(s.Dir, entry.Name()) + lock, err := lockSessionPath(path) + if err != nil { + return err + } + doc, err := readSessionFile(path) + if err != nil { + unlockAndClose(lock) + return err + } + if doc.SessionID == sessionID || strings.HasPrefix(doc.SessionID, sessionID+"@") { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + unlockAndClose(lock) + return fmt.Errorf("remove approval session: %w", err) + } + } + unlockAndClose(lock) + } + return nil +} + +func removeSessionFile(path string) error { lock, err := lockSessionPath(path) if err != nil { return err diff --git a/internal/approval/session_store_test.go b/internal/approval/session_store_test.go index c8edfc1..0e7347e 100644 --- a/internal/approval/session_store_test.go +++ b/internal/approval/session_store_test.go @@ -67,3 +67,54 @@ func TestSessionStoreOnceConcurrentConsumesOnce(t *testing.T) { t.Fatalf("once grant matched %d times, want 1", got) } } + +func TestSessionStoreAllowsSameSessionAcrossHosts(t *testing.T) { + store := SessionStore{Dir: t.TempDir()} + webMatcher, err := Exact("systemctl restart web") + if err != nil { + t.Fatal(err) + } + dbMatcher, err := Exact("systemctl restart db") + if err != nil { + t.Fatal(err) + } + 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 { + 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" { + 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" { + 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 { + t.Fatalf("cross-host command match ok=%v err=%v", ok, err) + } +} + +func TestSessionStoreEndRemovesDerivedSessions(t *testing.T) { + store := SessionStore{Dir: t.TempDir()} + matcher, err := Exact("id") + if err != nil { + 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 { + t.Fatalf("Grant %s: %v", sessionID, err) + } + } + if err := store.End("s_batch"); err != nil { + 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 { + 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 { + t.Fatalf("unrelated derived session match ok=%v err=%v", ok, err) + } +} diff --git a/internal/audit/audit_test.go b/internal/audit/audit_test.go index 778d57d..4c8fec3 100644 --- a/internal/audit/audit_test.go +++ b/internal/audit/audit_test.go @@ -1,4 +1,4 @@ -package audit +package audit_test import ( "encoding/json" @@ -6,19 +6,22 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Praeviso/AgentSSH/internal/approval" + "github.com/Praeviso/AgentSSH/internal/audit" ) func TestAppendVerifyAndSeqContinuation(t *testing.T) { - store := NewStore(filepath.Join(t.TempDir(), "audit.log")) - first, err := store.Append(Record{ReqID: "r1", Event: EventStarted, Host: "web-1"}) + store := audit.NewStore(filepath.Join(t.TempDir(), "audit.log")) + first, err := store.Append(audit.Record{ReqID: "r1", Event: audit.EventStarted, Host: "web-1"}) if err != nil { t.Fatalf("append first: %v", err) } - second, err := store.Append(Record{ReqID: "r1", Event: EventCompleted, Host: "web-1"}) + second, err := store.Append(audit.Record{ReqID: "r1", Event: audit.EventCompleted, Host: "web-1"}) if err != nil { t.Fatalf("append second: %v", err) } - if first.Seq != 0 || first.PrevHash != ZeroHash { + if first.Seq != 0 || first.PrevHash != audit.ZeroHash { t.Fatalf("first = %#v", first) } if second.Seq != 1 || second.PrevHash != first.Hash { @@ -32,8 +35,8 @@ func TestAppendVerifyAndSeqContinuation(t *testing.T) { t.Fatalf("verify result = %#v", result) } - reopened := NewStore(store.Path) - third, err := reopened.Append(Record{ReqID: "r2", Event: EventDenied, Host: "web-2"}) + reopened := audit.NewStore(store.Path) + third, err := reopened.Append(audit.Record{ReqID: "r2", Event: audit.EventDenied, Host: "web-2"}) if err != nil { t.Fatalf("append third: %v", err) } @@ -73,9 +76,9 @@ func TestVerifyDetectsChangedDeletedInsertedRecords(t *testing.T) { t.Run("inserted", func(t *testing.T) { store := testStoreWithRecords(t) records := mustRead(t, store) - inserted := Record{Seq: 99, ReqID: "inserted", Event: EventStarted, PrevHash: records[0].Hash} - inserted.Hash = ComputeHash(inserted) - records = append(records[:1], append([]Record{inserted}, records[1:]...)...) + inserted := audit.Record{Seq: 99, ReqID: "inserted", Event: audit.EventStarted, PrevHash: records[0].Hash} + inserted.Hash = audit.ComputeHash(inserted) + records = append(records[:1], append([]audit.Record{inserted}, records[1:]...)...) writeRecords(t, store.Path, records) result, err := store.Verify() if err != nil { @@ -88,25 +91,25 @@ func TestVerifyDetectsChangedDeletedInsertedRecords(t *testing.T) { } func TestApprovalFieldsAreHashProtectedAndOldRecordsVerify(t *testing.T) { - store := NewStore(filepath.Join(t.TempDir(), "audit.log")) - old, err := store.Append(Record{ReqID: "old", Event: EventStarted, Host: "web-1"}) + 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) } - approval, err := store.Append(Record{ + approvalRecord, err := store.Append(audit.Record{ ReqID: "r1", - Event: EventApprovalRequested, + Event: audit.EventApprovalRequested, Host: "web-1", ApprovalID: "ap_0123456789abcdef01234567", ApprovalScope: "session", ApprovalMatcher: `\Aid\z`, - ApprovalChannel: "exit", + ApprovalChannel: approval.ChannelExit, }) if err != nil { t.Fatalf("append approval: %v", err) } records := mustRead(t, store) - records[0].Hash = ComputeHash(records[0]) + records[0].Hash = audit.ComputeHash(records[0]) if records[0].Hash != old.Hash { t.Fatalf("old hash changed after approval fields were added: %s != %s", records[0].Hash, old.Hash) } @@ -120,23 +123,23 @@ func TestApprovalFieldsAreHashProtectedAndOldRecordsVerify(t *testing.T) { fields := []struct { name string - edit func(*Record) + edit func(*audit.Record) }{ - {"approval_id", func(r *Record) { r.ApprovalID = "ap_ffffffffffffffffffffffff" }}, - {"approval_scope", func(r *Record) { r.ApprovalScope = "host" }}, - {"approval_matcher", func(r *Record) { r.ApprovalMatcher = `\Als\z` }}, - {"approval_channel", func(r *Record) { r.ApprovalChannel = "cli" }}, + {"approval_id", func(r *audit.Record) { r.ApprovalID = "ap_ffffffffffffffffffffffff" }}, + {"approval_scope", func(r *audit.Record) { r.ApprovalScope = "host" }}, + {"approval_matcher", func(r *audit.Record) { r.ApprovalMatcher = `\Als\z` }}, + {"approval_channel", func(r *audit.Record) { r.ApprovalChannel = "cli" }}, } for _, tt := range fields { t.Run(tt.name, func(t *testing.T) { - tampered := append([]Record(nil), records...) + 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 != approval.Seq || result.Reason != "hash" { + if result.OK || result.BrokenSeq != approvalRecord.Seq || result.Reason != "hash" { t.Fatalf("tamper result = %#v", result) } writeRecords(t, store.Path, records) @@ -207,13 +210,13 @@ func TestTruncateBrokenNoopsWhenChainOK(t *testing.T) { } } -func testStoreWithRecords(t *testing.T) Store { +func testStoreWithRecords(t *testing.T) audit.Store { t.Helper() - store := NewStore(filepath.Join(t.TempDir(), "audit.log")) - for _, record := range []Record{ - {ReqID: "r1", Event: EventStarted, Host: "web-1"}, - {ReqID: "r1", Event: EventCompleted, Host: "web-1"}, - {ReqID: "r2", Event: EventDenied, Host: "web-2"}, + store := audit.NewStore(filepath.Join(t.TempDir(), "audit.log")) + for _, record := range []audit.Record{ + {ReqID: "r1", Event: audit.EventStarted, Host: "web-1"}, + {ReqID: "r1", Event: audit.EventCompleted, Host: "web-1"}, + {ReqID: "r2", Event: audit.EventDenied, Host: "web-2"}, } { if _, err := store.Append(record); err != nil { t.Fatalf("append: %v", err) @@ -222,7 +225,7 @@ func testStoreWithRecords(t *testing.T) Store { return store } -func mustRead(t *testing.T, store Store) []Record { +func mustRead(t *testing.T, store audit.Store) []audit.Record { t.Helper() records, err := store.ReadAll() if err != nil { @@ -231,7 +234,7 @@ func mustRead(t *testing.T, store Store) []Record { return records } -func writeRecords(t *testing.T, path string, records []Record) { +func writeRecords(t *testing.T, path string, records []audit.Record) { t.Helper() var builder strings.Builder for _, record := range records { diff --git a/internal/tui/approvals_section.go b/internal/tui/approvals_section.go index 4712748..f92c9de 100644 --- a/internal/tui/approvals_section.go +++ b/internal/tui/approvals_section.go @@ -51,10 +51,16 @@ func newApprovalsSection(paths config.Paths, st appStyles, runtime approval.Runt func (s approvalsSection) capturing() bool { return false } func (s approvalsSection) Init() tea.Cmd { + if !s.runtime.Enabled { + return nil + } return tea.Batch(s.loadCmd(), approvalsTickCmd()) } -func approvalsTickCmd() tea.Cmd { +func approvalsTickCmd(enabled ...bool) tea.Cmd { + if len(enabled) > 0 && !enabled[0] { + return nil + } return tea.Tick(approvalsPollInterval, func(time.Time) tea.Msg { return approvalsTickMsg{} }) } @@ -63,6 +69,9 @@ func (s approvalsSection) pendingStore() approval.PendingStore { } func (s approvalsSection) loadCmd() tea.Cmd { + if !s.runtime.Enabled { + return nil + } store := s.pendingStore() return func() tea.Msg { reqs, err := store.List() @@ -71,7 +80,12 @@ func (s approvalsSection) loadCmd() tea.Cmd { } // pendingCount is read by the shell to badge the tab from any screen. -func (s approvalsSection) pendingCount() int { return len(s.pending) } +func (s approvalsSection) pendingCount() int { + if !s.runtime.Enabled { + return 0 + } + return len(s.pending) +} func (s approvalsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -79,9 +93,12 @@ func (s approvalsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.w, s.h = msg.Width, msg.Height return s, nil case approvalsTickMsg: + if !s.runtime.Enabled { + return s, nil + } // Keep polling regardless of the active tab so the queue and the tab badge // stay live; re-arm the tick. - return s, tea.Batch(s.loadCmd(), approvalsTickCmd()) + return s, tea.Batch(s.loadCmd(), approvalsTickCmd(s.runtime.Enabled)) case approvalsLoadedMsg: s.pending = msg.pending s.err = msg.err @@ -106,12 +123,24 @@ func (s approvalsSection) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { s.cursor = maxInt(len(s.pending)-1, 0) s.clearStatus() case "o": + if !s.runtime.Enabled { + return s, nil + } return s.decide(approval.VerdictApproved, approval.ScopeOnce) case "s": + if !s.runtime.Enabled { + return s, nil + } return s.decide(approval.VerdictApproved, approval.ScopeSession) case "h": + if !s.runtime.Enabled { + return s, nil + } return s.decide(approval.VerdictApproved, approval.ScopeHost) case "d": + if !s.runtime.Enabled { + return s, nil + } return s.decide(approval.VerdictDenied, "") case "r": s.clearStatus() @@ -171,9 +200,19 @@ func (s approvalsSection) decide(verdict approval.Verdict, scope approval.Scope) s.result = "host scope unavailable — privileged command; use once or session" return s, nil } - inv, _ := inventory.Load(s.paths.InventoryFile) - pol, _ := policy.Load(s.paths.PolicyFile) - _, err := approval.ApplyDecision(approval.ApplyOptions{ + 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 + } + _, err = approval.ApplyDecision(approval.ApplyOptions{ Pending: s.pendingStore(), Sessions: approval.SessionStore{Dir: s.paths.SessionsDir}, Audit: audit.NewStore(s.paths.AuditFile), diff --git a/internal/tui/approvals_section_test.go b/internal/tui/approvals_section_test.go index 174fe34..fa646a4 100644 --- a/internal/tui/approvals_section_test.go +++ b/internal/tui/approvals_section_test.go @@ -1,10 +1,15 @@ package tui import ( + "os" + "path/filepath" "strings" "testing" "github.com/Praeviso/AgentSSH/internal/approval" + "github.com/Praeviso/AgentSSH/internal/config" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) func mkApprovalReq(t *testing.T, id, host, session, cmd string) approval.PendingRequest { @@ -109,3 +114,72 @@ func TestApprovalsDisabledShowsOffNote(t *testing.T) { t.Errorf("disabled approvals should show the off note:\n%s", view) } } + +func TestApprovalsDisabledDoesNotPollBadgeOrDecide(t *testing.T) { + req := sampleApprovalQueue(t)[:1] + m := buildAppWith(t, "version: 1\nhosts: {}\n", "version: 1\n") + m = sized(t, m, 92, 20) + m.approvals.pending = req + m = press(t, m, "3") + if got := m.approvals.pendingCount(); got != 0 { + t.Fatalf("disabled pendingCount = %d, want 0", got) + } + if strings.Contains(m.renderStatusBar(), "pending") { + t.Fatalf("disabled status bar shows pending badge:\n%s", m.renderStatusBar()) + } + next, cmd := m.approvals.Update(approvalsTickMsg{}) + if cmd != nil { + t.Fatal("disabled tick should not poll or re-arm") + } + section := next.(approvalsSection) + before := section.pendingCount() + next, cmd = section.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}) + if cmd != nil { + t.Fatal("disabled verdict key should be a no-op command") + } + section = next.(approvalsSection) + if got := section.pendingCount(); got != before { + t.Fatalf("disabled verdict key changed pending count from %d to %d", before, got) + } +} + +func TestApprovalsDecisionLoadErrorAborts(t *testing.T) { + dir := t.TempDir() + paths := config.NewPaths(dir) + if err := os.WriteFile(paths.InventoryFile, []byte("version: 1\nhosts:\n web-1: {addr: 10.0.0.11}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.PolicyFile, []byte("version: 1\nhost_overrides: [bad\n"), 0o600); err != nil { + t.Fatal(err) + } + req := mkApprovalReq(t, "ap_7f3a1b2c3d4e5f6071829300", "web-1", "s1", "ls /var") + store := approval.PendingStore{PendingDir: paths.PendingDir, ResponsesDir: paths.ResponsesDir} + if _, err := store.Create(req); err != nil { + t.Fatal(err) + } + + section := approvalsSection{ + paths: paths, + styles: newAppStyles(lipgloss.NewRenderer(os.Stdout)), + runtime: approval.RuntimeConfig{Enabled: true}, + pending: []approval.PendingRequest{req}, + } + next, cmd := section.decide(approval.VerdictApproved, approval.ScopeHost) + if cmd != nil { + t.Fatal("load failure should not trigger reload command") + } + section = next.(approvalsSection) + if section.err == nil { + t.Fatal("decision error = nil, want policy load error") + } + if _, err := os.Stat(filepath.Join(paths.ResponsesDir, req.ID+".json")); !os.IsNotExist(err) { + t.Fatalf("response file exists after load failure: %v", err) + } + raw, err := os.ReadFile(paths.PolicyFile) + if err != nil { + t.Fatal(err) + } + if string(raw) != "version: 1\nhost_overrides: [bad\n" { + t.Fatalf("policy file was clobbered:\n%s", raw) + } +} diff --git a/internal/tui/harness_test.go b/internal/tui/harness_test.go index 2728d4a..4df12b6 100644 --- a/internal/tui/harness_test.go +++ b/internal/tui/harness_test.go @@ -104,6 +104,10 @@ func buildAppWith(t *testing.T, inv, pol string) appModel { PolicyFile: filepath.Join(dir, "policy.yaml"), AuditFile: filepath.Join(dir, "audit.log"), SecretsFile: filepath.Join(dir, "secrets.enc"), + ApprovalsDir: filepath.Join(dir, "approvals"), + SessionsDir: filepath.Join(dir, "approvals", "sessions"), + PendingDir: filepath.Join(dir, "approvals", "pending"), + ResponsesDir: filepath.Join(dir, "approvals", "responses"), } return newAppModel(paths, lipgloss.NewRenderer(os.Stdout)) } diff --git a/skills/agentssh-usage/SKILL.md b/skills/agentssh-usage/SKILL.md index 012163d..0f3868d 100644 --- a/skills/agentssh-usage/SKILL.md +++ b/skills/agentssh-usage/SKILL.md @@ -87,7 +87,7 @@ agentssh status [--json] # look up a past run's resu 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 audit ls [--session ] | show | verify # browse / inspect / verify the hash chain -agentssh policy test --host '' # check how a command would be judged, without running it +agentssh policy test --host '' # static policy check, without running it or consulting session grants agentssh tui # interactive audit + policy viewer ``` From 0aa5b0c42d132de3da1694b7c7893c561fde08fe Mon Sep 17 00:00:00 2001 From: Kritoooo Date: Wed, 1 Jul 2026 02:01:59 -0700 Subject: [PATCH 4/5] feat(tui): Enter-driven verdict menu + responsive Approvals layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approvals tab polish: - Replace the hand-rolled fixed-width row renderer with the shared responsive renderTable: aligned ID/HOST/COMMAND/KIND columns that span the frame (COMMAND takes the slack, HOST is no longer truncated), a header row, and full-row selection highlight — matching the other tabs. - Add an Enter-driven verdict chooser that replaces the o/s/h/d footer hints with a selectable once/session/host/deny menu: ←/→ (or tab) move, enter/space apply, esc cancel. While open the tab captures the keyboard so esc/enter aren't treated as quit/tab-nav. - The menu omits host for non-promotable (privileged) requests, and pins to the targeted request across background polls so a reload can't misapply the verdict. The direct o/s/h/d shortcuts still work. Also seed approval data in the e2e script: enable approval in the demo policy and produce a six-request pending queue via `run` (exit 7 in the gray zone, no SSH), covering every KIND/consequence, and refresh the manual checklist for the current 3-tab layout. Tests: chooser open/cancel/nav, host omitted on privileged, and apply resolving a real pending request. go build ./... and go test ./... pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/tui/approvals_section.go | 255 ++++++++++++++++++------- internal/tui/approvals_section_test.go | 96 ++++++++++ scripts/e2e_tui_audit_run_summary.sh | 106 ++++++++-- 3 files changed, 379 insertions(+), 78 deletions(-) diff --git a/internal/tui/approvals_section.go b/internal/tui/approvals_section.go index f92c9de..4a177e2 100644 --- a/internal/tui/approvals_section.go +++ b/internal/tui/approvals_section.go @@ -26,9 +26,14 @@ type approvalsSection struct { runtime approval.RuntimeConfig pending []approval.PendingRequest cursor int - err error - result string - w, h int + // choosing is the Enter-driven verdict menu: while open the footer swaps to a + // selectable once/session/host/deny chooser and this tab owns the keyboard. + 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 } const approvalsPollInterval = 1500 * time.Millisecond @@ -46,9 +51,10 @@ func newApprovalsSection(paths config.Paths, st appStyles, runtime approval.Runt return approvalsSection{paths: paths, styles: st, runtime: runtime} } -// capturing is always false: this tab has no text input or modal, so the shell -// keeps ownership of tab/esc/q. -func (s approvalsSection) capturing() bool { return false } +// capturing is true only while the Enter-driven verdict chooser is open, so the +// shell hands this tab the keyboard (esc cancels, enter applies) instead of +// treating those keys as tab/quit navigation. +func (s approvalsSection) capturing() bool { return s.choosing } func (s approvalsSection) Init() tea.Cmd { if !s.runtime.Enabled { @@ -103,6 +109,7 @@ func (s approvalsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { s.pending = msg.pending s.err = msg.err s.clampCursor() + s.resyncChooser() return s, nil case tea.KeyMsg: return s.updateKey(msg) @@ -111,6 +118,9 @@ func (s approvalsSection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (s approvalsSection) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if s.choosing { + return s.updateChoosing(msg) + } switch msg.String() { case "j", "down": s.moveCursor(1) @@ -122,26 +132,16 @@ func (s approvalsSection) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "G", "end": s.cursor = maxInt(len(s.pending)-1, 0) s.clearStatus() + case "enter": + return s.openChooser() case "o": - if !s.runtime.Enabled { - return s, nil - } - return s.decide(approval.VerdictApproved, approval.ScopeOnce) + return s.resolveWith(approval.VerdictApproved, approval.ScopeOnce) case "s": - if !s.runtime.Enabled { - return s, nil - } - return s.decide(approval.VerdictApproved, approval.ScopeSession) + return s.resolveWith(approval.VerdictApproved, approval.ScopeSession) case "h": - if !s.runtime.Enabled { - return s, nil - } - return s.decide(approval.VerdictApproved, approval.ScopeHost) + return s.resolveWith(approval.VerdictApproved, approval.ScopeHost) case "d": - if !s.runtime.Enabled { - return s, nil - } - return s.decide(approval.VerdictDenied, "") + return s.resolveWith(approval.VerdictDenied, "") case "r": s.clearStatus() return s, s.loadCmd() @@ -149,6 +149,114 @@ func (s approvalsSection) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return s, nil } +// updateChoosing drives the Enter verdict menu: arrows/tab move the highlight, +// enter/space applies it, esc cancels, and the o/s/h/d shortcuts still apply a +// verdict directly. All other keys are swallowed so the modal keeps focus. +func (s approvalsSection) updateChoosing(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + choices := s.choices() + switch msg.String() { + case "left", "shift+tab": + s.choiceIdx = maxInt(s.choiceIdx-1, 0) + case "right", "tab": + s.choiceIdx = minInt(s.choiceIdx+1, len(choices)-1) + case "enter", " ": + if s.choiceIdx >= 0 && s.choiceIdx < len(choices) { + c := choices[s.choiceIdx] + return s.resolveWith(c.verdict, c.scope) + } + case "esc": + s.choosing = false + s.choiceID = "" + s.clearStatus() + case "o": + return s.resolveWith(approval.VerdictApproved, approval.ScopeOnce) + case "s": + return s.resolveWith(approval.VerdictApproved, approval.ScopeSession) + case "h": + return s.resolveWith(approval.VerdictApproved, approval.ScopeHost) + case "d": + return s.resolveWith(approval.VerdictDenied, "") + } + return s, nil +} + +// openChooser starts the verdict menu on the focused request. It is a no-op when +// approval is disabled or the queue is empty. +func (s approvalsSection) openChooser() (tea.Model, tea.Cmd) { + if !s.runtime.Enabled { + return s, nil + } + req, ok := s.selected() + if !ok { + return s, nil + } + s.clearStatus() + s.choosing = true + s.choiceIdx = 0 + s.choiceID = req.ID + return s, nil +} + +// resolveWith closes the chooser (if open) and applies a verdict to the focused +// request, shared by the direct o/s/h/d shortcuts and the menu. +func (s approvalsSection) resolveWith(verdict approval.Verdict, scope approval.Scope) (tea.Model, tea.Cmd) { + if !s.runtime.Enabled { + return s, nil + } + s.choosing = false + s.choiceID = "" + return s.decide(verdict, scope) +} + +// resyncChooser keeps the open menu pinned to the request it targeted across a +// background poll: it re-points the cursor to that request, or closes the menu +// if the request was resolved elsewhere and is gone. +func (s *approvalsSection) resyncChooser() { + if !s.choosing { + return + } + idx := s.indexOfReq(s.choiceID) + if idx < 0 { + s.choosing = false + s.choiceID = "" + return + } + s.cursor = idx + if choices := s.choices(); s.choiceIdx >= len(choices) { + s.choiceIdx = maxInt(len(choices)-1, 0) + } +} + +func (s approvalsSection) indexOfReq(id string) int { + for i, req := range s.pending { + if req.ID == id { + return i + } + } + return -1 +} + +// approvalChoice is one entry in the verdict menu. +type approvalChoice struct { + label string + verdict approval.Verdict + scope approval.Scope +} + +// choices are the verdict options for the focused request. Host is offered only +// when the request is promotable, so the menu never presents an action that +// would be refused. +func (s approvalsSection) choices() []approvalChoice { + out := []approvalChoice{ + {"once", approval.VerdictApproved, approval.ScopeOnce}, + {"session", approval.VerdictApproved, approval.ScopeSession}, + } + if req, ok := s.selected(); ok && req.Candidate.Promotable { + out = append(out, approvalChoice{"host", approval.VerdictApproved, approval.ScopeHost}) + } + return append(out, approvalChoice{"deny", approval.VerdictDenied, ""}) +} + func (s *approvalsSection) moveCursor(delta int) { s.clearStatus() n := len(s.pending) @@ -249,10 +357,15 @@ func (s approvalsSection) helpKeyMap() help.KeyMap { if !s.runtime.Enabled || len(s.pending) == 0 { return helpMap{short: []key.Binding{hk("r", "refresh")}} } + if s.choosing { + return helpMap{ + short: []key.Binding{hk("←/→", "move"), hk("enter", "apply"), hk("esc", "cancel")}, + } + } return helpMap{ - short: []key.Binding{hk("o", "once"), hk("s", "session"), hk("h", "host"), hk("d", "deny")}, + 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("r", "refresh")}, + {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")}, }, } @@ -278,10 +391,15 @@ func (s approvalsSection) View() string { var b strings.Builder visible := s.visibleRows() start, end := scrollWindow(s.cursor, len(s.pending), visible) + rows := make([][]string, 0, end-start) for i := start; i < end; i++ { - b.WriteString(s.renderRow(s.pending[i], i == s.cursor, inner)) - b.WriteString("\n") + rows = append(rows, approvalRow(s.pending[i])) } + // Reuse the shared responsive table (fill=true) so the columns span the + // frame instead of clumping at fixed widths: COMMAND takes the slack, HOST + // shows in full, KIND is a tidy right column. + b.WriteString(renderTable(s.styles, approvalColumns, rows, s.cursor-start, inner, true)) + b.WriteString("\n") if end < len(s.pending) || start > 0 { b.WriteString(s.styles.dim.Render(fmt.Sprintf("%d-%d of %d", start+1, end, len(s.pending)))) b.WriteString("\n") @@ -291,49 +409,47 @@ func (s approvalsSection) View() string { if req, ok := s.selected(); ok { b.WriteString(truncate(s.consequenceLine(req), inner)) } - if s.err != nil { + switch { + case s.choosing: + b.WriteString("\n" + truncate(s.chooserLine(), inner)) + case s.err != nil: b.WriteString("\n" + truncate(s.styles.err.Render(s.err.Error()), inner)) - } else if s.result != "" { + case s.result != "": b.WriteString("\n" + truncate(s.styles.confirm.Render(s.result), inner)) } return s.styles.background.MaxWidth(s.w).Render(b.String()) } -// renderRow draws one pending request on a single line: cursor · id · host · -// command · a short kind marker (priv / exact). The focused row is brighter. -func (s approvalsSection) renderRow(req approval.PendingRequest, selected bool, inner int) string { - marker := " " - if selected { - marker = s.styles.cursor.Render("▌ ") - } - id := s.styles.dim.Render(shortApprovalID(req.ID)) - host := padCell(req.Host, 12) - kind := s.rowMarker(req) +// approvalColumns is the aligned, responsive layout for the pending queue. KIND +// telegraphs what an [h] host-allow does (prefix generalizes, exact stays +// this-command-only, priv can't promote); COMMAND carries the most weight so it +// absorbs the slack on a wide frame. +var approvalColumns = []tableColumn{ + {header: "ID", min: 12, max: 14}, + {header: "HOST", min: 6, max: 20, weight: 1}, + {header: "COMMAND", min: 12, weight: 4}, + {header: "KIND", min: 4, max: 6, right: true}, +} - // Reserve columns for id (12), host (12), marker prefix (2), kind (~6) and gaps. - cmdWidth := inner - 2 - 12 - 1 - 12 - 1 - 7 - if cmdWidth < 8 { - cmdWidth = 8 +func approvalRow(req approval.PendingRequest) []string { + return []string{ + shortApprovalID(req.ID), + req.Host, + req.Cmd, + kindLabel(req), } - cmd := truncate(req.Cmd, cmdWidth) - if selected { - cmd = s.styles.header.Render(cmd) - } - line := marker + id + " " + s.styles.dim.Render(host) + " " + cmd - if kind != "" { - line += " " + kind - } - return truncate(line, inner) } -func (s approvalsSection) rowMarker(req approval.PendingRequest) string { +// kindLabel classifies a request by what host-allow would free — the same split +// the consequence line spells out for the focused row. +func kindLabel(req approval.PendingRequest) string { switch { case !req.Candidate.Promotable: - return s.styles.confirm.Render("priv") + return "priv" case req.Candidate.Kind == approval.MatcherPrefix: - return "" + return "prefix" default: - return s.styles.dim.Render("exact") + return "exact" } } @@ -355,10 +471,26 @@ func (s approvalsSection) consequenceLine(req approval.PendingRequest) string { } } +// chooserLine renders the Enter-driven verdict menu that replaces the footer +// hints while open: once / session / host / deny with the focused option +// bracketed and highlighted, mirroring the Info pane's auth-mode chooser. +func (s approvalsSection) chooserLine() string { + choices := s.choices() + cells := make([]string, len(choices)) + for i, c := range choices { + if i == s.choiceIdx { + cells[i] = s.styles.cursor.Render("[" + c.label + "]") + } else { + cells[i] = s.styles.dim.Render(" " + c.label + " ") + } + } + return s.styles.dim.Render("decide: ") + strings.Join(cells, " ") +} + func (s approvalsSection) visibleRows() int { - // Body height minus the separator, the consequence line, and a possible - // status/overflow line. - v := s.h - 3 + // Body height minus the table header, the separator, the consequence line, + // and a possible status/overflow line. + v := s.h - 4 if v < 1 { v = 1 } @@ -371,10 +503,3 @@ func shortApprovalID(id string) string { } return id[:11] + "…" } - -func padCell(value string, width int) string { - if len(value) >= width { - return truncate(value, width) - } - return value + strings.Repeat(" ", width-len(value)) -} diff --git a/internal/tui/approvals_section_test.go b/internal/tui/approvals_section_test.go index fa646a4..14d4cd0 100644 --- a/internal/tui/approvals_section_test.go +++ b/internal/tui/approvals_section_test.go @@ -183,3 +183,99 @@ func TestApprovalsDecisionLoadErrorAborts(t *testing.T) { t.Fatalf("policy file was clobbered:\n%s", raw) } } + +func TestApprovalsEnterOpensChooserAndEscCancels(t *testing.T) { + m := loadedApprovalsApp(t) + + m = press(t, m, "enter") + if !m.approvals.choosing { + t.Fatal("enter should open the verdict chooser") + } + view := m.View() + // The footer hints swap to the chooser controls, and the menu renders inline. + for _, want := range []string{"decide:", "[once]", "enter apply", "esc cancel"} { + if !strings.Contains(view, want) { + t.Errorf("chooser view missing %q:\n%s", want, view) + } + } + // While the menu owns the keyboard the shell must not treat keys as tab/quit. + if !m.approvals.capturing() { + t.Error("open chooser should capture the keyboard") + } + + // Navigation clamps at the ends and moves the highlight. + m = press(t, m, "right") + if m.approvals.choiceIdx != 1 { + t.Fatalf("right should move to session (idx 1), got %d", m.approvals.choiceIdx) + } + m = press(t, m, "left") + m = press(t, m, "left") + if m.approvals.choiceIdx != 0 { + t.Fatalf("left should clamp at once (idx 0), got %d", m.approvals.choiceIdx) + } + + m = press(t, m, "esc") + if m.approvals.choosing { + t.Fatal("esc should cancel the chooser") + } +} + +func TestApprovalsChooserOmitsHostOnPrivileged(t *testing.T) { + m := loadedApprovalsApp(t) + m = press(t, m, "j") // focus the sudo (non-promotable) request + m = press(t, m, "enter") // open the chooser + + got := m.approvals.choices() + if len(got) != 3 { + t.Fatalf("privileged request should offer 3 options (once/session/deny), got %d: %+v", len(got), got) + } + for _, c := range got { + if c.scope == approval.ScopeHost { + t.Errorf("privileged request must not offer host scope: %+v", got) + } + } +} + +func TestApprovalsChooserApplyResolvesRequest(t *testing.T) { + dir := t.TempDir() + paths := config.NewPaths(dir) + if err := os.WriteFile(paths.InventoryFile, []byte("version: 1\nhosts:\n web-1: {addr: 10.0.0.11}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paths.PolicyFile, []byte("version: 1\n"), 0o600); err != nil { + t.Fatal(err) + } + req := mkApprovalReq(t, "ap_7f3a1b2c3d4e5f6071829300", "web-1", "s1", "ls /var") + store := approval.PendingStore{PendingDir: paths.PendingDir, ResponsesDir: paths.ResponsesDir} + if _, err := store.Create(req); err != nil { + t.Fatal(err) + } + + section := approvalsSection{ + paths: paths, + styles: newAppStyles(lipgloss.NewRenderer(os.Stdout)), + runtime: approval.RuntimeConfig{Enabled: true}, + pending: []approval.PendingRequest{req}, + } + + // Enter opens the menu (once is pre-selected); Enter again applies it. + next, _ := section.Update(tea.KeyMsg{Type: tea.KeyEnter}) + section = next.(approvalsSection) + if !section.choosing { + t.Fatal("enter did not open the chooser") + } + next, cmd := section.Update(tea.KeyMsg{Type: tea.KeyEnter}) + section = next.(approvalsSection) + if cmd == nil { + t.Fatal("applying a verdict should schedule a reload command") + } + if section.choosing { + t.Error("applying a verdict should close the chooser") + } + if _, err := os.Stat(filepath.Join(paths.ResponsesDir, req.ID+".json")); err != nil { + t.Fatalf("response file not written after apply: %v", err) + } + if section.pendingCount() != 0 { + t.Errorf("applied request should be dropped from the queue, got %d", section.pendingCount()) + } +} diff --git a/scripts/e2e_tui_audit_run_summary.sh b/scripts/e2e_tui_audit_run_summary.sh index 655d44d..a7397d1 100755 --- a/scripts/e2e_tui_audit_run_summary.sh +++ b/scripts/e2e_tui_audit_run_summary.sh @@ -8,18 +8,21 @@ usage() { cat <<'EOF' Usage: scripts/e2e_tui_audit_run_summary.sh [--no-tui] -Creates an isolated AGENTSSH_HOME with a deterministic audit.log, then opens the -real AgentSSH TUI so you can inspect the Audit tab's session-first behavior. +Creates an isolated AGENTSSH_HOME with a deterministic audit.log and a queue of +pending gray-zone approvals, then opens the real AgentSSH TUI so you can inspect +both the Approvals tab and the per-host Audit (Sessions) viewer. Options: - --no-tui Generate data and print audit ls / verify output, but do not open TUI. + --no-tui Generate data and print audit ls / verify plus the seeded pending + approvals, but do not open the TUI. Environment: AGENTSSH_E2E_HOME Use this directory instead of creating /tmp/agentssh-tui-e2e.*. AGENTSSH_BIN Command used to run AgentSSH. Defaults to: go run ./cmd/agentssh -The generated audit data is synthetic. It includes a literal denied command such -as "rm -rf /" in audit.log, but the command is never executed. +The generated data is synthetic. audit.log includes a literal denied command +such as "rm -rf /", and the pending approvals are produced by `run` returning +exit 7 in the gray zone; no SSH connection is made and no command is executed. EOF } @@ -68,6 +71,9 @@ YAML cat > "$home/policy.yaml" <<'YAML' version: 1 +approval: + enabled: true + host_grant_mode: safe-prefix rules: - name: catastrophic priority: 100 @@ -186,6 +192,36 @@ with open(path, "w", encoding="utf-8") as fh: fh.write("\n") PY +# Seed the Approvals tab. With approval.enabled: true in policy.yaml, gray-zone +# (default-deny) commands from `run` return exit 7 and write a pending request +# file under approvals/pending/ — no SSH connection is attempted. The commands +# below are chosen to exercise every row marker and consequence the tab renders: +# - prefix matcher (safe-prefix host-allow would generalize): ls / git / kubectl +# - exact matcher (host-allow stays this-command-only): systemctl / journalctl +# - privileged, non-promotable (host scope unavailable): sudo ... +# Each run also appends an approval-requested record to audit.log, so the chain +# stays intact and the events show up in the host Sessions view too. +seed_pending() { + local sess="$1" host="$2" + shift 2 + AGENTSSH_HOME="$home" AGENTSSH_SESSION="$sess" \ + bash -lc "$agentssh_cmd run $host -- $*" >/dev/null 2>&1 || true +} + +# Start from a clean queue so re-running against a reused AGENTSSH_E2E_HOME keeps +# the seeded set at exactly six (fresh session ids each run would otherwise stack). +rm -rf "$home/approvals" + +s_prod="$(AGENTSSH_HOME="$home" bash -lc "$agentssh_cmd session new")" +s_stg="$(AGENTSSH_HOME="$home" bash -lc "$agentssh_cmd session new")" + +seed_pending "$s_prod" web-prod-1 systemctl restart nginx +seed_pending "$s_prod" web-prod-1 journalctl -u nginx -n 100 +seed_pending "$s_prod" web-prod-1 sudo systemctl restart nginx +seed_pending "$s_stg" web-staging-1 ls -la /var/log/nginx +seed_pending "$s_stg" web-staging-1 git status +seed_pending "$s_stg" web-staging-1 kubectl get pods -n prod + echo "AgentSSH TUI audit-session-first E2E" echo " AGENTSSH_HOME=$home" echo " AgentSSH command: $agentssh_cmd" @@ -197,16 +233,60 @@ echo "Hash-chain verification:" AGENTSSH_HOME="$home" bash -lc "$agentssh_cmd audit verify" echo +echo "Seeded pending approvals (adjudicate these in the Approvals tab):" +# `approval ls` requires an operator TTY, so read the pending files directly here. +python3 - "$home/approvals/pending" <<'PY' +import glob +import json +import os +import sys + +pending_dir = sys.argv[1] +rows = [] +for path in sorted(glob.glob(os.path.join(pending_dir, "*.json"))): + with open(path, encoding="utf-8") as fh: + req = json.load(fh) + cand = req["candidate_matcher"] + if not cand.get("promotable", False): + kind = "priv" # host scope unavailable + elif cand["kind"] == "prefix": + kind = "prefix" # host-allow generalizes to " *" + else: + kind = "exact" # host-allow stays this-command-only + rows.append((req["host"], req["cmd"], kind)) + +if not rows: + print(" (none)") +else: + for host, cmd, kind in rows: + print(f" {host:<14} {kind:<6} {cmd}") +PY +echo + cat < host-allow frees "ls *" + - "systemctl restart nginx" -> host-allow stays this exact command + - "sudo systemctl restart .." -> [h] unavailable (privileged) + 4. Press "s" (session) on one row: it disappears and a toast confirms; a re-run + of that exact command in the same session would now be allowed. + 5. Press "h" (host) on a prefix row (e.g. the git or kubectl one): a generated + approval/... rule is written to policy.yaml. Press "2" (Policy) to see it. + 6. Press "h" on the "sudo ..." row: it is refused (privileged, non-promotable). + 7. Press "d" to deny a row; press "r" to refresh the queue. + +Audit (per-host session viewer): + 8. Press "1" (Hosts), select a host, press Enter to open its detail. + 9. Open the Sessions pane: sessions are shown newest-first, one row per command + (not per raw record). The denied "rm -rf /" shows exit 6 / not executed. + 10. Approval-requested events seeded above appear here too. Press "q" to exit. EOF if [[ "$open_tui" -eq 0 ]]; then From 8bebf7e3baaab030a8d2ed8759bced29163b1361 Mon Sep 17 00:00:00 2001 From: Kritoooo Date: Wed, 1 Jul 2026 02:07:26 -0700 Subject: [PATCH 5/5] docs: bump install version to v0.7.0 First release to include the async gray-zone approval system (run exit 7 + operator adjudication via CLI and the TUI Approvals tab). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8c6d483..5d2c665 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.6.0/agentssh_v0.6.0_linux_amd64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.6.0_linux_amd64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.7.0/agentssh_v0.7.0_linux_amd64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.7.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.6.0/agentssh_v0.6.0_linux_amd64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.6.0_linux_amd64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.7.0/agentssh_v0.7.0_linux_amd64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.7.0_linux_amd64/agentssh # Linux arm64 -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.6.0/agentssh_v0.6.0_linux_arm64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.6.0_linux_arm64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.7.0/agentssh_v0.7.0_linux_arm64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.7.0_linux_arm64/agentssh # macOS Apple Silicon (arm64) -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.6.0/agentssh_v0.6.0_darwin_arm64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.6.0_darwin_arm64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.7.0/agentssh_v0.7.0_darwin_arm64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.7.0_darwin_arm64/agentssh # macOS Intel (amd64) -curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.6.0/agentssh_v0.6.0_darwin_amd64.tar.gz \ - | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.6.0_darwin_amd64/agentssh +curl -fsSL https://github.com/Praeviso/AgentSSH/releases/download/v0.7.0/agentssh_v0.7.0_darwin_amd64.tar.gz \ + | sudo tar xz --strip-components=1 -C /usr/local/bin agentssh_v0.7.0_darwin_amd64/agentssh ``` -Verify: `agentssh --version`. (Bump `v0.6.0` for a different release; checksums are in `SHA256SUMS.txt` on the Releases page.) +Verify: `agentssh --version`. (Bump `v0.7.0` for a different release; checksums are in `SHA256SUMS.txt` on the Releases page.) ### From source (needs Go matching the go.mod directive)