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(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 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
129fn 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 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 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 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
242fn 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 #[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 #[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 #[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 #[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}