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    let culprit = if verdict.is_allowed() || stmt.pipeline.commands.len() <= 1 {
106        None
107    } else {
108        first_denied_label(&stmt.pipeline, covered)
109    };
110    SegmentReport {
111        text: stmt.pipeline.to_string(),
112        verdict,
113        culprit,
114    }
115}
116
117fn effective_verdict(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Verdict {
118    let base = pipeline_verdict(pipeline);
119    if base.is_allowed() {
120        return base;
121    }
122    if !pipeline.commands.is_empty() && pipeline.commands.iter().all(covered) {
123        // `SafeWrite`, the TOP of the auto-approve band — not `Inert`.
124        //
125        // A `permissions.allow` rule says the user accepts this command. It does NOT say the command
126        // is inert, and claiming so was a lie with teeth: `Inert` is the bottom of the ordering, so it
127        // cleared every threshold and a `Bash(rm:*)` rule out-ranked even `--level paranoid`. A
128        // ceiling a per-command rule can lift is not a ceiling.
129        //
130        // Granting at the band's top keeps the rule honoured wherever the band is (the default
131        // threshold IS `SafeWrite`, so ordinary use is unchanged) while letting a stricter level
132        // clamp it: `paranoid` and `reader` now refuse a covered command, which is what someone
133        // asking for a read-only plan meant. The grant widens what is allowed; it no longer escapes
134        // the ceiling the user stated.
135        return Verdict::Allowed(SafetyLevel::SafeWrite);
136    }
137    base
138}
139
140fn first_denied_label(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Option<String> {
141    pipeline
142        .commands
143        .iter()
144        .find(|c| !cmd_verdict(c).is_allowed() && !covered(c))
145        .and_then(command_label)
146}
147
148fn command_label(cmd: &Cmd) -> Option<String> {
149    match cmd {
150        Cmd::Simple(s) => simple_cmd_name(s),
151        _ => None,
152    }
153}
154
155fn simple_cmd_name(s: &SimpleCmd) -> Option<String> {
156    s.words
157        .first()
158        .map(|w| Token::from_raw(w.eval()).command_name().to_string())
159        .filter(|name| !name.is_empty())
160}
161
162/// Whether a segment establishes shell state that later segments would rely on:
163/// a directory change, an environment change, or a sourced script. Splitting
164/// such a chain into separate calls would silently lose that state.
165fn establishes_shell_state(stmt: &Stmt) -> bool {
166    stmt.pipeline.commands.iter().any(|cmd| match cmd {
167        Cmd::Simple(s) => {
168            if s.words.is_empty() && !s.env.is_empty() {
169                return true;
170            }
171            matches!(
172                simple_cmd_name(s).as_deref(),
173                Some("cd" | "pushd" | "popd" | "export" | "source" | "." | "set" | "alias" | "umask")
174            )
175        }
176        _ => false,
177    })
178}
179
180impl Explanation {
181    pub fn is_allowed(&self) -> bool {
182        self.overall.is_allowed()
183    }
184
185    fn counts(&self) -> (usize, usize) {
186        let total = self.segments.len();
187        let denied = self
188            .segments
189            .iter()
190            .filter(|s| !s.verdict.is_allowed())
191            .count();
192        (total, denied)
193    }
194
195    /// Whether this explanation is worth injecting into an agent's context
196    /// automatically. The teachable case is a *mix*: an otherwise-auto-approving
197    /// chain dragged into a manual prompt by one un-allowlisted segment. A single
198    /// denied command, or an all-denied chain, carries no chaining lesson — so we
199    /// stay quiet and let the normal approval flow handle it.
200    pub fn should_surface(&self) -> bool {
201        if !self.parsed || self.segments.len() < 2 {
202            return false;
203        }
204        let (total, denied) = self.counts();
205        denied > 0 && denied < total
206    }
207
208    /// A model- and human-readable breakdown: which segments auto-approve, which
209    /// don't, and what to actually do about it.
210    pub fn render(&self) -> String {
211        if !self.parsed {
212            return "safe-chains: could not parse this command, so it will not be auto-approved.\n"
213                .to_string();
214        }
215        if self.segments.is_empty() {
216            return "safe-chains: no command to check.\n".to_string();
217        }
218
219        let (total, denied) = self.counts();
220        let mut out = String::new();
221        out.push_str(&header(total, denied));
222        for s in &self.segments {
223            out.push_str(&render_line(s));
224        }
225        if let Some(tip) = self.guidance(total, denied) {
226            out.push_str(tip);
227            out.push('\n');
228        }
229        out
230    }
231
232    fn guidance(&self, total: usize, denied: usize) -> Option<&'static str> {
233        if denied == 0 {
234            return None;
235        }
236        // The auto-injected case is always the mixed chain (see should_surface).
237        // By the time an agent reads this, the command has gone through the
238        // normal approval flow and most likely already run — so the guidance is
239        // feedback for next time, never an instruction to re-run.
240        if total == 1 {
241            return Some(
242                "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.",
243            );
244        }
245        if denied == total {
246            return Some(
247                "This is not a block. These all need manual approval. None of them auto-approve on their own.",
248            );
249        }
250        if self.stateful {
251            return Some(
252                "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.",
253            );
254        }
255        Some(
256            "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.",
257        )
258    }
259}
260
261fn header(total: usize, denied: usize) -> String {
262    if denied == 0 {
263        if total == 1 {
264            return "safe-chains: auto-approves.\n".to_string();
265        }
266        return format!("safe-chains: all {total} segments auto-approve.\n");
267    }
268    if total == 1 {
269        return "safe-chains: this command is not on the allowlist, so it is not auto-approved:\n"
270            .to_string();
271    }
272    format!("safe-chains: not auto-approved. {denied} of {total} segments are not on the allowlist:\n")
273}
274
275/// One `✓`/`✗` line. The echoed text is command-derived, so it is neutralized first: a raw newline
276/// in it let a command forge an entire extra line carrying our own `✓` marker (see
277/// [`crate::sanitize_display`]).
278fn render_line(s: &SegmentReport) -> String {
279    let mark = if s.verdict.is_allowed() { '✓' } else { '✗' };
280    let text = crate::sanitize_display(&s.text);
281    match &s.culprit {
282        Some(culprit) if !s.verdict.is_allowed() => {
283            format!("  {mark}  {text}   ({})\n", crate::sanitize_display(culprit))
284        }
285        _ => format!("  {mark}  {text}\n"),
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn marks(input: &str) -> Vec<bool> {
294        explain(input)
295            .segments
296            .iter()
297            .map(|s| s.verdict.is_allowed())
298            .collect()
299    }
300
301    #[test]
302    fn single_safe_command_one_allowed_segment() {
303        let e = explain("ls -la");
304        assert!(e.is_allowed());
305        assert_eq!(e.segments.len(), 1);
306        assert!(e.segments[0].verdict.is_allowed());
307        assert_eq!(e.segments[0].culprit, None);
308    }
309
310    #[test]
311    fn single_unsafe_command_is_denied_without_redundant_culprit() {
312        let e = explain("rm -rf /");
313        assert!(!e.is_allowed());
314        assert_eq!(e.segments.len(), 1);
315        assert_eq!(e.segments[0].culprit, None);
316    }
317
318    #[test]
319    fn one_torpedo_marks_only_that_segment() {
320        let e = explain("git status && rm -rf / && echo done");
321        assert!(!e.is_allowed());
322        assert_eq!(marks("git status && rm -rf / && echo done"), vec![true, false, true]);
323        assert!(e.segments.iter().all(|s| s.culprit.is_none()));
324    }
325
326    #[test]
327    fn all_safe_chain_is_allowed() {
328        let e = explain("git status && ls && echo hi");
329        assert!(e.is_allowed());
330        assert_eq!(marks("git status && ls && echo hi"), vec![true, true, true]);
331    }
332
333    #[test]
334    fn semicolons_and_or_split_into_segments() {
335        assert_eq!(explain("ls; pwd; whoami").segments.len(), 3);
336        assert_eq!(explain("ls || rm -rf /").segments.len(), 2);
337    }
338
339    #[test]
340    fn culprit_is_first_denied_in_a_pipeline() {
341        let e = explain("grep foo file | rm -rf /");
342        assert!(!e.is_allowed());
343        assert_eq!(e.segments.len(), 1);
344        assert_eq!(e.segments[0].culprit.as_deref(), Some("rm"));
345    }
346
347    #[test]
348    fn segment_text_round_trips() {
349        let e = explain("git status && echo done");
350        assert_eq!(e.segments[0].text, "git status");
351        assert_eq!(e.segments[1].text, "echo done");
352    }
353
354    #[test]
355    fn unparseable_input_is_a_single_unparsed_segment() {
356        let e = explain("echo 'unterminated");
357        assert!(!e.parsed);
358        assert!(!e.is_allowed());
359    }
360
361    // ---- stateful detection ----
362
363    #[test]
364    fn cd_chain_is_marked_stateful() {
365        assert!(explain("cd build && rm -rf x").stateful);
366        assert!(explain("export FOO=bar && rm -rf x").stateful);
367        assert!(explain("FOO=bar && rm -rf x").stateful);
368        assert!(explain("source ./env && rm -rf x").stateful);
369    }
370
371    #[test]
372    fn independent_chain_is_not_stateful() {
373        assert!(!explain("git status && rm -rf x && echo done").stateful);
374        assert!(!explain("ls && pwd").stateful);
375    }
376
377    #[test]
378    fn single_segment_is_never_stateful() {
379        assert!(!explain("cd build").stateful);
380    }
381
382    // ---- should_surface (auto-injection gate) ----
383
384    #[test]
385    fn surfaces_only_the_mixed_bundling_case() {
386        assert!(explain("git status && rm -rf / && echo done").should_surface());
387        assert!(!explain("ls && pwd").should_surface(), "all-safe: nothing to teach");
388        assert!(!explain("rm -rf / && rm -rf /etc").should_surface(), "all-denied: no rescue");
389        assert!(!explain("rm -rf /").should_surface(), "single denied: no chaining lesson");
390        assert!(!explain("echo 'unterminated").should_surface(), "unparseable");
391    }
392
393    // ---- coverage overlay ----
394
395    #[test]
396    fn coverage_overlay_flips_a_user_allowed_segment() {
397        let patterns = Matcher::from_allow_patterns(&["rm *"]);
398        let e = explain_with_coverage("git status && rm -rf / && echo done", &patterns);
399        assert!(e.is_allowed(), "user allowlisted rm, so the chain auto-approves");
400        assert!(e.segments.iter().all(|s| s.verdict.is_allowed()));
401        assert!(!e.should_surface());
402    }
403
404    #[test]
405    fn coverage_overlay_leaves_uncovered_segments_denied() {
406        let patterns = Matcher::from_allow_patterns(&["rm *"]);
407        let e = explain_with_coverage("rm -rf / && cargo publish", &patterns);
408        assert!(!e.is_allowed());
409        assert_eq!(marks_cov("rm -rf / && cargo publish", &patterns), vec![true, false]);
410    }
411
412    fn marks_cov(input: &str, patterns: &Matcher) -> Vec<bool> {
413        explain_with_coverage(input, patterns)
414            .segments
415            .iter()
416            .map(|s| s.verdict.is_allowed())
417            .collect()
418    }
419
420    // ---- rendering ----
421
422    #[test]
423    fn render_mixed_chain_lists_marks_and_split_tip() {
424        let out = explain("git status && rm -rf / && echo done").render();
425        assert!(out.contains("✓  git status"));
426        assert!(out.contains("✗  rm -rf /"));
427        assert!(out.contains("✓  echo done"));
428        assert!(out.contains("1 of 3 segments"));
429        assert!(out.contains("not a block"), "must clarify it is not a block: {out}");
430        assert!(out.contains("not a request to re-run"), "must not invite a re-run: {out}");
431        assert!(out.contains("separate tool calls"));
432    }
433
434    #[test]
435    fn render_stateful_chain_says_belongs_in_one_call() {
436        let out = explain("cd build && rm -rf / && echo done").render();
437        assert!(out.contains("belong in one call"), "stateful chain must not advise splitting: {out}");
438        assert!(out.contains("not a request to re-run"));
439        assert!(!out.contains("separate tool calls"));
440    }
441
442    #[test]
443    fn render_pipeline_culprit_disambiguates_failing_stage() {
444        let out = explain("grep foo file | rm -rf /").render();
445        assert!(out.contains("(rm)"), "pipeline should name the failing stage: {out}");
446    }
447
448    #[test]
449    fn render_all_safe_has_no_tip() {
450        let out = explain("ls && pwd").render();
451        assert!(out.contains("all 2 segments auto-approve"));
452        assert!(!out.contains('✗'));
453        assert!(!out.contains("approval"));
454    }
455
456    #[test]
457    fn render_single_denied_keeps_it_alone() {
458        let out = explain("cargo publish").render();
459        assert!(out.contains("not auto-approved"));
460        assert!(out.contains("not a block"));
461        assert!(out.contains("needs manual approval"));
462    }
463
464    #[test]
465    fn render_unparseable_is_explicit() {
466        let out = explain("echo 'unterminated").render();
467        assert!(out.contains("could not parse"));
468    }
469
470    #[test]
471    fn empty_input_renders_no_command() {
472        for input in ["", "   "] {
473            let e = explain(input);
474            assert!(e.segments.is_empty(), "{input:?} should have no segments");
475            assert!(e.render().contains("no command to check"));
476        }
477    }
478}