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(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 let segments: Vec<SegmentReport> = super::check::walk_with_scope(&script, |stmt| segment_report(stmt, &covered));
80 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 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 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
146fn command_label(cmd: &Cmd) -> Option<String> {
163 match cmd {
164 Cmd::Simple(s) => simple_cmd_name(s),
165 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 Cmd::DoubleBracket { .. } => None,
178 }
179}
180
181pub(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 if matches!(cmd, Cmd::Simple(_)) {
198 return None;
199 }
200 first_denied_simple(cmd)
201}
202
203fn 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
227fn 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
247fn 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 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 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 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 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
353fn 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 #[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 assert_eq!(denied_inner_words("(cat ~/.ssh/id_rsa)"), Some(vec!["cat".to_string(), "~/.ssh/id_rsa".to_string()]),);
439 assert_eq!(denied_inner_words("cat ~/.ssh/id_rsa"), None);
441 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 #[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 #[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 #[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 #[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 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}