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