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    let Some(script) = parse(input) else {
50        return Explanation {
51            overall: Verdict::Denied,
52            segments: vec![SegmentReport {
53                text: input.trim().to_string(),
54                verdict: Verdict::Denied,
55                culprit: None,
56            }],
57            parsed: false,
58            stateful: false,
59        };
60    };
61
62    // Walk with the SAME accumulated scope as `script_verdict` (cwd + `VAR=` bindings + function
63    // definitions), so each segment is judged in the context of the ones before it. Without this the
64    // per-segment view — and the hook's coverage fallback built on it — would re-allow a call whose
65    // definition shadows a builtin (`ls(){ rm; }; ls`) that the whole-command verdict denies.
66    let segments: Vec<SegmentReport> =
67        super::check::walk_with_scope(&script, |stmt| segment_report(stmt, &covered));
68    let overall = segments
69        .iter()
70        .map(|s| s.verdict)
71        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
72    let stateful = segments.len() >= 2 && script.0.iter().any(establishes_shell_state);
73
74    Explanation {
75        overall,
76        segments,
77        parsed: true,
78        stateful,
79    }
80}
81
82fn segment_report(stmt: &Stmt, covered: &impl Fn(&Cmd) -> bool) -> SegmentReport {
83    let verdict = effective_verdict(&stmt.pipeline, covered);
84    let culprit = if verdict.is_allowed() || stmt.pipeline.commands.len() <= 1 {
85        None
86    } else {
87        first_denied_label(&stmt.pipeline, covered)
88    };
89    SegmentReport {
90        text: stmt.pipeline.to_string(),
91        verdict,
92        culprit,
93    }
94}
95
96fn effective_verdict(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Verdict {
97    let base = pipeline_verdict(pipeline);
98    if base.is_allowed() {
99        return base;
100    }
101    if !pipeline.commands.is_empty() && pipeline.commands.iter().all(covered) {
102        return Verdict::Allowed(SafetyLevel::Inert);
103    }
104    base
105}
106
107fn first_denied_label(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Option<String> {
108    pipeline
109        .commands
110        .iter()
111        .find(|c| !cmd_verdict(c).is_allowed() && !covered(c))
112        .and_then(command_label)
113}
114
115fn command_label(cmd: &Cmd) -> Option<String> {
116    match cmd {
117        Cmd::Simple(s) => simple_cmd_name(s),
118        _ => None,
119    }
120}
121
122fn simple_cmd_name(s: &SimpleCmd) -> Option<String> {
123    s.words
124        .first()
125        .map(|w| Token::from_raw(w.eval()).command_name().to_string())
126        .filter(|name| !name.is_empty())
127}
128
129/// Whether a segment establishes shell state that later segments would rely on:
130/// a directory change, an environment change, or a sourced script. Splitting
131/// such a chain into separate calls would silently lose that state.
132fn establishes_shell_state(stmt: &Stmt) -> bool {
133    stmt.pipeline.commands.iter().any(|cmd| match cmd {
134        Cmd::Simple(s) => {
135            if s.words.is_empty() && !s.env.is_empty() {
136                return true;
137            }
138            matches!(
139                simple_cmd_name(s).as_deref(),
140                Some("cd" | "pushd" | "popd" | "export" | "source" | "." | "set" | "alias" | "umask")
141            )
142        }
143        _ => false,
144    })
145}
146
147impl Explanation {
148    pub fn is_allowed(&self) -> bool {
149        self.overall.is_allowed()
150    }
151
152    fn counts(&self) -> (usize, usize) {
153        let total = self.segments.len();
154        let denied = self
155            .segments
156            .iter()
157            .filter(|s| !s.verdict.is_allowed())
158            .count();
159        (total, denied)
160    }
161
162    /// Whether this explanation is worth injecting into an agent's context
163    /// automatically. The teachable case is a *mix*: an otherwise-auto-approving
164    /// chain dragged into a manual prompt by one un-allowlisted segment. A single
165    /// denied command, or an all-denied chain, carries no chaining lesson — so we
166    /// stay quiet and let the normal approval flow handle it.
167    pub fn should_surface(&self) -> bool {
168        if !self.parsed || self.segments.len() < 2 {
169            return false;
170        }
171        let (total, denied) = self.counts();
172        denied > 0 && denied < total
173    }
174
175    /// A model- and human-readable breakdown: which segments auto-approve, which
176    /// don't, and what to actually do about it.
177    pub fn render(&self) -> String {
178        if !self.parsed {
179            return "safe-chains: could not parse this command, so it will not be auto-approved.\n"
180                .to_string();
181        }
182        if self.segments.is_empty() {
183            return "safe-chains: no command to check.\n".to_string();
184        }
185
186        let (total, denied) = self.counts();
187        let mut out = String::new();
188        out.push_str(&header(total, denied));
189        for s in &self.segments {
190            out.push_str(&render_line(s));
191        }
192        if let Some(tip) = self.guidance(total, denied) {
193            out.push_str(tip);
194            out.push('\n');
195        }
196        out
197    }
198
199    fn guidance(&self, total: usize, denied: usize) -> Option<&'static str> {
200        if denied == 0 {
201            return None;
202        }
203        // The auto-injected case is always the mixed chain (see should_surface).
204        // By the time an agent reads this, the command has gone through the
205        // normal approval flow and most likely already run — so the guidance is
206        // feedback for next time, never an instruction to re-run.
207        if total == 1 {
208            return Some(
209                "This is not a block — it just needs manual approval. Don't put a command that needs approval in the same call as auto-approving ones.",
210            );
211        }
212        if denied == total {
213            return Some(
214                "This is not a block — these just need manual approval; none auto-approve on their own.",
215            );
216        }
217        if self.stateful {
218            return Some(
219                "This is not a block — the command has likely already run, so this is feedback, not a request to re-run. These segments share shell state (a cd, variable, or source), so they belong in one call; bundling was correct here — nothing to change.",
220            );
221        }
222        Some(
223            "This is not a block — the command has likely already run, so this is feedback, not a request to re-run. 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.",
224        )
225    }
226}
227
228fn header(total: usize, denied: usize) -> String {
229    if denied == 0 {
230        if total == 1 {
231            return "safe-chains: auto-approves.\n".to_string();
232        }
233        return format!("safe-chains: all {total} segments auto-approve.\n");
234    }
235    if total == 1 {
236        return "safe-chains: this command is not on the allowlist, so it is not auto-approved:\n"
237            .to_string();
238    }
239    format!("safe-chains: not auto-approved — {denied} of {total} segments are not on the allowlist:\n")
240}
241
242/// One `✓`/`✗` line. The echoed text is command-derived, so it is neutralized first: a raw newline
243/// in it let a command forge an entire extra line carrying our own `✓` marker (see
244/// [`crate::sanitize_display`]).
245fn render_line(s: &SegmentReport) -> String {
246    let mark = if s.verdict.is_allowed() { '✓' } else { '✗' };
247    let text = crate::sanitize_display(&s.text);
248    match &s.culprit {
249        Some(culprit) if !s.verdict.is_allowed() => {
250            format!("  {mark}  {text}   ({})\n", crate::sanitize_display(culprit))
251        }
252        _ => format!("  {mark}  {text}\n"),
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn marks(input: &str) -> Vec<bool> {
261        explain(input)
262            .segments
263            .iter()
264            .map(|s| s.verdict.is_allowed())
265            .collect()
266    }
267
268    #[test]
269    fn single_safe_command_one_allowed_segment() {
270        let e = explain("ls -la");
271        assert!(e.is_allowed());
272        assert_eq!(e.segments.len(), 1);
273        assert!(e.segments[0].verdict.is_allowed());
274        assert_eq!(e.segments[0].culprit, None);
275    }
276
277    #[test]
278    fn single_unsafe_command_is_denied_without_redundant_culprit() {
279        let e = explain("rm -rf /");
280        assert!(!e.is_allowed());
281        assert_eq!(e.segments.len(), 1);
282        assert_eq!(e.segments[0].culprit, None);
283    }
284
285    #[test]
286    fn one_torpedo_marks_only_that_segment() {
287        let e = explain("git status && rm -rf / && echo done");
288        assert!(!e.is_allowed());
289        assert_eq!(marks("git status && rm -rf / && echo done"), vec![true, false, true]);
290        assert!(e.segments.iter().all(|s| s.culprit.is_none()));
291    }
292
293    #[test]
294    fn all_safe_chain_is_allowed() {
295        let e = explain("git status && ls && echo hi");
296        assert!(e.is_allowed());
297        assert_eq!(marks("git status && ls && echo hi"), vec![true, true, true]);
298    }
299
300    #[test]
301    fn semicolons_and_or_split_into_segments() {
302        assert_eq!(explain("ls; pwd; whoami").segments.len(), 3);
303        assert_eq!(explain("ls || rm -rf /").segments.len(), 2);
304    }
305
306    #[test]
307    fn culprit_is_first_denied_in_a_pipeline() {
308        let e = explain("grep foo file | rm -rf /");
309        assert!(!e.is_allowed());
310        assert_eq!(e.segments.len(), 1);
311        assert_eq!(e.segments[0].culprit.as_deref(), Some("rm"));
312    }
313
314    #[test]
315    fn segment_text_round_trips() {
316        let e = explain("git status && echo done");
317        assert_eq!(e.segments[0].text, "git status");
318        assert_eq!(e.segments[1].text, "echo done");
319    }
320
321    #[test]
322    fn unparseable_input_is_a_single_unparsed_segment() {
323        let e = explain("echo 'unterminated");
324        assert!(!e.parsed);
325        assert!(!e.is_allowed());
326    }
327
328    // ---- stateful detection ----
329
330    #[test]
331    fn cd_chain_is_marked_stateful() {
332        assert!(explain("cd build && rm -rf x").stateful);
333        assert!(explain("export FOO=bar && rm -rf x").stateful);
334        assert!(explain("FOO=bar && rm -rf x").stateful);
335        assert!(explain("source ./env && rm -rf x").stateful);
336    }
337
338    #[test]
339    fn independent_chain_is_not_stateful() {
340        assert!(!explain("git status && rm -rf x && echo done").stateful);
341        assert!(!explain("ls && pwd").stateful);
342    }
343
344    #[test]
345    fn single_segment_is_never_stateful() {
346        assert!(!explain("cd build").stateful);
347    }
348
349    // ---- should_surface (auto-injection gate) ----
350
351    #[test]
352    fn surfaces_only_the_mixed_bundling_case() {
353        assert!(explain("git status && rm -rf / && echo done").should_surface());
354        assert!(!explain("ls && pwd").should_surface(), "all-safe: nothing to teach");
355        assert!(!explain("rm -rf / && rm -rf /etc").should_surface(), "all-denied: no rescue");
356        assert!(!explain("rm -rf /").should_surface(), "single denied: no chaining lesson");
357        assert!(!explain("echo 'unterminated").should_surface(), "unparseable");
358    }
359
360    // ---- coverage overlay ----
361
362    #[test]
363    fn coverage_overlay_flips_a_user_allowed_segment() {
364        let patterns = Matcher::from_allow_patterns(&["rm *"]);
365        let e = explain_with_coverage("git status && rm -rf / && echo done", &patterns);
366        assert!(e.is_allowed(), "user allowlisted rm, so the chain auto-approves");
367        assert!(e.segments.iter().all(|s| s.verdict.is_allowed()));
368        assert!(!e.should_surface());
369    }
370
371    #[test]
372    fn coverage_overlay_leaves_uncovered_segments_denied() {
373        let patterns = Matcher::from_allow_patterns(&["rm *"]);
374        let e = explain_with_coverage("rm -rf / && cargo publish", &patterns);
375        assert!(!e.is_allowed());
376        assert_eq!(marks_cov("rm -rf / && cargo publish", &patterns), vec![true, false]);
377    }
378
379    fn marks_cov(input: &str, patterns: &Matcher) -> Vec<bool> {
380        explain_with_coverage(input, patterns)
381            .segments
382            .iter()
383            .map(|s| s.verdict.is_allowed())
384            .collect()
385    }
386
387    // ---- rendering ----
388
389    #[test]
390    fn render_mixed_chain_lists_marks_and_split_tip() {
391        let out = explain("git status && rm -rf / && echo done").render();
392        assert!(out.contains("✓  git status"));
393        assert!(out.contains("✗  rm -rf /"));
394        assert!(out.contains("✓  echo done"));
395        assert!(out.contains("1 of 3 segments"));
396        assert!(out.contains("not a block"), "must clarify it is not a block: {out}");
397        assert!(out.contains("not a request to re-run"), "must not invite a re-run: {out}");
398        assert!(out.contains("separate tool calls"));
399    }
400
401    #[test]
402    fn render_stateful_chain_says_belongs_in_one_call() {
403        let out = explain("cd build && rm -rf / && echo done").render();
404        assert!(out.contains("belong in one call"), "stateful chain must not advise splitting: {out}");
405        assert!(out.contains("not a request to re-run"));
406        assert!(!out.contains("separate tool calls"));
407    }
408
409    #[test]
410    fn render_pipeline_culprit_disambiguates_failing_stage() {
411        let out = explain("grep foo file | rm -rf /").render();
412        assert!(out.contains("(rm)"), "pipeline should name the failing stage: {out}");
413    }
414
415    #[test]
416    fn render_all_safe_has_no_tip() {
417        let out = explain("ls && pwd").render();
418        assert!(out.contains("all 2 segments auto-approve"));
419        assert!(!out.contains('✗'));
420        assert!(!out.contains("approval"));
421    }
422
423    #[test]
424    fn render_single_denied_keeps_it_alone() {
425        let out = explain("cargo publish").render();
426        assert!(out.contains("not auto-approved"));
427        assert!(out.contains("not a block"));
428        assert!(out.contains("needs manual approval"));
429    }
430
431    #[test]
432    fn render_unparseable_is_explicit() {
433        let out = explain("echo 'unterminated").render();
434        assert!(out.contains("could not parse"));
435    }
436
437    #[test]
438    fn empty_input_renders_no_command() {
439        for input in ["", "   "] {
440            let e = explain(input);
441            assert!(e.segments.is_empty(), "{input:?} should have no segments");
442            assert!(e.render().contains("no command to check"));
443        }
444    }
445}