Skip to main content

safe_chains/cst/
explain.rs

1use super::check::{cmd_verdict, pipeline_verdict};
2use super::*;
3use crate::allowlist::{Matcher, is_cmd_covered};
4use crate::parse::Token;
5use crate::verdict::{SafetyLevel, Verdict};
6
7/// A per-segment breakdown of why a command would or would not auto-approve.
8///
9/// "Segment" means a top-level list element — the pieces a user separates with
10/// `&&`, `||`, `;`, or `&`. This is the granularity that matters for the common
11/// failure mode: one un-allowlisted command torpedoing an otherwise-safe chain.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Explanation {
14    pub overall: Verdict,
15    pub segments: Vec<SegmentReport>,
16    /// False when the input could not be parsed at all.
17    pub parsed: bool,
18    /// True when segments share shell state (a `cd`, `export`, assignment, or
19    /// `source`) so that splitting them into separate calls would break them.
20    pub stateful: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SegmentReport {
25    /// The segment rendered back to source (whitespace/operators normalized).
26    pub text: String,
27    pub verdict: Verdict,
28    /// For a denied *pipeline* segment (`a | b | c`), the name of the first
29    /// stage that is not auto-approved — disambiguating which stage to drop.
30    /// `None` for a single-command segment (its text already names it) or when
31    /// the culprit isn't a plain command (e.g. a subshell or redirect target).
32    pub culprit: Option<String>,
33}
34
35/// Explain against the built-in classification only.
36pub fn explain(input: &str) -> Explanation {
37    explain_inner(input, |_| false)
38}
39
40/// Explain with the user's allowlist patterns overlaid, so a command the user
41/// has allowed isn't reported as not-auto-approved. This mirrors the hook's own
42/// coverage check (`main.rs`): a segment counts as allowed when it is built-in
43/// safe *or* every command in it is covered by the user's patterns.
44pub fn explain_with_coverage(input: &str, patterns: &Matcher) -> Explanation {
45    explain_inner(input, |cmd| is_cmd_covered(cmd, patterns))
46}
47
48fn explain_inner(input: &str, covered: impl Fn(&Cmd) -> bool) -> Explanation {
49    // ONE work budget for the whole explanation, taken the same way `command_verdict` takes it.
50    //
51    // Without this, explaining had no budget of its own: brace-expansion fan-out charged the shared
52    // counter while the per-segment classifications inside reset it whenever one bottomed out at
53    // depth 0. The result depended on how much the CALLER had already spent and on where the resets
54    // fell, so `explain` was neither order-independent (it disagreed with the verdict enforced just
55    // before it) nor deterministic (two consecutive calls on one dense input rendered different
56    // answers). Entering here resets once, at the top, and keeps every nested classification at
57    // depth >= 1, which is what makes explaining and enforcing spend from the same pool.
58    let Some(_guard) = super::check::ClassifyGuard::enter() else {
59        return Explanation {
60            overall: Verdict::Denied,
61            segments: vec![SegmentReport { text: input.trim().to_string(), verdict: Verdict::Denied, culprit: None }],
62            parsed: false,
63            stateful: false,
64        };
65    };
66    let Some(script) = parse(input) else {
67        return Explanation {
68            overall: Verdict::Denied,
69            segments: vec![SegmentReport { text: input.trim().to_string(), verdict: Verdict::Denied, culprit: None }],
70            parsed: false,
71            stateful: false,
72        };
73    };
74
75    // Walk with the SAME accumulated scope as `script_verdict` (cwd + `VAR=` bindings + function
76    // definitions), so each segment is judged in the context of the ones before it. Without this the
77    // per-segment view — and the hook's coverage fallback built on it — would re-allow a call whose
78    // definition shadows a builtin (`ls(){ rm; }; ls`) that the whole-command verdict denies.
79    let segments: Vec<SegmentReport> = super::check::walk_with_scope(&script, |stmt| segment_report(stmt, &covered));
80    let overall = segments.iter().map(|s| s.verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
81    let stateful = segments.len() >= 2 && script.0.iter().any(establishes_shell_state);
82
83    Explanation { overall, segments, parsed: true, stateful }
84}
85
86fn segment_report(stmt: &Stmt, covered: &impl Fn(&Cmd) -> bool) -> SegmentReport {
87    let verdict = effective_verdict(&stmt.pipeline, covered);
88    // A culprit is suppressed when it would only repeat the segment's own name: for a lone SIMPLE
89    // command the segment text already IS `cat ~/.ssh/id_rsa`, so labelling it `cat` says nothing.
90    //
91    // That used to be spelled `commands.len() <= 1`, which caught compounds as well — and there the
92    // label is the only actionable information there is. The segment text of a denied `for` loop is
93    // the whole loop; what the caller has to change is the command inside it, and suppressing that
94    // is how a third of the author's decision-log denials came to read "no reason recorded".
95    let redundant_with_segment_text = matches!(stmt.pipeline.commands.as_slice(), [Cmd::Simple(_)]);
96    let culprit = if verdict.is_allowed() || redundant_with_segment_text { None } else { first_denied_label(&stmt.pipeline, covered) };
97    SegmentReport { text: stmt.pipeline.to_string(), verdict, culprit }
98}
99
100fn effective_verdict(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Verdict {
101    let base = pipeline_verdict(pipeline);
102    if base.is_allowed() {
103        return base;
104    }
105    if !pipeline.commands.is_empty() && pipeline.commands.iter().all(covered) {
106        // `SafeWrite`, the TOP of the auto-approve band — not `Inert`.
107        //
108        // A `permissions.allow` rule says the user accepts this command. It does NOT say the command
109        // is inert, and claiming so was a lie with teeth: `Inert` is the bottom of the ordering, so it
110        // cleared every threshold and a `Bash(rm:*)` rule out-ranked even `--level paranoid`. A
111        // ceiling a per-command rule can lift is not a ceiling.
112        //
113        // Granting at the band's top keeps the rule honoured wherever the band is (the default
114        // threshold IS `SafeWrite`, so ordinary use is unchanged) while letting a stricter level
115        // clamp it: `paranoid` and `reader` now refuse a covered command, which is what someone
116        // asking for a read-only plan meant. The grant widens what is allowed; it no longer escapes
117        // the ceiling the user stated.
118        return Verdict::Allowed(SafetyLevel::SafeWrite);
119    }
120    base
121}
122
123fn first_denied_label(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Option<String> {
124    pipeline
125        .commands
126        .iter()
127        .find(|c| !cmd_verdict(c).is_allowed() && !covered(c))
128        .and_then(command_label)
129}
130
131/// The name to report as the culprit for a denied command.
132///
133/// A compound is not itself a command anyone can act on: the thing the caller has to change lives
134/// INSIDE it. So this descends into the body and names the first inner command that is denied on
135/// its own — `(cat ~/.ssh/id_rsa)` reports `cat`, not nothing.
136///
137/// It used to return `None` for everything but `Simple`, which is why a denied `for`/`while`/`case`
138/// left `culprit: null` and `facets: null` in the decision log — roughly a third of the denials in
139/// the author's own log read "no reason recorded". `--explain` had the same hole, and it is the
140/// worse place for it: the hook renders that text back to the agent, so a refusal with no reason is
141/// one the agent cannot act on except by guessing.
142///
143/// Every body is descended, not just the one that will run. Which `if` branch or `case` arm
144/// executes is a runtime value, so the classifier already treats such a command as only as safe as
145/// its worst body; reporting has to look in the same places or it would name nothing for exactly
146/// the constructs that were denied because of what is buried in them.
147fn command_label(cmd: &Cmd) -> Option<String> {
148    match cmd {
149        Cmd::Simple(s) => simple_cmd_name(s),
150        // A function DEFINITION is inert — its body only matters when called, and naming the body's
151        // commands here would report a culprit for a command that did nothing.
152        Cmd::FunctionDef { .. } => None,
153        Cmd::Subshell { body, .. } | Cmd::BraceGroup { body, .. } => denied_label_in(body),
154        Cmd::For { body, .. } => denied_label_in(body),
155        Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => denied_label_in(cond).or_else(|| denied_label_in(body)),
156        Cmd::If { branches, else_body, .. } => branches
157            .iter()
158            .find_map(|b| denied_label_in(&b.cond).or_else(|| denied_label_in(&b.body)))
159            .or_else(|| else_body.as_ref().and_then(denied_label_in)),
160        Cmd::Case { arms, .. } => arms.iter().find_map(|arm| denied_label_in(&arm.body)),
161        // `[[ … ]]` is a test expression, not a command that could be the culprit.
162        Cmd::DoubleBracket { .. } => None,
163    }
164}
165
166/// The WORDS of the first denied command inside a compound, for the facet breakdown.
167///
168/// `--explain`'s profile section tokenises the raw string flatly, which cannot see into a compound:
169/// `(cat ~/.ssh/id_rsa)` splits to `["(cat", "~/.ssh/id_rsa)"]`, no resolver recognises `(cat`, and
170/// the refusal renders with no reason at all. Roughly a third of the denials in the author's
171/// decision log read "no reason recorded" for this shape.
172///
173/// Returns `None` for a plain simple command, so the caller keeps its existing path and this is
174/// only consulted where that path has nothing to say.
175pub(crate) fn denied_inner_words(input: &str) -> Option<Vec<String>> {
176    let _guard = super::check::ClassifyGuard::enter()?;
177    let script = parse(input)?;
178    let [stmt] = &script.0[..] else { return None };
179    let [cmd] = &stmt.pipeline.commands[..] else { return None };
180    // A simple command is already handled by the flat path, and going through the CST for it would
181    // change what that path reports on inputs it handles correctly today.
182    if matches!(cmd, Cmd::Simple(_)) {
183        return None;
184    }
185    first_denied_simple(cmd)
186}
187
188/// The first simple command at or below `cmd` that is denied on its own.
189fn first_denied_simple(cmd: &Cmd) -> Option<Vec<String>> {
190    match cmd {
191        Cmd::Simple(s) => Some(s.words.iter().map(Word::eval).collect()),
192        Cmd::FunctionDef { .. } | Cmd::DoubleBracket { .. } => None,
193        Cmd::Subshell { body, .. } | Cmd::BraceGroup { body, .. } | Cmd::For { body, .. } => first_denied_simple_in(body),
194        Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
195            first_denied_simple_in(cond).or_else(|| first_denied_simple_in(body))
196        }
197        Cmd::If { branches, else_body, .. } => branches
198            .iter()
199            .find_map(|b| first_denied_simple_in(&b.cond).or_else(|| first_denied_simple_in(&b.body)))
200            .or_else(|| else_body.as_ref().and_then(first_denied_simple_in)),
201        Cmd::Case { arms, .. } => arms.iter().find_map(|arm| first_denied_simple_in(&arm.body)),
202    }
203}
204
205fn first_denied_simple_in(script: &Script) -> Option<Vec<String>> {
206    script
207        .0
208        .iter()
209        .find_map(|stmt| stmt.pipeline.commands.iter().find(|c| !cmd_verdict(c).is_allowed()).and_then(first_denied_simple))
210}
211
212/// The first command inside `script` that is denied on its own, by name.
213///
214/// Recurses through `command_label`, so a culprit nested several constructs deep is still found.
215/// Termination rests on the CST being finite and acyclic — a body is always a strictly smaller
216/// subtree than the command containing it — which is the same property the classifier's own walk
217/// relies on.
218fn denied_label_in(script: &Script) -> Option<String> {
219    script
220        .0
221        .iter()
222        .find_map(|stmt| stmt.pipeline.commands.iter().find(|c| !cmd_verdict(c).is_allowed()).and_then(command_label))
223}
224
225fn simple_cmd_name(s: &SimpleCmd) -> Option<String> {
226    s.words
227        .first()
228        .map(|w| Token::from_raw(w.eval()).command_name().to_string())
229        .filter(|name| !name.is_empty())
230}
231
232/// Whether a segment establishes shell state that later segments would rely on:
233/// a directory change, an environment change, or a sourced script. Splitting
234/// such a chain into separate calls would silently lose that state.
235fn establishes_shell_state(stmt: &Stmt) -> bool {
236    stmt.pipeline.commands.iter().any(|cmd| match cmd {
237        Cmd::Simple(s) => {
238            if s.words.is_empty() && !s.env.is_empty() {
239                return true;
240            }
241            matches!(simple_cmd_name(s).as_deref(), Some("cd" | "pushd" | "popd" | "export" | "source" | "." | "set" | "alias" | "umask"))
242        }
243        _ => false,
244    })
245}
246
247impl Explanation {
248    pub fn is_allowed(&self) -> bool {
249        self.overall.is_allowed()
250    }
251
252    fn counts(&self) -> (usize, usize) {
253        let total = self.segments.len();
254        let denied = self.segments.iter().filter(|s| !s.verdict.is_allowed()).count();
255        (total, denied)
256    }
257
258    /// Whether this explanation is worth injecting into an agent's context
259    /// automatically. The teachable case is a *mix*: an otherwise-auto-approving
260    /// chain dragged into a manual prompt by one un-allowlisted segment. A single
261    /// denied command, or an all-denied chain, carries no chaining lesson — so we
262    /// stay quiet and let the normal approval flow handle it.
263    pub fn should_surface(&self) -> bool {
264        if !self.parsed || self.segments.len() < 2 {
265            return false;
266        }
267        let (total, denied) = self.counts();
268        denied > 0 && denied < total
269    }
270
271    /// A model- and human-readable breakdown: which segments auto-approve, which
272    /// don't, and what to actually do about it.
273    pub fn render(&self) -> String {
274        if !self.parsed {
275            return "safe-chains: could not parse this command, so it will not be auto-approved.\n".to_string();
276        }
277        if self.segments.is_empty() {
278            return "safe-chains: no command to check.\n".to_string();
279        }
280
281        let (total, denied) = self.counts();
282        let mut out = String::new();
283        out.push_str(&header(total, denied));
284        for s in &self.segments {
285            out.push_str(&render_line(s));
286        }
287        if let Some(tip) = self.guidance(total, denied) {
288            out.push_str(tip);
289            out.push('\n');
290        }
291        out
292    }
293
294    fn guidance(&self, total: usize, denied: usize) -> Option<&'static str> {
295        if denied == 0 {
296            return None;
297        }
298        // The auto-injected case is always the mixed chain (see should_surface).
299        // By the time an agent reads this, the command has gone through the
300        // normal approval flow and most likely already run — so the guidance is
301        // feedback for next time, never an instruction to re-run.
302        if total == 1 {
303            return Some(
304                "This is not a block. It just needs manual approval. Next time send a command that needs approval on its own, not in the same call as commands that auto-approve.",
305            );
306        }
307        if denied == total {
308            return Some("This is not a block. These all need manual approval. None of them auto-approve on their own.");
309        }
310        if self.stateful {
311            return Some(
312                "This is not a block. The command has likely already run, so this is feedback and not a request to re-run it. These segments share shell state, such as a cd, a variable, or a source, so they belong in one call. Bundling them was correct. Nothing to change.",
313            );
314        }
315        Some(
316            "This is not a block. The command has likely already run, so this is feedback and not a request to re-run it. Next time send independent commands as separate tool calls instead of chaining them. The ✓ segments auto-approve on their own, so only a ✗ segment needs approval.",
317        )
318    }
319}
320
321fn header(total: usize, denied: usize) -> String {
322    if denied == 0 {
323        if total == 1 {
324            return "safe-chains: auto-approves.\n".to_string();
325        }
326        return format!("safe-chains: all {total} segments auto-approve.\n");
327    }
328    // The THIRD producer of refusal copy, and the one that kept "not on the allowlist" alive after
329    // it was removed from the others. It routes through the same builder now, with
330    // `Outcome::Unknown`: `--explain` is run against no harness, so what follows is not ours to
331    // claim. See docs/design/refusal-copy.md.
332    if total == 1 {
333        return format!("safe-chains: {}\n", crate::refusal::EXPLAIN_SINGLE);
334    }
335    format!("safe-chains: did not auto-approve {denied} of {total} segments. {}\n", crate::refusal::EXPLAIN_MANY)
336}
337
338/// One `✓`/`✗` line. The echoed text is command-derived, so it is neutralized first: a raw newline
339/// in it let a command forge an entire extra line carrying our own `✓` marker (see
340/// [`crate::sanitize_display`]).
341fn render_line(s: &SegmentReport) -> String {
342    let mark = if s.verdict.is_allowed() { '✓' } else { '✗' };
343    let text = crate::sanitize_display(&s.text);
344    match &s.culprit {
345        Some(culprit) if !s.verdict.is_allowed() => {
346            format!("  {mark}  {text}   ({})\n", crate::sanitize_display(culprit))
347        }
348        _ => format!("  {mark}  {text}\n"),
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn marks(input: &str) -> Vec<bool> {
357        explain(input).segments.iter().map(|s| s.verdict.is_allowed()).collect()
358    }
359
360    #[test]
361    fn single_safe_command_one_allowed_segment() {
362        let e = explain("ls -la");
363        assert!(e.is_allowed());
364        assert_eq!(e.segments.len(), 1);
365        assert!(e.segments[0].verdict.is_allowed());
366        assert_eq!(e.segments[0].culprit, None);
367    }
368
369    #[test]
370    fn single_unsafe_command_is_denied_without_redundant_culprit() {
371        let e = explain("rm -rf /");
372        assert!(!e.is_allowed());
373        assert_eq!(e.segments.len(), 1);
374        assert_eq!(e.segments[0].culprit, None);
375    }
376
377    #[test]
378    fn one_torpedo_marks_only_that_segment() {
379        let e = explain("git status && rm -rf / && echo done");
380        assert!(!e.is_allowed());
381        assert_eq!(marks("git status && rm -rf / && echo done"), vec![true, false, true]);
382        assert!(e.segments.iter().all(|s| s.culprit.is_none()));
383    }
384
385    #[test]
386    fn all_safe_chain_is_allowed() {
387        let e = explain("git status && ls && echo hi");
388        assert!(e.is_allowed());
389        assert_eq!(marks("git status && ls && echo hi"), vec![true, true, true]);
390    }
391
392    #[test]
393    fn semicolons_and_or_split_into_segments() {
394        assert_eq!(explain("ls; pwd; whoami").segments.len(), 3);
395        assert_eq!(explain("ls || rm -rf /").segments.len(), 2);
396    }
397
398    /// A denied COMPOUND names the command inside it, rather than nothing.
399    ///
400    /// `command_label` returned `None` for every non-simple command, so a denied `for`/`while`/
401    /// `case`/subshell left `culprit: null` in the decision log and no reason in `--explain`.
402    /// Roughly a third of the denials in the author's own log read "no reason recorded".
403    ///
404    /// The construct itself is never the actionable answer — the caller cannot change "a subshell",
405    /// only the command in it — so every body is descended, including the branches and arms that
406    /// may not run. The classifier already treats such a command as only as safe as its worst body;
407    /// reporting looks in the same places, or it names nothing for precisely the constructs whose
408    /// denial came from something buried in them.
409    #[test]
410    fn a_denied_compound_names_the_command_inside_it() {
411        for src in [
412            "(cat ~/.ssh/id_rsa)", "{ cat ~/.ssh/id_rsa; }", "if true; then cat ~/.ssh/id_rsa; fi",
413            "for f in a b; do cat ~/.ssh/id_rsa; done", "while true; do cat ~/.ssh/id_rsa; done",
414            "case $x in a) cat ~/.ssh/id_rsa ;; esac",
415        ] {
416            let ex = explain(src);
417            assert_eq!(ex.segments.len(), 1, "{src}: one segment");
418            assert!(!ex.is_allowed(), "{src}: denied");
419            assert_eq!(ex.segments[0].culprit.as_deref(), Some("cat"), "{src}: must name the command inside the construct");
420        }
421
422        // And the words reach the facet breakdown, which is what puts a REASON on the refusal.
423        assert_eq!(denied_inner_words("(cat ~/.ssh/id_rsa)"), Some(vec!["cat".to_string(), "~/.ssh/id_rsa".to_string()]),);
424        // A plain simple command keeps the existing path — this is only for what it cannot see.
425        assert_eq!(denied_inner_words("cat ~/.ssh/id_rsa"), None);
426        // A construct whose body is fine has no culprit to name.
427        assert_eq!(denied_inner_words("(ls)"), None);
428    }
429
430    #[test]
431    fn culprit_is_first_denied_in_a_pipeline() {
432        let e = explain("grep foo file | rm -rf /");
433        assert!(!e.is_allowed());
434        assert_eq!(e.segments.len(), 1);
435        assert_eq!(e.segments[0].culprit.as_deref(), Some("rm"));
436    }
437
438    #[test]
439    fn segment_text_round_trips() {
440        let e = explain("git status && echo done");
441        assert_eq!(e.segments[0].text, "git status");
442        assert_eq!(e.segments[1].text, "echo done");
443    }
444
445    #[test]
446    fn unparseable_input_is_a_single_unparsed_segment() {
447        let e = explain("echo 'unterminated");
448        assert!(!e.parsed);
449        assert!(!e.is_allowed());
450    }
451
452    // ---- stateful detection ----
453
454    #[test]
455    fn cd_chain_is_marked_stateful() {
456        assert!(explain("cd build && rm -rf x").stateful);
457        assert!(explain("export FOO=bar && rm -rf x").stateful);
458        assert!(explain("FOO=bar && rm -rf x").stateful);
459        assert!(explain("source ./env && rm -rf x").stateful);
460    }
461
462    #[test]
463    fn independent_chain_is_not_stateful() {
464        assert!(!explain("git status && rm -rf x && echo done").stateful);
465        assert!(!explain("ls && pwd").stateful);
466    }
467
468    #[test]
469    fn single_segment_is_never_stateful() {
470        assert!(!explain("cd build").stateful);
471    }
472
473    // ---- should_surface (auto-injection gate) ----
474
475    #[test]
476    fn surfaces_only_the_mixed_bundling_case() {
477        assert!(explain("git status && rm -rf / && echo done").should_surface());
478        assert!(!explain("ls && pwd").should_surface(), "all-safe: nothing to teach");
479        assert!(!explain("rm -rf / && rm -rf /etc").should_surface(), "all-denied: no rescue");
480        assert!(!explain("rm -rf /").should_surface(), "single denied: no chaining lesson");
481        assert!(!explain("echo 'unterminated").should_surface(), "unparseable");
482    }
483
484    // ---- coverage overlay ----
485
486    #[test]
487    fn coverage_overlay_flips_a_user_allowed_segment() {
488        let patterns = Matcher::from_allow_patterns(&["rm *"]);
489        let e = explain_with_coverage("git status && rm -rf / && echo done", &patterns);
490        assert!(e.is_allowed(), "user allowlisted rm, so the chain auto-approves");
491        assert!(e.segments.iter().all(|s| s.verdict.is_allowed()));
492        assert!(!e.should_surface());
493    }
494
495    #[test]
496    fn coverage_overlay_leaves_uncovered_segments_denied() {
497        let patterns = Matcher::from_allow_patterns(&["rm *"]);
498        let e = explain_with_coverage("rm -rf / && cargo publish", &patterns);
499        assert!(!e.is_allowed());
500        assert_eq!(marks_cov("rm -rf / && cargo publish", &patterns), vec![true, false]);
501    }
502
503    fn marks_cov(input: &str, patterns: &Matcher) -> Vec<bool> {
504        explain_with_coverage(input, patterns).segments.iter().map(|s| s.verdict.is_allowed()).collect()
505    }
506
507    // ---- rendering ----
508
509    #[test]
510    fn render_mixed_chain_lists_marks_and_split_tip() {
511        let out = explain("git status && rm -rf / && echo done").render();
512        assert!(out.contains("✓  git status"));
513        assert!(out.contains("✗  rm -rf /"));
514        assert!(out.contains("✓  echo done"));
515        assert!(out.contains("1 of 3 segments"));
516        assert!(out.contains("not a block"), "must clarify it is not a block: {out}");
517        assert!(out.contains("not a request to re-run"), "must not invite a re-run: {out}");
518        assert!(out.contains("separate tool calls"));
519    }
520
521    #[test]
522    fn render_stateful_chain_says_belongs_in_one_call() {
523        let out = explain("cd build && rm -rf / && echo done").render();
524        assert!(out.contains("belong in one call"), "stateful chain must not advise splitting: {out}");
525        assert!(out.contains("not a request to re-run"));
526        assert!(!out.contains("separate tool calls"));
527    }
528
529    #[test]
530    fn render_pipeline_culprit_disambiguates_failing_stage() {
531        let out = explain("grep foo file | rm -rf /").render();
532        assert!(out.contains("(rm)"), "pipeline should name the failing stage: {out}");
533    }
534
535    #[test]
536    fn render_all_safe_has_no_tip() {
537        let out = explain("ls && pwd").render();
538        assert!(out.contains("all 2 segments auto-approve"));
539        assert!(!out.contains('✗'));
540        assert!(!out.contains("approval"));
541    }
542
543    #[test]
544    fn render_single_denied_keeps_it_alone() {
545        let out = explain("cargo publish").render();
546        // The header moved to the shared builder (`refusal::EXPLAIN_SINGLE`), so this asserts the
547        // FACTS it has to carry rather than the exact sentence — the spec's own note that target
548        // tests pinning literal copy "keep passing while the real copy changes, which is worse than
549        // no test". Wording is the copy guards' job (`refusal::tests`).
550        assert!(out.contains("did not auto-approve"), "says what happened: {out}");
551        assert!(out.contains("has researched"), "says why, without rating the command: {out}");
552        assert!(out.contains("not a block"));
553        assert!(out.contains("needs manual approval"));
554    }
555
556    #[test]
557    fn render_unparseable_is_explicit() {
558        let out = explain("echo 'unterminated").render();
559        assert!(out.contains("could not parse"));
560    }
561
562    #[test]
563    fn empty_input_renders_no_command() {
564        for input in ["", "   "] {
565            let e = explain(input);
566            assert!(e.segments.is_empty(), "{input:?} should have no segments");
567            assert!(e.render().contains("no command to check"));
568        }
569    }
570}