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