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#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Explanation {
14 pub overall: Verdict,
15 pub segments: Vec<SegmentReport>,
16 pub parsed: bool,
18 pub stateful: bool,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct SegmentReport {
25 pub text: String,
27 pub verdict: Verdict,
28 pub culprit: Option<String>,
33}
34
35pub fn explain(input: &str) -> Explanation {
37 explain_inner(input, |_| false)
38}
39
40pub 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(_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 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 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 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
156fn command_label(cmd: &Cmd) -> Option<String> {
173 match cmd {
174 Cmd::Simple(s) => simple_cmd_name(s),
175 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 Cmd::DoubleBracket { .. } => None,
190 }
191}
192
193pub(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 if matches!(cmd, Cmd::Simple(_)) {
210 return None;
211 }
212 first_denied_simple(cmd)
213}
214
215fn 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
246fn 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
269fn 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 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 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 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 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
388fn 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 #[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 assert_eq!(
485 denied_inner_words("(cat ~/.ssh/id_rsa)"),
486 Some(vec!["cat".to_string(), "~/.ssh/id_rsa".to_string()]),
487 );
488 assert_eq!(denied_inner_words("cat ~/.ssh/id_rsa"), None);
490 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 #[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 #[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 #[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 #[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 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}