From bced7e0b252204cfc38123cd76bef6f802865045 Mon Sep 17 00:00:00 2001 From: Tombar Date: Wed, 10 Jun 2026 00:56:27 -0300 Subject: [PATCH 1/2] report: categorize parser refusals as shell/unanalyzable with per-kind subverb (#3) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/shellparser/shellparser.go | 114 ++++--- internal/shellparser/shellparser_test.go | 373 ++++++++++++++--------- internal/subcommand/explain.go | 6 +- internal/subcommand/hook.go | 10 +- internal/subcommand/report.go | 18 +- internal/subcommand/report_test.go | 100 ++++++ 6 files changed, 409 insertions(+), 212 deletions(-) diff --git a/internal/shellparser/shellparser.go b/internal/shellparser/shellparser.go index 93eef53..46adf95 100644 --- a/internal/shellparser/shellparser.go +++ b/internal/shellparser/shellparser.go @@ -29,6 +29,16 @@ import ( // anything and there's no policy gate that recovers from that. const DynamicMarker = "" +// Refusal holds a machine-readable kind and a human-readable message +// explaining why the shell walker refused to analyze a command. +type Refusal struct { + // Kind is a machine-readable category, one of: glob, heredoc, + // control-flow, dynamic-head, subshell, background, input-redirect, + // fd-redirect, env-prefix, assignment, binary-op, wrapper, parse, other. + Kind string + Message string +} + // EffectiveCall is one argv-level invocation extracted from a shell AST. type EffectiveCall struct { Name string @@ -49,31 +59,43 @@ type EffectiveCall struct { } // Extract parses cmd into a shell AST and returns every effective call. -func Extract(cmd string) (calls []EffectiveCall, refuse string, err error) { +// If analysis is not possible, refusal is non-nil and contains a machine-readable +// Kind and human-readable Message; the engine treats any refusal as a block. +func Extract(cmd string) (calls []EffectiveCall, refusal *Refusal, err error) { parser := syntax.NewParser() file, err := parser.Parse(strings.NewReader(cmd), "") if err != nil { - return nil, "", fmt.Errorf("shell parse: %w", err) + return nil, nil, fmt.Errorf("shell parse: %w", err) } w := walker{} for _, stmt := range file.Stmts { w.walkStmt(stmt) if w.refuse != "" { - return w.calls, w.refuse, nil + return w.calls, &Refusal{Kind: w.refuseKind, Message: w.refuse}, nil } } - return w.calls, "", nil + return w.calls, nil, nil } type walker struct { calls []EffectiveCall refuse string + refuseKind string curCwd string inAndChain bool cdCount int sawAmbiguousCd bool } +// refuseWith records a refusal with a machine-readable kind. Subsequent calls +// are no-ops so the first refusal wins (mirrors the existing w.refuse != "" guards). +func (w *walker) refuseWith(kind, message string) { + if w.refuse == "" { + w.refuse = message + w.refuseKind = kind + } +} + var shellEvalBuiltins = map[string]bool{ "eval": true, "source": true, ".": true, "exec": true, } @@ -128,19 +150,19 @@ func (w *walker) walkStmt(s *syntax.Stmt) { return } if s.Background || s.Coprocess { - w.refuse = "background or coprocess statement" + w.refuseWith("background", "background or coprocess statement") return } for _, r := range s.Redirs { switch r.Op { case syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc: - w.refuse = "heredoc redirection (`<<`/`<<-`/`<<<`) not analyzable" + w.refuseWith("heredoc", "heredoc redirection (`<<`/`<<-`/`<<<`) not analyzable") return case syntax.RdrIn: - w.refuse = "input redirection (`<`) hides the command's actual input from policy" + w.refuseWith("input-redirect", "input redirection (`<`) hides the command's actual input from policy") return case syntax.DplIn: - w.refuse = "fd-duplicating input redirection (`<&`) not modeled" + w.refuseWith("fd-redirect", "fd-duplicating input redirection (`<&`) not modeled") return } } @@ -172,21 +194,21 @@ func (w *walker) walkCmd(stmt *syntax.Stmt, c syntax.Command) { w.curCwd = previous w.walkStmt(v.Y) default: - w.refuse = fmt.Sprintf("unsupported binary op: %v", v.Op) + w.refuseWith("binary-op", fmt.Sprintf("unsupported binary op: %v", v.Op)) } case *syntax.Subshell: - w.refuse = "subshell ( ... ) — side-effect scoping not modeled" + w.refuseWith("subshell", "subshell ( ... ) — side-effect scoping not modeled") case *syntax.Block: for _, st := range v.Stmts { w.walkStmt(st) } case *syntax.IfClause, *syntax.WhileClause, *syntax.ForClause, *syntax.CaseClause, *syntax.FuncDecl, *syntax.TestClause: - w.refuse = fmt.Sprintf("control-flow construct (%T) not analyzable", v) + w.refuseWith("control-flow", fmt.Sprintf("control-flow construct (%T) not analyzable", v)) case *syntax.LetClause, *syntax.DeclClause: - w.refuse = "declaration/assignment construct not analyzable" + w.refuseWith("assignment", "declaration/assignment construct not analyzable") default: - w.refuse = fmt.Sprintf("unrecognized command type %T", v) + w.refuseWith("parse", fmt.Sprintf("unrecognized command type %T", v)) } } @@ -204,23 +226,23 @@ func (w *walker) walkCallExpr(stmt *syntax.Stmt, c *syntax.CallExpr) { env := map[string]string{} for _, a := range c.Assigns { if a.Naked || a.Append || a.Index != nil { - w.refuse = "non-trivial assignment in command prefix" + w.refuseWith("assignment", "non-trivial assignment in command prefix") return } if a.Value == nil { continue } if reason := dangerousUnquoted(a.Value); reason != "" { - w.refuse = "env-prefix " + a.Name.Value + ": " + reason + w.refuseWith("env-prefix", "env-prefix "+a.Name.Value+": "+reason) return } val, ok := flattenWord(a.Value) if !ok { - w.refuse = "dynamic env-prefix value (var expansion / command substitution)" + w.refuseWith("env-prefix", "dynamic env-prefix value (var expansion / command substitution)") return } if isStartupSourcingEnvVar(a.Name.Value) { - w.refuse = "env-prefix " + a.Name.Value + " can source shell startup files; not analyzable" + w.refuseWith("env-prefix", "env-prefix "+a.Name.Value+" can source shell startup files; not analyzable") return } env[a.Name.Value] = val @@ -233,13 +255,13 @@ func (w *walker) walkCallExpr(stmt *syntax.Stmt, c *syntax.CallExpr) { var flat []string for i, w2 := range c.Args { if reason := dangerousUnquoted(w2); reason != "" { - w.refuse = reason + w.refuseWith("glob", reason) return } s, ok := flattenWord(w2) if !ok { if i == 0 { - w.refuse = "dynamic head (command name from var expansion / command substitution) — head must be statically determinable" + w.refuseWith("dynamic-head", "dynamic head (command name from var expansion / command substitution) — head must be statically determinable") return } flat = append(flat, DynamicMarker) @@ -250,38 +272,38 @@ func (w *walker) walkCallExpr(stmt *syntax.Stmt, c *syntax.CallExpr) { for depth := 0; depth < 8; depth++ { if len(flat) == 0 { - w.refuse = "wrapper resolved to no command" + w.refuseWith("wrapper", "wrapper resolved to no command") return } head := filepath.Base(flat[0]) if head == "cd" { if len(flat) == 1 { - w.refuse = "bare `cd` (resolves to $HOME) — target not statically known" + w.refuseWith("other", "bare `cd` (resolves to $HOME) — target not statically known") return } if len(flat) > 2 { - w.refuse = "cd with multiple args not supported" + w.refuseWith("other", "cd with multiple args not supported") return } target := flat[1] if target == "-" { - w.refuse = "`cd -` (resolves to $OLDPWD) — target not statically known" + w.refuseWith("other", "`cd -` (resolves to $OLDPWD) — target not statically known") return } if target == "~" || strings.HasPrefix(target, "~") { - w.refuse = "`cd ~`/`cd ~user` — tilde expansion not modeled" + w.refuseWith("other", "`cd ~`/`cd ~user` — tilde expansion not modeled") return } if cdpath, hasCdpath := env["CDPATH"]; hasCdpath && cdpath != "" { - w.refuse = "cd with explicit CDPATH=" + cdpath + " — target resolution depends on $CDPATH and isn't statically determinable" + w.refuseWith("env-prefix", "cd with explicit CDPATH="+cdpath+" — target resolution depends on $CDPATH and isn't statically determinable") return } if !filepath.IsAbs(target) && !strings.HasPrefix(target, "./") && !strings.HasPrefix(target, "../") && target != "." && target != ".." { - w.refuse = "cd to bare-relative target `" + target + "` — could be $CDPATH-resolved at runtime; use `./" + target + "` or an absolute path" + w.refuseWith("other", "cd to bare-relative target `"+target+"` — could be $CDPATH-resolved at runtime; use `./"+target+"` or an absolute path") return } w.cdCount++ @@ -293,19 +315,19 @@ func (w *walker) walkCallExpr(stmt *syntax.Stmt, c *syntax.CallExpr) { } if shellEvalBuiltins[head] { - w.refuse = head + " — dynamic shell evaluation not analyzable" + w.refuseWith("wrapper", head+" — dynamic shell evaluation not analyzable") return } if shellStateBuiltins[head] { - w.refuse = head + " — shell state mutation not modeled (refused so policy sees the right environment)" + w.refuseWith("wrapper", head+" — shell state mutation not modeled (refused so policy sees the right environment)") return } if blanketRefuseWrappers[head] { - w.refuse = head + " wrapper has dynamic semantics; not analyzable" + w.refuseWith("wrapper", head+" wrapper has dynamic semantics; not analyzable") return } if isUnknownDashCWrapper(flat) { - w.refuse = "unsupported shell wrapper with -c (only sh and bash recognized): " + flat[0] + w.refuseWith("wrapper", "unsupported shell wrapper with -c (only sh and bash recognized): "+flat[0]) return } if dashCWrappers[head] { @@ -349,7 +371,7 @@ func (w *walker) walkCallExpr(stmt *syntax.Stmt, c *syntax.CallExpr) { }) return } - w.refuse = "wrapper nesting too deep" + w.refuseWith("wrapper", "wrapper nesting too deep") } func (w *walker) peelEnv(rest []string, env map[string]string) ([]string, map[string]string, bool) { @@ -360,12 +382,12 @@ func (w *walker) peelEnv(rest []string, env map[string]string) ([]string, map[st for i := 0; i < len(rest); i++ { a := rest[i] if strings.HasPrefix(a, "-") { - w.refuse = "env wrapper with options not supported: " + a + w.refuseWith("wrapper", "env wrapper with options not supported: "+a) return nil, nil, false } if eq := strings.IndexByte(a, '='); eq > 0 && isEnvName(a[:eq]) { if isStartupSourcingEnvVar(a[:eq]) { - w.refuse = "env wrapper sets " + a[:eq] + " which can source shell startup files; not analyzable" + w.refuseWith("env-prefix", "env wrapper sets "+a[:eq]+" which can source shell startup files; not analyzable") return nil, nil, false } extraEnv[a[:eq]] = a[eq+1:] @@ -373,7 +395,7 @@ func (w *walker) peelEnv(rest []string, env map[string]string) ([]string, map[st } return rest[i:], extraEnv, true } - w.refuse = "env wrapper with no command" + w.refuseWith("wrapper", "env wrapper with no command") return nil, nil, false } @@ -395,12 +417,12 @@ func (w *walker) peelXargs(rest []string) ([]string, bool) { // flag we can't safely skip. This also catches `--replace=foo` and // `--max-args=5` because those literal tokens aren't in the allowlist. if !xargsBoolFlags[a] { - w.refuse = "xargs flag " + a + " not in safe-peel allowlist (value-taking or unknown)" + w.refuseWith("wrapper", "xargs flag "+a+" not in safe-peel allowlist (value-taking or unknown)") return nil, false } } if i >= len(rest) { - w.refuse = "xargs with no inner command" + w.refuseWith("wrapper", "xargs with no inner command") return nil, false } // Append a placeholder for stdin-fed args. If xargs is invoked with @@ -416,12 +438,12 @@ func (w *walker) peelTransparent(name string, rest []string) ([]string, bool) { for j := 0; j < len(rest); j++ { a := rest[j] if strings.HasPrefix(a, "-") { - w.refuse = name + " wrapper with options not supported (got flag: " + a + ")" + w.refuseWith("wrapper", name+" wrapper with options not supported (got flag: "+a+")") return nil, false } return rest[j:], true } - w.refuse = name + " wrapper with no command" + w.refuseWith("wrapper", name+" wrapper with no command") return nil, false } @@ -430,21 +452,21 @@ func (w *walker) unwrapDashC(stmt *syntax.Stmt, head string, rest []string, env a := rest[i] if a == "-c" { if i+1 >= len(rest) { - w.refuse = head + " -c with no string argument" + w.refuseWith("wrapper", head+" -c with no string argument") return } if i+2 < len(rest) { - w.refuse = head + " -c with positional args after STRING not supported" + w.refuseWith("wrapper", head+" -c with positional args after STRING not supported") return } inner := rest[i+1] - subCalls, subRefuse, err := Extract(inner) + subCalls, subRefusal, err := Extract(inner) if err != nil { - w.refuse = "inner " + head + " parse failed: " + err.Error() + w.refuseWith("parse", "inner "+head+" parse failed: "+err.Error()) return } - if subRefuse != "" { - w.refuse = "inside " + head + " -c: " + subRefuse + if subRefusal != nil { + w.refuseWith(subRefusal.Kind, "inside "+head+" -c: "+subRefusal.Message) return } for _, sc := range subCalls { @@ -467,10 +489,10 @@ func (w *walker) unwrapDashC(stmt *syntax.Stmt, head string, rest []string, env if a == "-x" { continue } - w.refuse = head + " invocation without -c not supported (got: " + a + ")" + w.refuseWith("wrapper", head+" invocation without -c not supported (got: "+a+")") return } - w.refuse = head + " invocation without -c not supported" + w.refuseWith("wrapper", head+" invocation without -c not supported") } func composeCwd(outer, inner string) string { diff --git a/internal/shellparser/shellparser_test.go b/internal/shellparser/shellparser_test.go index df1b6c5..024547c 100644 --- a/internal/shellparser/shellparser_test.go +++ b/internal/shellparser/shellparser_test.go @@ -10,12 +10,12 @@ import ( ) func TestExtract_Plain(t *testing.T) { - calls, refuse, err := Extract("kubectl get pods") + calls, refusal, err := Extract("kubectl get pods") if err != nil { t.Fatal(err) } - if refuse != "" { - t.Errorf("unexpected refuse: %s", refuse) + if refusal != nil { + t.Errorf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("calls = %+v", calls) @@ -63,9 +63,9 @@ func TestExtract_Pipe(t *testing.T) { } func TestExtract_UnwrapShDashC(t *testing.T) { - calls, refuse, _ := Extract(`sh -c "kubectl apply -f x.yaml"`) - if refuse != "" { - t.Errorf("should unwrap sh -c, got refuse: %s", refuse) + calls, refusal, _ := Extract(`sh -c "kubectl apply -f x.yaml"`) + if refusal != nil { + t.Errorf("should unwrap sh -c, got refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("expected unwrapped kubectl call, got %+v", calls) @@ -73,8 +73,8 @@ func TestExtract_UnwrapShDashC(t *testing.T) { } func TestExtract_RefuseBashLc(t *testing.T) { - _, refuse, _ := Extract(`bash -lc 'kubectl --context arn get pods'`) - if refuse == "" { + _, refusal, _ := Extract(`bash -lc 'kubectl --context arn get pods'`) + if refusal == nil { t.Errorf("expected refuse on bash -lc form") } } @@ -90,9 +90,9 @@ func TestExtract_UnwrapEnv(t *testing.T) { } func TestExtract_RefuseSubshell(t *testing.T) { - _, refuse, _ := Extract("(kubectl apply -f x.yaml)") - if !strings.Contains(refuse, "subshell") { - t.Errorf("expected subshell refuse, got %q", refuse) + _, refusal, _ := Extract("(kubectl apply -f x.yaml)") + if refusal == nil || !strings.Contains(refusal.Message, "subshell") { + t.Errorf("expected subshell refuse, got %v", refusal) } } @@ -101,9 +101,9 @@ func TestExtract_CommandSubstNonHeadEmitsPlaceholder(t *testing.T) { // walker emits a placeholder for non-head dynamic args; the bundled // policy then blocks `kubectl apply -f ` because verb=apply // is not in read_verbs and allowed_dry_run requires a literal flag. - calls, refuse, _ := Extract(`kubectl apply -f $(echo x.yaml)`) - if refuse != "" { - t.Fatalf("expected no walker refuse for non-head $(...); got: %s", refuse) + calls, refusal, _ := Extract(`kubectl apply -f $(echo x.yaml)`) + if refusal != nil { + t.Fatalf("expected no walker refuse for non-head $(...); got: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Fatalf("expected one kubectl call, got %+v", calls) @@ -116,8 +116,8 @@ func TestExtract_CommandSubstNonHeadEmitsPlaceholder(t *testing.T) { } func TestExtract_DynamicHeadStillRefuses(t *testing.T) { - _, refuse, _ := Extract(`$(echo kubectl) apply`) - if refuse == "" { + _, refusal, _ := Extract(`$(echo kubectl) apply`) + if refusal == nil { t.Errorf("expected refuse for dynamic head — head must be statically known") } } @@ -126,9 +126,9 @@ func TestExtract_GhPRCreateAcceptsDynamicBody(t *testing.T) { // The real-world unblocking case: `gh pr create --body "$(cat ...)"` is // the standard PR-creation pattern. gh isn't an infra tool, so the // registry skips it and the command runs. - calls, refuse, _ := Extract(`gh pr create --title "X" --body "$(cat body.md)"`) - if refuse != "" { - t.Fatalf("expected no refuse for gh + dynamic body; got: %s", refuse) + calls, refusal, _ := Extract(`gh pr create --title "X" --body "$(cat body.md)"`) + if refusal != nil { + t.Fatalf("expected no refuse for gh + dynamic body; got: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "gh" { t.Fatalf("expected one gh call, got %+v", calls) @@ -149,9 +149,9 @@ func TestExtract_DynamicVerbEmitsPlaceholder(t *testing.T) { // `kubectl $(echo apply)` — head is static (kubectl), but the verb // position is dynamic. Walker emits with placeholder; bundled policy // blocks because the placeholder is not in read_verbs. - calls, refuse, _ := Extract(`kubectl $(echo apply)`) - if refuse != "" { - t.Fatalf("expected no walker refuse; got: %s", refuse) + calls, refusal, _ := Extract(`kubectl $(echo apply)`) + if refusal != nil { + t.Fatalf("expected no walker refuse; got: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Fatalf("expected kubectl call, got %+v", calls) @@ -162,15 +162,15 @@ func TestExtract_DynamicVerbEmitsPlaceholder(t *testing.T) { } func TestExtract_RefuseEval(t *testing.T) { - _, refuse, _ := Extract(`eval "kubectl apply"`) - if refuse == "" { + _, refusal, _ := Extract(`eval "kubectl apply"`) + if refusal == nil { t.Errorf("expected refuse for eval") } } func TestExtract_RefuseUnknownWrapperDashC(t *testing.T) { - _, refuse, _ := Extract(`zsh -c "kubectl apply"`) - if refuse == "" { + _, refusal, _ := Extract(`zsh -c "kubectl apply"`) + if refusal == nil { t.Errorf("expected refuse for unknown wrapper -c form") } for _, cmd := range []string{ @@ -179,7 +179,7 @@ func TestExtract_RefuseUnknownWrapperDashC(t *testing.T) { `/usr/local/bin/zsh -c "kubectl apply"`, } { _, r, _ := Extract(cmd) - if r == "" { + if r == nil { t.Errorf("expected refuse for %q", cmd) } } @@ -192,9 +192,9 @@ func TestExtract_NonShellDashCNotRefused(t *testing.T) { `cc -c file.c`, `gcc -c -o file.o file.c`, } { - calls, refuse, _ := Extract(cmd) - if refuse != "" { - t.Errorf("did not expect refuse for %q; got: %s", cmd, refuse) + calls, refusal, _ := Extract(cmd) + if refusal != nil { + t.Errorf("did not expect refuse for %q; got: %s", cmd, refusal.Message) } if len(calls) == 0 { t.Errorf("expected at least one call for %q", cmd) @@ -203,9 +203,9 @@ func TestExtract_NonShellDashCNotRefused(t *testing.T) { } func TestExtract_MultiCDChain(t *testing.T) { - calls, refuse, _ := Extract("cd /abs && cd ./rel && kubectl get pods") - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract("cd /abs && cd ./rel && kubectl get pods") + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Cwd != "/abs/rel" { t.Errorf("expected one call with Cwd=/abs/rel, got %+v", calls) @@ -213,9 +213,9 @@ func TestExtract_MultiCDChain(t *testing.T) { } func TestExtract_CDPropagatesCwd(t *testing.T) { - calls, refuse, _ := Extract("cd /tmp/x && kubectl get pods") - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract("cd /tmp/x && kubectl get pods") + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Fatalf("expected one kubectl call, got %+v", calls) @@ -226,9 +226,9 @@ func TestExtract_CDPropagatesCwd(t *testing.T) { } func TestExtract_CDSemicolonEmitsUncertainCwd(t *testing.T) { - calls, refuse, _ := Extract("cd /tmp/y; kubectl get pods") - if refuse != "" { - t.Fatalf("unexpected walker refuse: %s", refuse) + calls, refusal, _ := Extract("cd /tmp/y; kubectl get pods") + if refusal != nil { + t.Fatalf("unexpected walker refuse: %s", refusal.Message) } if len(calls) != 1 { t.Fatalf("expected 1 call (kubectl), got %+v", calls) @@ -239,9 +239,9 @@ func TestExtract_CDSemicolonEmitsUncertainCwd(t *testing.T) { } func TestExtract_CDOrOpEmitsUncertainCwd(t *testing.T) { - calls, refuse, _ := Extract("cd /missing || kubectl get pods") - if refuse != "" { - t.Fatalf("unexpected walker refuse: %s", refuse) + calls, refusal, _ := Extract("cd /missing || kubectl get pods") + if refusal != nil { + t.Fatalf("unexpected walker refuse: %s", refusal.Message) } if len(calls) != 1 { t.Fatalf("expected 1 call (kubectl), got %+v", calls) @@ -252,16 +252,16 @@ func TestExtract_CDOrOpEmitsUncertainCwd(t *testing.T) { } func TestExtract_CDStandaloneOK(t *testing.T) { - _, refuse, _ := Extract("cd /tmp") - if refuse != "" { - t.Errorf("unexpected refuse on standalone cd: %s", refuse) + _, refusal, _ := Extract("cd /tmp") + if refusal != nil { + t.Errorf("unexpected refuse on standalone cd: %s", refusal.Message) } } func TestExtract_CallBeforeCDIsFine(t *testing.T) { - calls, refuse, _ := Extract("kubectl get && cd /tmp") - if refuse != "" { - t.Errorf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract("kubectl get && cd /tmp") + if refusal != nil { + t.Errorf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("expected one kubectl call, got %+v", calls) @@ -274,9 +274,9 @@ func TestExtract_CDInANDLeakingPastBoundaryEmitsUncertainCwd(t *testing.T) { `cd /repo && do-something; kubectl apply`, `cd /a && cd /b; kubectl get`, } { - calls, refuse, _ := Extract(cmd) - if refuse != "" { - t.Errorf("unexpected walker refuse for %q: %s", cmd, refuse) + calls, refusal, _ := Extract(cmd) + if refusal != nil { + t.Errorf("unexpected walker refuse for %q: %s", cmd, refusal.Message) continue } // The trailing call (kubectl) must be emitted with UncertainCwd=true @@ -299,9 +299,9 @@ func TestExtract_CDInANDLeakingPastBoundaryEmitsUncertainCwd(t *testing.T) { } func TestExtract_CDInBashDashCLeakingPastBoundaryEmitsUncertainCwd(t *testing.T) { - calls, refuse, _ := Extract(`bash -c 'cd /missing && true; kubectl apply -f x.yaml'`) - if refuse != "" { - t.Fatalf("unexpected walker refuse: %s", refuse) + calls, refusal, _ := Extract(`bash -c 'cd /missing && true; kubectl apply -f x.yaml'`) + if refusal != nil { + t.Fatalf("unexpected walker refuse: %s", refusal.Message) } var trailing *EffectiveCall for i := range calls { @@ -321,9 +321,9 @@ func TestExtract_TerraformCheckThenSemicolonEcho_AllowsEcho(t *testing.T) { // Real-world case: `cd /repo && terraform fmt 2>&1; echo $?` — // walker emits both calls, terraform without UncertainCwd (cd's // RHS in AndStmt is reliable), echo with UncertainCwd=true. - calls, refuse, _ := Extract(`cd /repo && terraform fmt 2>&1; echo done`) - if refuse != "" { - t.Fatalf("expected no walker refuse; got: %s", refuse) + calls, refusal, _ := Extract(`cd /repo && terraform fmt 2>&1; echo done`) + if refusal != nil { + t.Fatalf("expected no walker refuse; got: %s", refusal.Message) } // Two calls expected: terraform (cwd=/repo, UncertainCwd=false) // and echo (UncertainCwd=true, cwd=whatever the walker tracked). @@ -352,9 +352,9 @@ func TestExtract_TerraformCheckThenSemicolonEcho_AllowsEcho(t *testing.T) { } func TestExtract_AndStmtCDStillSafeForItsOwnRHS(t *testing.T) { - calls, refuse, _ := Extract("cd /repo && kubectl get pods") - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract("cd /repo && kubectl get pods") + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Cwd != "/repo" { t.Errorf("expected kubectl with Cwd=/repo, got %+v", calls) @@ -362,9 +362,9 @@ func TestExtract_AndStmtCDStillSafeForItsOwnRHS(t *testing.T) { } func TestExtract_AndStmtCDInAllowsTrailingNonCallCommand(t *testing.T) { - _, refuse, _ := Extract("cd /tmp && true") - if refuse != "" { - t.Errorf("standalone cd-and-true should not refuse: %s", refuse) + _, refusal, _ := Extract("cd /tmp && true") + if refusal != nil { + t.Errorf("standalone cd-and-true should not refuse: %s", refusal.Message) } } @@ -377,17 +377,17 @@ func TestExtract_RefuseUnquotedGlob(t *testing.T) { `kubectl apply -f {a,b}.yaml`, `rm -rf /tmp/*`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (unquoted glob)", cmd) } } } func TestExtract_QuotedGlobIsLiteral(t *testing.T) { - calls, refuse, _ := Extract(`kubectl apply -f '*.yaml'`) - if refuse != "" { - t.Fatalf("quoted glob should NOT refuse: %s", refuse) + calls, refusal, _ := Extract(`kubectl apply -f '*.yaml'`) + if refusal != nil { + t.Fatalf("quoted glob should NOT refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Args[len(calls[0].Args)-1] != "*.yaml" { t.Errorf("expected literal *.yaml in args, got %+v", calls) @@ -399,17 +399,17 @@ func TestExtract_RefuseUnquotedLeadingTilde(t *testing.T) { `kubectl apply -f ~/manifests.yaml`, `cat ~someuser/file`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (unquoted leading tilde)", cmd) } } } func TestExtract_TildeInMiddleIsLiteral(t *testing.T) { - calls, refuse, _ := Extract(`echo file~backup`) - if refuse != "" { - t.Errorf("mid-word tilde should not refuse: %s", refuse) + calls, refusal, _ := Extract(`echo file~backup`) + if refusal != nil { + t.Errorf("mid-word tilde should not refuse: %s", refusal.Message) } if len(calls) != 1 { t.Errorf("calls = %+v", calls) @@ -422,17 +422,17 @@ func TestExtract_RefuseInputRedirection(t *testing.T) { `cat < /etc/passwd`, `kubectl apply -f - <<< "$(cat prod.yaml)"`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (input redirection)", cmd) } } } func TestExtract_OutputRedirectionAllowed(t *testing.T) { - calls, refuse, _ := Extract(`kubectl get pods > /tmp/out`) - if refuse != "" { - t.Fatalf("output redirection should not refuse: %s", refuse) + calls, refusal, _ := Extract(`kubectl get pods > /tmp/out`) + if refusal != nil { + t.Fatalf("output redirection should not refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("expected kubectl call, got %+v", calls) @@ -445,8 +445,8 @@ func TestExtract_RefuseBlockLevelInputRedirection(t *testing.T) { `{ cd ./repo && kubectl apply -f -; } < prod.yaml`, `{ cat /etc/hosts; } < /dev/null`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (block-level < attached to outer stmt)", cmd) } } @@ -459,17 +459,17 @@ func TestExtract_RefuseEnvPrefixTildeValue(t *testing.T) { `PATH=/usr/bin:~/bin kubectl get pods`, `env PATH=/usr/bin:~/bin kubectl get pods`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (unquoted ~ in env-prefix value)", cmd) } } } func TestExtract_QuotedTildeInEnvValueOK(t *testing.T) { - calls, refuse, _ := Extract(`KUBECONFIG="~/prod" kubectl get pods`) - if refuse != "" { - t.Errorf("quoted tilde in env value should not refuse: %s", refuse) + calls, refusal, _ := Extract(`KUBECONFIG="~/prod" kubectl get pods`) + if refusal != nil { + t.Errorf("quoted tilde in env value should not refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Env["KUBECONFIG"] != "~/prod" { t.Errorf("expected literal ~/prod in env, got %+v", calls) @@ -485,24 +485,24 @@ func TestExtract_RefuseCDBareCDDashCDTilde(t *testing.T) { `cd ~/Code && kubectl get pods`, `cd ~user && kubectl get pods`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (statically unknown cd target)", cmd) } } } func TestExtract_RefuseCDBareRelative(t *testing.T) { - _, refuse, _ := Extract("cd subdir && kubectl get pods") - if refuse == "" { + _, refusal, _ := Extract("cd subdir && kubectl get pods") + if refusal == nil { t.Errorf("expected refuse on bare-relative cd target") } } func TestExtract_RelativeCDExplicitDotSlashIsFine(t *testing.T) { - calls, refuse, _ := Extract("cd ./subdir && kubectl get pods") - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract("cd ./subdir && kubectl get pods") + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 { t.Fatalf("calls = %+v", calls) @@ -517,23 +517,23 @@ func TestExtract_RefuseCDWithCDPATHEnvPrefix(t *testing.T) { `CDPATH=/evil cd ./repo && kubectl apply -f x.yaml`, `CDPATH=/x:/y cd /abs/path && kubectl apply`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (CDPATH env-prefix)", cmd) } } } func TestExtract_RefuseFunctionDecl(t *testing.T) { - _, refuse, _ := Extract(`f() { kubectl apply; }; f`) - if refuse == "" { + _, refusal, _ := Extract(`f() { kubectl apply; }; f`) + if refusal == nil { t.Errorf("expected refuse for function definition") } } func TestExtract_RefuseLoop(t *testing.T) { - _, refuse, _ := Extract(`for i in 1 2; do kubectl apply -f $i.yaml; done`) - if refuse == "" { + _, refusal, _ := Extract(`for i in 1 2; do kubectl apply -f $i.yaml; done`) + if refusal == nil { t.Errorf("expected refuse for for loop") } } @@ -559,8 +559,8 @@ func TestExtract_RefuseShellEvalBuiltins(t *testing.T) { `. /tmp/setup.sh`, `exec kubectl apply -f x.yaml`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q", cmd) } } @@ -568,16 +568,16 @@ func TestExtract_RefuseShellEvalBuiltins(t *testing.T) { func TestExtract_RefuseHeredoc(t *testing.T) { cmd := "bash <<'EOF'\nkubectl apply -f x.yaml\nEOF\n" - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse on heredoc") } } func TestExtract_AbsolutePathBashDashC(t *testing.T) { - calls, refuse, _ := Extract(`/bin/bash -c "kubectl apply -f x.yaml"`) - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract(`/bin/bash -c "kubectl apply -f x.yaml"`) + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("expected unwrapped kubectl call from /bin/bash -c, got %+v", calls) @@ -585,9 +585,9 @@ func TestExtract_AbsolutePathBashDashC(t *testing.T) { } func TestExtract_AbsolutePathEnv(t *testing.T) { - calls, refuse, _ := Extract(`/usr/bin/env kubectl get pods`) - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract(`/usr/bin/env kubectl get pods`) + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("expected unwrapped kubectl from /usr/bin/env, got %+v", calls) @@ -602,16 +602,16 @@ func TestExtract_RefuseShWithoutDashC(t *testing.T) { `bash -i`, `sh -- foo bar`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q", cmd) } } } func TestExtract_RefuseShDashCWithExtraArgs(t *testing.T) { - _, refuse, _ := Extract(`sh -c "kubectl get pods" myname extra`) - if refuse == "" { + _, refusal, _ := Extract(`sh -c "kubectl get pods" myname extra`) + if refusal == nil { t.Errorf("expected refuse on -c with trailing positional args") } } @@ -622,8 +622,8 @@ func TestExtract_RefuseEnvDashI(t *testing.T) { `env -u PATH kubectl apply`, `env -S "FOO=bar" kubectl apply`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q", cmd) } } @@ -635,8 +635,8 @@ func TestExtract_RefuseTransparentWrapperWithFlags(t *testing.T) { `time -p kubectl apply -f x.yaml`, `ionice -c 3 kubectl apply -f x.yaml`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q", cmd) } } @@ -658,9 +658,9 @@ func TestExtract_XargsPeelsBooleanFlags(t *testing.T) { {`echo a | xargs -t -x grep foo`, "grep", "foo"}, } for _, tc := range cases { - calls, refuse, _ := Extract(tc.cmd) - if refuse != "" { - t.Errorf("expected no refuse for %q; got: %s", tc.cmd, refuse) + calls, refusal, _ := Extract(tc.cmd) + if refusal != nil { + t.Errorf("expected no refuse for %q; got: %s", tc.cmd, refusal.Message) continue } // Find the inner-command call (skip echo). @@ -693,29 +693,29 @@ func TestExtract_XargsValueFlagRefuses(t *testing.T) { `xargs --max-args=5 echo`, } for _, cmd := range cases { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for value-taking xargs flag in %q", cmd) } } } func TestExtract_XargsNoCommandRefuses(t *testing.T) { - _, refuse, _ := Extract(`xargs`) - if refuse == "" { + _, refusal, _ := Extract(`xargs`) + if refusal == nil { t.Errorf("expected refuse for bare xargs") } - _, refuse, _ = Extract(`xargs -0`) - if refuse == "" { + _, refusal2, _ := Extract(`xargs -0`) + if refusal2 == nil { t.Errorf("expected refuse for xargs with only flags, no command") } } func TestExtract_DiskUsageFindXargsDuPipeline(t *testing.T) { cmd := `find /tmp -maxdepth 1 -print0 | xargs -0 du -sh 2>/dev/null | sort -rh | head -40` - calls, refuse, _ := Extract(cmd) - if refuse != "" { - t.Fatalf("expected no refuse for find/xargs/du pipeline; got: %s", refuse) + calls, refusal, _ := Extract(cmd) + if refusal != nil { + t.Fatalf("expected no refuse for find/xargs/du pipeline; got: %s", refusal.Message) } // Should have find, du (from peeled xargs), sort, head — none are infra. names := make([]string, 0, len(calls)) @@ -738,9 +738,9 @@ func TestExtract_DiskUsageFindXargsDuPipeline(t *testing.T) { } func TestExtract_NestedEnvBashDashC(t *testing.T) { - calls, refuse, _ := Extract(`env FOO=bar bash -c "kubectl apply -f x.yaml"`) - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract(`env FOO=bar bash -c "kubectl apply -f x.yaml"`) + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Fatalf("expected kubectl, got %+v", calls) @@ -751,9 +751,9 @@ func TestExtract_NestedEnvBashDashC(t *testing.T) { } func TestExtract_NestedNiceBashDashC(t *testing.T) { - calls, refuse, _ := Extract(`nice bash -c "kubectl apply"`) - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract(`nice bash -c "kubectl apply"`) + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("expected kubectl, got %+v", calls) @@ -761,9 +761,9 @@ func TestExtract_NestedNiceBashDashC(t *testing.T) { } func TestExtract_DeeplyNestedWrappers(t *testing.T) { - calls, refuse, _ := Extract(`env A=1 nice bash -c "kubectl get pods"`) - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract(`env A=1 nice bash -c "kubectl get pods"`) + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Errorf("expected kubectl, got %+v", calls) @@ -781,8 +781,8 @@ func TestExtract_RefuseBashInteractiveOrLogin(t *testing.T) { `bash --login -c "kubectl apply"`, `bash --interactive -c "kubectl apply"`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (interactive/login sources startup files)", cmd) } } @@ -794,17 +794,17 @@ func TestExtract_RefuseStartupSourcingEnvVars(t *testing.T) { `ENV=/tmp/setup.sh sh -c "kubectl apply"`, `PROMPT_COMMAND="echo evil" bash -c "kubectl apply"`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q", cmd) } } } func TestExtract_CDInsideBashDashCComposes(t *testing.T) { - calls, refuse, _ := Extract(`cd /repo && bash -c 'cd ./sub && kubectl apply'`) - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract(`cd /repo && bash -c 'cd ./sub && kubectl apply'`) + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "kubectl" { t.Fatalf("expected one kubectl call, got %+v", calls) @@ -831,17 +831,17 @@ func TestExtract_RefuseShellStateBuiltins(t *testing.T) { `pushd /tmp; kubectl apply`, `trap 'echo done' EXIT; kubectl apply`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q", cmd) } } } func TestExtract_BuiltinPeels(t *testing.T) { - calls, refuse, _ := Extract(`builtin cat /etc/hosts`) - if refuse != "" { - t.Fatalf("unexpected refuse: %s", refuse) + calls, refusal, _ := Extract(`builtin cat /etc/hosts`) + if refusal != nil { + t.Fatalf("unexpected refuse: %s", refusal.Message) } if len(calls) != 1 || calls[0].Name != "cat" { t.Errorf("expected cat call after builtin peel, got %+v", calls) @@ -857,9 +857,80 @@ func TestExtract_BuiltinEscapeRefuses(t *testing.T) { `builtin unset PATH`, `builtin alias k=kubectl`, } { - _, refuse, _ := Extract(cmd) - if refuse == "" { + _, refusal, _ := Extract(cmd) + if refusal == nil { t.Errorf("expected refuse for %q (builtin escape via inner eval/state-mutation)", cmd) } } } + +// TestExtract_RefusalKinds asserts that the machine-readable Kind is set +// correctly for representative inputs. This is the contract that hook.go +// uses to populate the Subverb column of the decision log. +func TestExtract_RefusalKinds(t *testing.T) { + cases := []struct { + cmd string + wantKind string + desc string + }{ + { + cmd: "bash <<'EOF'\nkubectl apply\nEOF\n", + wantKind: "heredoc", + desc: "heredoc redirection", + }, + { + cmd: `for i in 1 2; do kubectl apply; done`, + wantKind: "control-flow", + desc: "for loop", + }, + { + cmd: `if true; then kubectl apply; fi`, + wantKind: "control-flow", + desc: "if clause", + }, + { + cmd: `kubectl apply -f *.yaml`, + wantKind: "glob", + desc: "unquoted glob argument", + }, + { + cmd: `(kubectl apply -f x.yaml)`, + wantKind: "subshell", + desc: "subshell", + }, + { + cmd: `$(echo kubectl) apply`, + wantKind: "dynamic-head", + desc: "dynamic head", + }, + { + cmd: `kubectl apply &`, + wantKind: "background", + desc: "background statement", + }, + { + cmd: `kubectl apply -f - < prod.yaml`, + wantKind: "input-redirect", + desc: "input redirection", + }, + { + cmd: `kubectl apply -f - <& 3`, + wantKind: "fd-redirect", + desc: "fd-duplicating input redirect", + }, + } + for _, tc := range cases { + _, refusal, err := Extract(tc.cmd) + if err != nil { + t.Errorf("%s: unexpected parse error: %v", tc.desc, err) + continue + } + if refusal == nil { + t.Errorf("%s: expected refusal, got nil", tc.desc) + continue + } + if refusal.Kind != tc.wantKind { + t.Errorf("%s: Kind = %q, want %q (message: %s)", tc.desc, refusal.Kind, tc.wantKind, refusal.Message) + } + } +} diff --git a/internal/subcommand/explain.go b/internal/subcommand/explain.go index 5f85ee6..76ae597 100644 --- a/internal/subcommand/explain.go +++ b/internal/subcommand/explain.go @@ -42,14 +42,14 @@ func Explain(cmdArgs []string, out io.Writer, opts ExplainOptions) int { command = joinShellArgs(cmdArgs) } - calls, refuse, err := shellparser.Extract(command) + calls, refusal, err := shellparser.Extract(command) if err != nil { fmt.Fprintf(out, "shell parse error: %v\n", err) return 2 } - if refuse != "" { + if refusal != nil { fmt.Fprintf(out, "Decision: BLOCK (refuse-on-ambiguity)\n") - fmt.Fprintf(out, "Reason : %s\n", refuse) + fmt.Fprintf(out, "Reason : %s\n", refusal.Message) return 0 } if len(calls) == 0 { diff --git a/internal/subcommand/hook.go b/internal/subcommand/hook.go index dbeaa02..b78c1ef 100644 --- a/internal/subcommand/hook.go +++ b/internal/subcommand/hook.go @@ -101,16 +101,16 @@ func Hook(stdin io.Reader, stdout, stderr io.Writer, opts HookOptions) int { // a strict subset of valid shell, so an obscure-but-valid command // might fail our parser yet still execute. Failing closed is // correct for a guard.) - calls, refuse, err := shellparser.Extract(in.ToolInput.Command) + calls, refusal, err := shellparser.Extract(in.ToolInput.Command) if err != nil { reason := "failsafe cannot parse this command (shell syntax not supported by mvdan.cc/sh): " + err.Error() - logRec("block", reason, "", "", "", in.CWD) + logRec("block", reason, "shell", "unanalyzable", "parse", in.CWD) _ = hookio.WriteBlock(stdout, reason) return 0 } - if refuse != "" { - reason := "failsafe cannot safely analyze this command: " + refuse - logRec("block", reason, "", "", "", in.CWD) + if refusal != nil { + reason := "failsafe cannot safely analyze this command: " + refusal.Message + logRec("block", reason, "shell", "unanalyzable", refusal.Kind, in.CWD) _ = hookio.WriteBlock(stdout, reason) return 0 } diff --git a/internal/subcommand/report.go b/internal/subcommand/report.go index 598d8b1..59e6c87 100644 --- a/internal/subcommand/report.go +++ b/internal/subcommand/report.go @@ -170,6 +170,7 @@ func scariness(r auditlog.Record) int { type countRow struct { Tool string `json:"tool"` Verb string `json:"verb"` + Subverb string `json:"subverb,omitempty"` Decision string `json:"decision"` Count int `json:"count"` } @@ -195,15 +196,15 @@ type report struct { const scariestN = 5 func buildReport(since string, recs []auditlog.Record) report { - // Aggregate by tool+verb+decision. - type key struct{ tool, verb, decision string } + // Aggregate by tool+verb+subverb+decision. + type key struct{ tool, verb, subverb, decision string } tally := map[key]int{} for _, r := range recs { - tally[key{r.Tool, r.Verb, r.Decision}]++ + tally[key{r.Tool, r.Verb, r.Subverb, r.Decision}]++ } counts := make([]countRow, 0, len(tally)) for k, n := range tally { - counts = append(counts, countRow{Tool: k.tool, Verb: k.verb, Decision: k.decision, Count: n}) + counts = append(counts, countRow{Tool: k.tool, Verb: k.verb, Subverb: k.subverb, Decision: k.decision, Count: n}) } // Deterministic order: most frequent first, then alphabetical for stable ties. sort.Slice(counts, func(i, j int) bool { @@ -216,6 +217,9 @@ func buildReport(since string, recs []auditlog.Record) report { if counts[i].Verb != counts[j].Verb { return counts[i].Verb < counts[j].Verb } + if counts[i].Subverb != counts[j].Subverb { + return counts[i].Subverb < counts[j].Subverb + } return counts[i].Decision < counts[j].Decision }) @@ -264,10 +268,10 @@ func renderMarkdown(out io.Writer, rep report) int { } fmt.Fprintf(out, "## Counts by tool / verb / decision\n\n") - fmt.Fprintln(out, "| Tool | Verb | Decision | Count |") - fmt.Fprintln(out, "|------|------|----------|-------|") + fmt.Fprintln(out, "| Tool | Verb | Subverb | Decision | Count |") + fmt.Fprintln(out, "|------|------|---------|----------|-------|") for _, c := range rep.Counts { - fmt.Fprintf(out, "| %s | %s | %s | %d |\n", c.Tool, c.Verb, c.Decision, c.Count) + fmt.Fprintf(out, "| %s | %s | %s | %s | %d |\n", c.Tool, c.Verb, c.Subverb, c.Decision, c.Count) } if len(rep.Scariest) > 0 { diff --git a/internal/subcommand/report_test.go b/internal/subcommand/report_test.go index c93ed65..64e599c 100644 --- a/internal/subcommand/report_test.go +++ b/internal/subcommand/report_test.go @@ -210,3 +210,103 @@ func TestReport_NoShareKeepsRawValues(t *testing.T) { t.Errorf("local report should keep raw cwd; got:\n%s", buf.String()) } } + +// TestReport_SubverbColumnBreaksDownRefusalKinds verifies that shell-parser refusals +// with different subverbs (e.g. glob vs heredoc) produce distinct rows in the report +// and that a normal tool row (git/commit) still renders correctly with an empty Subverb. +func TestReport_SubverbColumnBreaksDownRefusalKinds(t *testing.T) { + log := writeLog(t, + // Two distinct shell/unanalyzable refusals: glob and heredoc + `{"ts":"2026-05-30T11:00:00Z","decision":"block","tool":"shell","verb":"unanalyzable","subverb":"glob","session":{"agent_session_id":"s"}}`, + `{"ts":"2026-05-30T11:01:00Z","decision":"block","tool":"shell","verb":"unanalyzable","subverb":"heredoc","session":{"agent_session_id":"s"}}`, + // A normal tool row with no subverb + `{"ts":"2026-05-30T11:02:00Z","decision":"allow","tool":"git","verb":"commit","session":{"agent_session_id":"s"}}`, + ) + env := runReportJSON(t, log) + if env.Total != 3 { + t.Fatalf("total = %d, want 3", env.Total) + } + + // Build a map of (tool,verb,decision) → rows to make lookup easy. + type rowKey struct{ tool, verb, decision string } + // We need to capture subverb too; use a local struct for richer lookup. + type fullRow struct { + Tool, Verb, Subverb, Decision string + Count int + } + // Re-run with JSON to get subverb column. + var rawBuf bytes.Buffer + args := []string{"--format", "json"} + code := Report(args, &rawBuf, ReportOptions{Home: "/Users/you", LogPath: log, Now: fixedNow}) + if code != 0 { + t.Fatalf("Report exit=%d, out=%s", code, rawBuf.String()) + } + // Decode with a richer struct that includes Subverb. + var richEnv struct { + Counts []struct { + Tool string `json:"tool"` + Verb string `json:"verb"` + Subverb string `json:"subverb"` + Decision string `json:"decision"` + Count int `json:"count"` + } `json:"counts"` + } + if err := json.Unmarshal(rawBuf.Bytes(), &richEnv); err != nil { + t.Fatalf("json decode: %v\noutput: %s", err, rawBuf.String()) + } + + // We expect three distinct rows (glob block, heredoc block, git/commit allow). + if len(richEnv.Counts) != 3 { + t.Fatalf("expected 3 distinct count rows (glob+heredoc refusals + git/commit), got %d: %+v", len(richEnv.Counts), richEnv.Counts) + } + + var sawGlob, sawHeredoc, sawGitCommit bool + for _, c := range richEnv.Counts { + switch { + case c.Tool == "shell" && c.Verb == "unanalyzable" && c.Subverb == "glob" && c.Decision == "block": + sawGlob = true + if c.Count != 1 { + t.Errorf("glob block count = %d, want 1", c.Count) + } + case c.Tool == "shell" && c.Verb == "unanalyzable" && c.Subverb == "heredoc" && c.Decision == "block": + sawHeredoc = true + if c.Count != 1 { + t.Errorf("heredoc block count = %d, want 1", c.Count) + } + case c.Tool == "git" && c.Verb == "commit" && c.Decision == "allow": + sawGitCommit = true + if c.Subverb != "" { + t.Errorf("git/commit subverb = %q, want empty", c.Subverb) + } + } + } + if !sawGlob { + t.Errorf("missing shell/unanalyzable/glob/block row; counts: %+v", richEnv.Counts) + } + if !sawHeredoc { + t.Errorf("missing shell/unanalyzable/heredoc/block row; counts: %+v", richEnv.Counts) + } + if !sawGitCommit { + t.Errorf("missing git/commit/allow row; counts: %+v", richEnv.Counts) + } +} + +// TestReport_MarkdownSubverbColumn checks that the markdown table header and rows +// include the Subverb column. +func TestReport_MarkdownSubverbColumn(t *testing.T) { + log := writeLog(t, + `{"ts":"2026-05-30T11:00:00Z","decision":"block","tool":"shell","verb":"unanalyzable","subverb":"glob","session":{"agent_session_id":"s"}}`, + `{"ts":"2026-05-30T11:01:00Z","decision":"allow","tool":"git","verb":"commit","session":{"agent_session_id":"s"}}`, + ) + var buf bytes.Buffer + if code := Report(nil, &buf, ReportOptions{Home: "/Users/you", LogPath: log, Now: fixedNow}); code != 0 { + t.Fatalf("exit=%d", code) + } + out := buf.String() + if !strings.Contains(out, "Subverb") { + t.Errorf("markdown table should contain Subverb column header; got:\n%s", out) + } + if !strings.Contains(out, "glob") { + t.Errorf("markdown table should contain 'glob' subverb value; got:\n%s", out) + } +} From f89a2e8fdcd4d5f0fa7185372f14d8637af1bcc7 Mon Sep 17 00:00:00 2001 From: Tombar Date: Wed, 10 Jun 2026 01:00:51 -0300 Subject: [PATCH 2/2] report: show blocked command + category; add 'failsafe log' to inspect decisions (#4) Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/failsafe/main.go | 8 ++ internal/subcommand/log.go | 137 ++++++++++++++++++++++++ internal/subcommand/log_test.go | 164 +++++++++++++++++++++++++++++ internal/subcommand/report.go | 42 ++++++-- internal/subcommand/report_test.go | 37 +++++++ 5 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 internal/subcommand/log.go create mode 100644 internal/subcommand/log_test.go diff --git a/cmd/failsafe/main.go b/cmd/failsafe/main.go index 75a18bb..832a8be 100644 --- a/cmd/failsafe/main.go +++ b/cmd/failsafe/main.go @@ -81,6 +81,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { Home: home, LogPath: auditlog.DefaultLogger(home, os.Getenv).Path, }) + case "log": + home := os.Getenv("HOME") + return subcommand.Log(args[2:], stdout, subcommand.LogOptions{ + Home: home, + LogPath: auditlog.DefaultLogger(home, os.Getenv).Path, + }) case "toggle": return subcommand.Toggle(stdout, subcommand.ToggleOptions{ Chain: subcommand.DefaultModeChain(), @@ -155,6 +161,8 @@ Usage: failsafe hook same; explicit failsafe report [flags] summarize the decision log [--since 7d] [--format md|json] [--share] + failsafe log [flags] inspect the raw decision log + [--tail 20] [--since DUR] [--json] failsafe --version print version failsafe --help this help diff --git a/internal/subcommand/log.go b/internal/subcommand/log.go new file mode 100644 index 0000000..1a7a0d3 --- /dev/null +++ b/internal/subcommand/log.go @@ -0,0 +1,137 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +package subcommand + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "path/filepath" + "time" + + "github.com/UndermountainCC/failsafe/internal/auditlog" +) + +// LogOptions configures the log subcommand. LogPath is injected so the log +// source is deterministic under test; Home is used only when LogPath is empty. +type LogOptions struct { + Home string + LogPath string // decisions.jsonl path; empty → resolve from Home + Now time.Time // reference clock for --since; zero → time.Now() +} + +// Log reads the decision log and prints recent decisions. It is the inspection +// companion to `report`: where report aggregates and scores, log shows raw +// entries so an operator can see exactly what was blocked and why. +// +// Flags: +// - --tail N (default 20): print the last N records. +// - --since DUR (e.g. 7d, 24h): filter to records within this window. +// - --json: emit raw JSON-Lines records instead of formatted output. +// +// Missing or empty log files are not errors — a fresh install has nothing to +// show yet. +func Log(args []string, out io.Writer, opts LogOptions) int { + fs := flag.NewFlagSet("log", flag.ContinueOnError) + fs.SetOutput(out) + tail := fs.Int("tail", 20, "print the last N records") + since := fs.String("since", "", "time window, e.g. 7d, 24h, 30m (default: no window filter)") + jsonOut := fs.Bool("json", false, "emit raw JSON-Lines records") + if err := fs.Parse(args); err != nil { + return 2 + } + + now := opts.Now + if now.IsZero() { + now = time.Now() + } + + // Determine cutoff from --since, if given. + var cutoff time.Time + if *since != "" { + window, err := parseSince(*since) + if err != nil { + fmt.Fprintln(out, err) + return 2 + } + cutoff = now.Add(-window) + } + + path := opts.LogPath + if path == "" && opts.Home != "" { + path = filepath.Join(opts.Home, ".config", "failsafe", "decisions.jsonl") + } + + // readRecords already handles missing files gracefully (returns nil, nil). + recs, err := readRecords(path, cutoff) + if err != nil { + fmt.Fprintf(out, "log: %v\n", err) + return 1 + } + + if len(recs) == 0 { + fmt.Fprintln(out, "No decisions logged yet.") + return 0 + } + + // Apply --tail: keep the last N records. + if *tail > 0 && len(recs) > *tail { + recs = recs[len(recs)-*tail:] + } + + if *jsonOut { + return renderLogJSON(out, recs) + } + return renderLogText(out, recs) +} + +// renderLogJSON emits each record as a JSON line (raw re-marshal from the +// parsed Record). This lets operators pipe to jq, grep, etc. +func renderLogJSON(out io.Writer, recs []auditlog.Record) int { + enc := json.NewEncoder(out) + for _, r := range recs { + if err := enc.Encode(r); err != nil { + fmt.Fprintf(out, "log: %v\n", err) + return 1 + } + } + return 0 +} + +// renderLogText prints a human-readable list of decisions — one per line — +// with time, decision, category, cwd, reason, and command. +func renderLogText(out io.Writer, recs []auditlog.Record) int { + for _, r := range recs { + cat := logCategory(r) + ts := r.Time.UTC().Format("2006-01-02T15:04:05Z") + line := fmt.Sprintf("%s %-14s %s", ts, r.Decision, cat) + if r.CWD != "" { + line += " [" + r.CWD + "]" + } + if r.Reason != "" { + line += " " + r.Reason + } + if r.Command != "" { + line += " `" + r.Command + "`" + } + fmt.Fprintln(out, line) + } + return 0 +} + +// logCategory builds the tool/verb/subverb category string for a record. +func logCategory(r auditlog.Record) string { + if r.Tool == "" { + return "" + } + cat := r.Tool + if r.Verb != "" { + cat += "/" + r.Verb + } + if r.Subverb != "" { + cat += "/" + r.Subverb + } + return cat +} diff --git a/internal/subcommand/log_test.go b/internal/subcommand/log_test.go new file mode 100644 index 0000000..9686d7c --- /dev/null +++ b/internal/subcommand/log_test.go @@ -0,0 +1,164 @@ +// Copyright 2026 Undermountain Coding Company +// SPDX-License-Identifier: Apache-2.0 + +package subcommand + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +// logFixture is a small JSONL fixture covering the main cases: a block with +// tool/verb/subverb + command + reason, a plain allow, and a refuse block. +var logFixture = []string{ + // block shell/unanalyzable/glob with command and reason + `{"ts":"2026-05-30T11:00:00Z","decision":"block","tool":"shell","verb":"unanalyzable","subverb":"glob","cwd":"/repo","reason":"cannot safely analyze: unquoted glob","command":"rm -rf build/*.tmp","session":{"agent_session_id":"s1"}}`, + // plain allow (kubectl get) + `{"ts":"2026-05-30T11:01:00Z","decision":"allow","tool":"kubectl","verb":"get","cwd":"/repo","session":{"agent_session_id":"s1"}}`, + // allow_override with reason and command + `{"ts":"2026-05-30T11:02:00Z","decision":"allow_override","tool":"terraform","verb":"apply","cwd":"/infra","reason":"user-approved","command":"terraform apply -auto-approve","session":{"agent_session_id":"s1"}}`, + // block without command (reason only) + `{"ts":"2026-05-30T11:03:00Z","decision":"block","tool":"kubectl","verb":"delete","cwd":"/infra","reason":"blocked in read mode","session":{"agent_session_id":"s1"}}`, +} + +func TestLog_FormattedOutput(t *testing.T) { + path := writeLog(t, logFixture...) + var buf bytes.Buffer + code := Log(nil, &buf, LogOptions{Home: "/Users/you", LogPath: path, Now: fixedNow}) + if code != 0 { + t.Fatalf("Log exit=%d, out=%s", code, buf.String()) + } + out := buf.String() + + // The shell block should show category, reason, and command. + if !strings.Contains(out, "shell/unanalyzable/glob") { + t.Errorf("output should contain category 'shell/unanalyzable/glob'; got:\n%s", out) + } + if !strings.Contains(out, "cannot safely analyze: unquoted glob") { + t.Errorf("output should contain the reason; got:\n%s", out) + } + if !strings.Contains(out, "rm -rf build/*.tmp") { + t.Errorf("output should contain the command; got:\n%s", out) + } + + // All four records should appear (no --tail reduction since 4 < default 20). + for _, want := range []string{"block", "allow", "allow_override"} { + if !strings.Contains(out, want) { + t.Errorf("output should contain decision %q; got:\n%s", want, out) + } + } +} + +func TestLog_TailLimitsOutput(t *testing.T) { + path := writeLog(t, logFixture...) + var buf bytes.Buffer + code := Log([]string{"--tail", "2"}, &buf, LogOptions{Home: "/Users/you", LogPath: path, Now: fixedNow}) + if code != 0 { + t.Fatalf("Log exit=%d, out=%s", code, buf.String()) + } + out := buf.String() + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 2 { + t.Errorf("--tail 2 should produce 2 lines, got %d:\n%s", len(lines), out) + } + // Should be the last 2 records: allow_override and the no-command block. + if !strings.Contains(out, "allow_override") { + t.Errorf("--tail 2 should include the allow_override record; got:\n%s", out) + } +} + +func TestLog_SinceFiltersWindow(t *testing.T) { + // fixedNow = 2026-05-30T12:00:00Z; --since 1h → cutoff 2026-05-30T11:00:00Z. + // All four fixture records are at 11:00–11:03, so all should be in-window. + path := writeLog(t, logFixture...) + var buf bytes.Buffer + code := Log([]string{"--since", "1h"}, &buf, LogOptions{Home: "/Users/you", LogPath: path, Now: fixedNow}) + if code != 0 { + t.Fatalf("Log exit=%d, out=%s", code, buf.String()) + } + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + if len(lines) != 4 { + t.Errorf("--since 1h should include all 4 records, got %d", len(lines)) + } +} + +func TestLog_SinceExcludesOldRecords(t *testing.T) { + // --since 30m from fixedNow means cutoff = 2026-05-30T11:30:00Z. + // All fixture records (11:00–11:03) are before the cutoff → none in window. + path := writeLog(t, logFixture...) + var buf bytes.Buffer + code := Log([]string{"--since", "30m"}, &buf, LogOptions{Home: "/Users/you", LogPath: path, Now: fixedNow}) + if code != 0 { + t.Fatalf("Log exit=%d, out=%s", code, buf.String()) + } + out := buf.String() + if !strings.Contains(out, "No decisions logged yet.") { + t.Errorf("--since 30m should produce empty result message; got:\n%s", out) + } +} + +func TestLog_JSONOutput(t *testing.T) { + path := writeLog(t, logFixture...) + var buf bytes.Buffer + code := Log([]string{"--json"}, &buf, LogOptions{Home: "/Users/you", LogPath: path, Now: fixedNow}) + if code != 0 { + t.Fatalf("Log exit=%d, out=%s", code, buf.String()) + } + out := buf.String() + + // Each line should be valid JSON. + for i, line := range strings.Split(strings.TrimSpace(out), "\n") { + if line == "" { + continue + } + var obj map[string]interface{} + if err := json.Unmarshal([]byte(line), &obj); err != nil { + t.Errorf("--json line %d is not valid JSON: %v\nline: %s", i, err, line) + } + } + + // The shell/glob block record should appear with its fields. + if !strings.Contains(out, "shell") { + t.Errorf("--json output should contain 'shell'; got:\n%s", out) + } + if !strings.Contains(out, "rm -rf build") { + t.Errorf("--json output should contain the command; got:\n%s", out) + } +} + +func TestLog_MissingLogIsFriendly(t *testing.T) { + var buf bytes.Buffer + code := Log(nil, &buf, LogOptions{Home: "/Users/you", LogPath: "/no/such/file.jsonl", Now: fixedNow}) + if code != 0 { + t.Fatalf("missing log should exit 0, got %d", code) + } + out := buf.String() + if !strings.Contains(out, "No decisions logged yet.") { + t.Errorf("missing log should print friendly message; got:\n%s", out) + } +} + +func TestLog_EmptyFileIsFriendly(t *testing.T) { + // writeLog with no records still writes a single newline; test empty-ish file. + path := writeLog(t) // no lines → produces just "\n" + var buf bytes.Buffer + code := Log(nil, &buf, LogOptions{Home: "/Users/you", LogPath: path, Now: fixedNow}) + if code != 0 { + t.Fatalf("empty log should exit 0, got %d", code) + } + out := buf.String() + if !strings.Contains(out, "No decisions logged yet.") { + t.Errorf("empty log should print friendly message; got:\n%s", out) + } +} + +func TestLog_InvalidSince(t *testing.T) { + path := writeLog(t, logFixture...) + var buf bytes.Buffer + code := Log([]string{"--since", "bogus"}, &buf, LogOptions{Home: "/Users/you", LogPath: path, Now: fixedNow}) + if code != 2 { + t.Errorf("invalid --since should exit 2, got %d", code) + } +} diff --git a/internal/subcommand/report.go b/internal/subcommand/report.go index 59e6c87..856baba 100644 --- a/internal/subcommand/report.go +++ b/internal/subcommand/report.go @@ -180,6 +180,7 @@ type scariestRow struct { Decision string `json:"decision"` Tool string `json:"tool"` Verb string `json:"verb"` + Subverb string `json:"subverb,omitempty"` CWD string `json:"cwd,omitempty"` Reason string `json:"reason,omitempty"` Command string `json:"command,omitempty"` @@ -232,8 +233,8 @@ func buildReport(since string, recs []auditlog.Record) report { } scary = append(scary, scariestRow{ Time: r.Time.UTC().Format(time.RFC3339), Decision: r.Decision, - Tool: r.Tool, Verb: r.Verb, CWD: r.CWD, Reason: r.Reason, - Command: r.Command, Score: s, + Tool: r.Tool, Verb: r.Verb, Subverb: r.Subverb, CWD: r.CWD, + Reason: r.Reason, Command: r.Command, Score: s, }) } sort.SliceStable(scary, func(i, j int) bool { @@ -277,7 +278,12 @@ func renderMarkdown(out io.Writer, rep report) int { if len(rep.Scariest) > 0 { fmt.Fprintf(out, "\n## Scariest decisions\n\n") for _, s := range rep.Scariest { - fmt.Fprintf(out, "- **%s** %s %s (score %d)%s — %s\n", s.Decision, s.Tool, s.Verb, s.Score, scariestWhere(s), scariestDetail(s)) + cat := scariestCategory(s) + if cat != "" { + fmt.Fprintf(out, "- **%s** %s (score %d)%s — %s\n", s.Decision, cat, s.Score, scariestWhere(s), scariestDetail(s)) + } else { + fmt.Fprintf(out, "- **%s** (score %d)%s — %s\n", s.Decision, s.Score, scariestWhere(s), scariestDetail(s)) + } } } return 0 @@ -293,11 +299,35 @@ func scariestWhere(s scariestRow) string { } func scariestDetail(s scariestRow) string { + detail := "" if s.Reason != "" { - return s.Reason + detail = s.Reason } if s.Command != "" { - return "`" + s.Command + "`" + if detail != "" { + detail += " — `" + s.Command + "`" + } else { + detail = "`" + s.Command + "`" + } + } + if detail == "" { + return "(no detail)" + } + return detail +} + +// scariestCategory renders the tool/verb/subverb category string for a scariest +// decision, e.g. "shell/unanalyzable/glob" or "kubectl/delete". +func scariestCategory(s scariestRow) string { + if s.Tool == "" { + return "" + } + cat := s.Tool + if s.Verb != "" { + cat += "/" + s.Verb + } + if s.Subverb != "" { + cat += "/" + s.Subverb } - return "(no detail)" + return cat } diff --git a/internal/subcommand/report_test.go b/internal/subcommand/report_test.go index 64e599c..3be9637 100644 --- a/internal/subcommand/report_test.go +++ b/internal/subcommand/report_test.go @@ -291,6 +291,43 @@ func TestReport_SubverbColumnBreaksDownRefusalKinds(t *testing.T) { } } +// TestReport_ScariestShowsCommandAndCategory verifies that a block/refuse record with +// both a Reason and a Command renders BOTH in the scariest section, along with the +// full category (tool/verb/subverb). A record with an empty Command must not produce +// a dangling "— ``" trailer. +func TestReport_ScariestShowsCommandAndCategory(t *testing.T) { + log := writeLog(t, + // Block with reason + command + subverb category. + `{"ts":"2026-05-30T11:00:00Z","decision":"block","tool":"shell","verb":"unanalyzable","subverb":"glob","cwd":"/repo","reason":"cannot safely analyze: unquoted glob","command":"rm -rf build/*.tmp","session":{"agent_session_id":"s"}}`, + // Block with reason but NO command (must not dangling dash). + `{"ts":"2026-05-30T11:01:00Z","decision":"block","tool":"kubectl","verb":"delete","cwd":"/repo","reason":"blocked in read mode","session":{"agent_session_id":"s"}}`, + ) + var buf bytes.Buffer + code := Report(nil, &buf, ReportOptions{Home: "/Users/you", LogPath: log, Now: fixedNow}) + if code != 0 { + t.Fatalf("Report exit=%d, out=%s", code, buf.String()) + } + out := buf.String() + + // The first entry must include the category, reason, and command. + if !strings.Contains(out, "shell/unanalyzable/glob") { + t.Errorf("scariest should contain full category 'shell/unanalyzable/glob'; got:\n%s", out) + } + if !strings.Contains(out, "cannot safely analyze: unquoted glob") { + t.Errorf("scariest should contain the reason; got:\n%s", out) + } + if !strings.Contains(out, "rm -rf build/*.tmp") { + t.Errorf("scariest should contain the command; got:\n%s", out) + } + + // The second entry (no command) must not produce a dangling "— `" or "— ``". + // We check by verifying that "blocked in read mode" appears without a trailing + // backtick-pair immediately after. + if strings.Contains(out, "blocked in read mode — ``") { + t.Errorf("scariest with empty command must not produce dangling '— ``'; got:\n%s", out) + } +} + // TestReport_MarkdownSubverbColumn checks that the markdown table header and rows // include the Subverb column. func TestReport_MarkdownSubverbColumn(t *testing.T) {