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 { 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 let overall = segments.iter().map(|s| s.verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
81 let stateful = segments.len() >= 2 && script.0.iter().any(establishes_shell_state);
82
83 Explanation { overall, segments, parsed: true, stateful }
84}
85
86fn segment_report(stmt: &Stmt, covered: &impl Fn(&Cmd) -> bool) -> SegmentReport {
87 let verdict = effective_verdict(&stmt.pipeline, covered);
88 let redundant_with_segment_text = matches!(stmt.pipeline.commands.as_slice(), [Cmd::Simple(_)]);
96 let culprit = if verdict.is_allowed() || redundant_with_segment_text { None } else { first_denied_label(&stmt.pipeline, covered) };
97 SegmentReport { text: stmt.pipeline.to_string(), verdict, culprit }
98}
99
100fn effective_verdict(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Verdict {
101 let base = pipeline_verdict(pipeline);
102 if base.is_allowed() {
103 return base;
104 }
105 if !pipeline.commands.is_empty() && pipeline.commands.iter().all(covered) {
106 return Verdict::Allowed(SafetyLevel::SafeWrite);
119 }
120 base
121}
122
123fn first_denied_label(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Option<String> {
124 pipeline
125 .commands
126 .iter()
127 .find(|c| !cmd_verdict(c).is_allowed() && !covered(c))
128 .and_then(command_label)
129}
130
131fn command_label(cmd: &Cmd) -> Option<String> {
148 match cmd {
149 Cmd::Simple(s) => simple_cmd_name(s),
150 Cmd::FunctionDef { .. } => None,
153 Cmd::Subshell { body, .. } | Cmd::BraceGroup { body, .. } => denied_label_in(body),
154 Cmd::For { body, .. } => denied_label_in(body),
155 Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => denied_label_in(cond).or_else(|| denied_label_in(body)),
156 Cmd::If { branches, else_body, .. } => branches
157 .iter()
158 .find_map(|b| denied_label_in(&b.cond).or_else(|| denied_label_in(&b.body)))
159 .or_else(|| else_body.as_ref().and_then(denied_label_in)),
160 Cmd::Case { arms, .. } => arms.iter().find_map(|arm| denied_label_in(&arm.body)),
161 Cmd::DoubleBracket { .. } => None,
163 }
164}
165
166pub(crate) fn denied_inner_words(input: &str) -> Option<Vec<String>> {
176 let _guard = super::check::ClassifyGuard::enter()?;
177 let script = parse(input)?;
178 let [stmt] = &script.0[..] else { return None };
179 let [cmd] = &stmt.pipeline.commands[..] else { return None };
180 if matches!(cmd, Cmd::Simple(_)) {
183 return None;
184 }
185 first_denied_simple(cmd)
186}
187
188fn first_denied_simple(cmd: &Cmd) -> Option<Vec<String>> {
190 match cmd {
191 Cmd::Simple(s) => Some(s.words.iter().map(Word::eval).collect()),
192 Cmd::FunctionDef { .. } | Cmd::DoubleBracket { .. } => None,
193 Cmd::Subshell { body, .. } | Cmd::BraceGroup { body, .. } | Cmd::For { body, .. } => first_denied_simple_in(body),
194 Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
195 first_denied_simple_in(cond).or_else(|| first_denied_simple_in(body))
196 }
197 Cmd::If { branches, else_body, .. } => branches
198 .iter()
199 .find_map(|b| first_denied_simple_in(&b.cond).or_else(|| first_denied_simple_in(&b.body)))
200 .or_else(|| else_body.as_ref().and_then(first_denied_simple_in)),
201 Cmd::Case { arms, .. } => arms.iter().find_map(|arm| first_denied_simple_in(&arm.body)),
202 }
203}
204
205fn first_denied_simple_in(script: &Script) -> Option<Vec<String>> {
206 script
207 .0
208 .iter()
209 .find_map(|stmt| stmt.pipeline.commands.iter().find(|c| !cmd_verdict(c).is_allowed()).and_then(first_denied_simple))
210}
211
212fn denied_label_in(script: &Script) -> Option<String> {
219 script
220 .0
221 .iter()
222 .find_map(|stmt| stmt.pipeline.commands.iter().find(|c| !cmd_verdict(c).is_allowed()).and_then(command_label))
223}
224
225fn simple_cmd_name(s: &SimpleCmd) -> Option<String> {
226 s.words
227 .first()
228 .map(|w| Token::from_raw(w.eval()).command_name().to_string())
229 .filter(|name| !name.is_empty())
230}
231
232fn establishes_shell_state(stmt: &Stmt) -> bool {
236 stmt.pipeline.commands.iter().any(|cmd| match cmd {
237 Cmd::Simple(s) => {
238 if s.words.is_empty() && !s.env.is_empty() {
239 return true;
240 }
241 matches!(simple_cmd_name(s).as_deref(), Some("cd" | "pushd" | "popd" | "export" | "source" | "." | "set" | "alias" | "umask"))
242 }
243 _ => false,
244 })
245}
246
247impl Explanation {
248 pub fn is_allowed(&self) -> bool {
249 self.overall.is_allowed()
250 }
251
252 fn counts(&self) -> (usize, usize) {
253 let total = self.segments.len();
254 let denied = self.segments.iter().filter(|s| !s.verdict.is_allowed()).count();
255 (total, denied)
256 }
257
258 pub fn should_surface(&self) -> bool {
264 if !self.parsed || self.segments.len() < 2 {
265 return false;
266 }
267 let (total, denied) = self.counts();
268 denied > 0 && denied < total
269 }
270
271 pub fn render(&self) -> String {
274 if !self.parsed {
275 return "safe-chains: could not parse this command, so it will not be auto-approved.\n".to_string();
276 }
277 if self.segments.is_empty() {
278 return "safe-chains: no command to check.\n".to_string();
279 }
280
281 let (total, denied) = self.counts();
282 let mut out = String::new();
283 out.push_str(&header(total, denied));
284 for s in &self.segments {
285 out.push_str(&render_line(s));
286 }
287 if let Some(tip) = self.guidance(total, denied) {
288 out.push_str(tip);
289 out.push('\n');
290 }
291 out
292 }
293
294 fn guidance(&self, total: usize, denied: usize) -> Option<&'static str> {
295 if denied == 0 {
296 return None;
297 }
298 if total == 1 {
303 return Some(
304 "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.",
305 );
306 }
307 if denied == total {
308 return Some("This is not a block. These all need manual approval. None of them auto-approve on their own.");
309 }
310 if self.stateful {
311 return Some(
312 "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.",
313 );
314 }
315 Some(
316 "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.",
317 )
318 }
319}
320
321fn header(total: usize, denied: usize) -> String {
322 if denied == 0 {
323 if total == 1 {
324 return "safe-chains: auto-approves.\n".to_string();
325 }
326 return format!("safe-chains: all {total} segments auto-approve.\n");
327 }
328 if total == 1 {
333 return format!("safe-chains: {}\n", crate::refusal::EXPLAIN_SINGLE);
334 }
335 format!("safe-chains: did not auto-approve {denied} of {total} segments. {}\n", crate::refusal::EXPLAIN_MANY)
336}
337
338fn render_line(s: &SegmentReport) -> String {
342 let mark = if s.verdict.is_allowed() { '✓' } else { '✗' };
343 let text = crate::sanitize_display(&s.text);
344 match &s.culprit {
345 Some(culprit) if !s.verdict.is_allowed() => {
346 format!(" {mark} {text} ({})\n", crate::sanitize_display(culprit))
347 }
348 _ => format!(" {mark} {text}\n"),
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 fn marks(input: &str) -> Vec<bool> {
357 explain(input).segments.iter().map(|s| s.verdict.is_allowed()).collect()
358 }
359
360 #[test]
361 fn single_safe_command_one_allowed_segment() {
362 let e = explain("ls -la");
363 assert!(e.is_allowed());
364 assert_eq!(e.segments.len(), 1);
365 assert!(e.segments[0].verdict.is_allowed());
366 assert_eq!(e.segments[0].culprit, None);
367 }
368
369 #[test]
370 fn single_unsafe_command_is_denied_without_redundant_culprit() {
371 let e = explain("rm -rf /");
372 assert!(!e.is_allowed());
373 assert_eq!(e.segments.len(), 1);
374 assert_eq!(e.segments[0].culprit, None);
375 }
376
377 #[test]
378 fn one_torpedo_marks_only_that_segment() {
379 let e = explain("git status && rm -rf / && echo done");
380 assert!(!e.is_allowed());
381 assert_eq!(marks("git status && rm -rf / && echo done"), vec![true, false, true]);
382 assert!(e.segments.iter().all(|s| s.culprit.is_none()));
383 }
384
385 #[test]
386 fn all_safe_chain_is_allowed() {
387 let e = explain("git status && ls && echo hi");
388 assert!(e.is_allowed());
389 assert_eq!(marks("git status && ls && echo hi"), vec![true, true, true]);
390 }
391
392 #[test]
393 fn semicolons_and_or_split_into_segments() {
394 assert_eq!(explain("ls; pwd; whoami").segments.len(), 3);
395 assert_eq!(explain("ls || rm -rf /").segments.len(), 2);
396 }
397
398 #[test]
410 fn a_denied_compound_names_the_command_inside_it() {
411 for src in [
412 "(cat ~/.ssh/id_rsa)", "{ cat ~/.ssh/id_rsa; }", "if true; then cat ~/.ssh/id_rsa; fi",
413 "for f in a b; do cat ~/.ssh/id_rsa; done", "while true; do cat ~/.ssh/id_rsa; done",
414 "case $x in a) cat ~/.ssh/id_rsa ;; esac",
415 ] {
416 let ex = explain(src);
417 assert_eq!(ex.segments.len(), 1, "{src}: one segment");
418 assert!(!ex.is_allowed(), "{src}: denied");
419 assert_eq!(ex.segments[0].culprit.as_deref(), Some("cat"), "{src}: must name the command inside the construct");
420 }
421
422 assert_eq!(denied_inner_words("(cat ~/.ssh/id_rsa)"), Some(vec!["cat".to_string(), "~/.ssh/id_rsa".to_string()]),);
424 assert_eq!(denied_inner_words("cat ~/.ssh/id_rsa"), None);
426 assert_eq!(denied_inner_words("(ls)"), None);
428 }
429
430 #[test]
431 fn culprit_is_first_denied_in_a_pipeline() {
432 let e = explain("grep foo file | rm -rf /");
433 assert!(!e.is_allowed());
434 assert_eq!(e.segments.len(), 1);
435 assert_eq!(e.segments[0].culprit.as_deref(), Some("rm"));
436 }
437
438 #[test]
439 fn segment_text_round_trips() {
440 let e = explain("git status && echo done");
441 assert_eq!(e.segments[0].text, "git status");
442 assert_eq!(e.segments[1].text, "echo done");
443 }
444
445 #[test]
446 fn unparseable_input_is_a_single_unparsed_segment() {
447 let e = explain("echo 'unterminated");
448 assert!(!e.parsed);
449 assert!(!e.is_allowed());
450 }
451
452 #[test]
455 fn cd_chain_is_marked_stateful() {
456 assert!(explain("cd build && rm -rf x").stateful);
457 assert!(explain("export FOO=bar && rm -rf x").stateful);
458 assert!(explain("FOO=bar && rm -rf x").stateful);
459 assert!(explain("source ./env && rm -rf x").stateful);
460 }
461
462 #[test]
463 fn independent_chain_is_not_stateful() {
464 assert!(!explain("git status && rm -rf x && echo done").stateful);
465 assert!(!explain("ls && pwd").stateful);
466 }
467
468 #[test]
469 fn single_segment_is_never_stateful() {
470 assert!(!explain("cd build").stateful);
471 }
472
473 #[test]
476 fn surfaces_only_the_mixed_bundling_case() {
477 assert!(explain("git status && rm -rf / && echo done").should_surface());
478 assert!(!explain("ls && pwd").should_surface(), "all-safe: nothing to teach");
479 assert!(!explain("rm -rf / && rm -rf /etc").should_surface(), "all-denied: no rescue");
480 assert!(!explain("rm -rf /").should_surface(), "single denied: no chaining lesson");
481 assert!(!explain("echo 'unterminated").should_surface(), "unparseable");
482 }
483
484 #[test]
487 fn coverage_overlay_flips_a_user_allowed_segment() {
488 let patterns = Matcher::from_allow_patterns(&["rm *"]);
489 let e = explain_with_coverage("git status && rm -rf / && echo done", &patterns);
490 assert!(e.is_allowed(), "user allowlisted rm, so the chain auto-approves");
491 assert!(e.segments.iter().all(|s| s.verdict.is_allowed()));
492 assert!(!e.should_surface());
493 }
494
495 #[test]
496 fn coverage_overlay_leaves_uncovered_segments_denied() {
497 let patterns = Matcher::from_allow_patterns(&["rm *"]);
498 let e = explain_with_coverage("rm -rf / && cargo publish", &patterns);
499 assert!(!e.is_allowed());
500 assert_eq!(marks_cov("rm -rf / && cargo publish", &patterns), vec![true, false]);
501 }
502
503 fn marks_cov(input: &str, patterns: &Matcher) -> Vec<bool> {
504 explain_with_coverage(input, patterns).segments.iter().map(|s| s.verdict.is_allowed()).collect()
505 }
506
507 #[test]
510 fn render_mixed_chain_lists_marks_and_split_tip() {
511 let out = explain("git status && rm -rf / && echo done").render();
512 assert!(out.contains("✓ git status"));
513 assert!(out.contains("✗ rm -rf /"));
514 assert!(out.contains("✓ echo done"));
515 assert!(out.contains("1 of 3 segments"));
516 assert!(out.contains("not a block"), "must clarify it is not a block: {out}");
517 assert!(out.contains("not a request to re-run"), "must not invite a re-run: {out}");
518 assert!(out.contains("separate tool calls"));
519 }
520
521 #[test]
522 fn render_stateful_chain_says_belongs_in_one_call() {
523 let out = explain("cd build && rm -rf / && echo done").render();
524 assert!(out.contains("belong in one call"), "stateful chain must not advise splitting: {out}");
525 assert!(out.contains("not a request to re-run"));
526 assert!(!out.contains("separate tool calls"));
527 }
528
529 #[test]
530 fn render_pipeline_culprit_disambiguates_failing_stage() {
531 let out = explain("grep foo file | rm -rf /").render();
532 assert!(out.contains("(rm)"), "pipeline should name the failing stage: {out}");
533 }
534
535 #[test]
536 fn render_all_safe_has_no_tip() {
537 let out = explain("ls && pwd").render();
538 assert!(out.contains("all 2 segments auto-approve"));
539 assert!(!out.contains('✗'));
540 assert!(!out.contains("approval"));
541 }
542
543 #[test]
544 fn render_single_denied_keeps_it_alone() {
545 let out = explain("cargo publish").render();
546 assert!(out.contains("did not auto-approve"), "says what happened: {out}");
551 assert!(out.contains("has researched"), "says why, without rating the command: {out}");
552 assert!(out.contains("not a block"));
553 assert!(out.contains("needs manual approval"));
554 }
555
556 #[test]
557 fn render_unparseable_is_explicit() {
558 let out = explain("echo 'unterminated").render();
559 assert!(out.contains("could not parse"));
560 }
561
562 #[test]
563 fn empty_input_renders_no_command() {
564 for input in ["", " "] {
565 let e = explain(input);
566 assert!(e.segments.is_empty(), "{input:?} should have no segments");
567 assert!(e.render().contains("no command to check"));
568 }
569 }
570}