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 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 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
162fn 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 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 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 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
275fn 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 #[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 #[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 #[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 #[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}