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> = script
63 .0
64 .iter()
65 .map(|stmt| segment_report(stmt, &covered))
66 .collect();
67 let overall = segments
68 .iter()
69 .map(|s| s.verdict)
70 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
71 let stateful = segments.len() >= 2 && script.0.iter().any(establishes_shell_state);
72
73 Explanation {
74 overall,
75 segments,
76 parsed: true,
77 stateful,
78 }
79}
80
81fn segment_report(stmt: &Stmt, covered: &impl Fn(&Cmd) -> bool) -> SegmentReport {
82 let verdict = effective_verdict(&stmt.pipeline, covered);
83 let culprit = if verdict.is_allowed() || stmt.pipeline.commands.len() <= 1 {
84 None
85 } else {
86 first_denied_label(&stmt.pipeline, covered)
87 };
88 SegmentReport {
89 text: stmt.pipeline.to_string(),
90 verdict,
91 culprit,
92 }
93}
94
95fn effective_verdict(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Verdict {
96 let base = pipeline_verdict(pipeline);
97 if base.is_allowed() {
98 return base;
99 }
100 if !pipeline.commands.is_empty() && pipeline.commands.iter().all(covered) {
101 return Verdict::Allowed(SafetyLevel::Inert);
102 }
103 base
104}
105
106fn first_denied_label(pipeline: &Pipeline, covered: &impl Fn(&Cmd) -> bool) -> Option<String> {
107 pipeline
108 .commands
109 .iter()
110 .find(|c| !cmd_verdict(c).is_allowed() && !covered(c))
111 .and_then(command_label)
112}
113
114fn command_label(cmd: &Cmd) -> Option<String> {
115 match cmd {
116 Cmd::Simple(s) => simple_cmd_name(s),
117 _ => None,
118 }
119}
120
121fn simple_cmd_name(s: &SimpleCmd) -> Option<String> {
122 s.words
123 .first()
124 .map(|w| Token::from_raw(w.eval()).command_name().to_string())
125 .filter(|name| !name.is_empty())
126}
127
128fn establishes_shell_state(stmt: &Stmt) -> bool {
132 stmt.pipeline.commands.iter().any(|cmd| match cmd {
133 Cmd::Simple(s) => {
134 if s.words.is_empty() && !s.env.is_empty() {
135 return true;
136 }
137 matches!(
138 simple_cmd_name(s).as_deref(),
139 Some("cd" | "pushd" | "popd" | "export" | "source" | "." | "set" | "alias" | "umask")
140 )
141 }
142 _ => false,
143 })
144}
145
146impl Explanation {
147 pub fn is_allowed(&self) -> bool {
148 self.overall.is_allowed()
149 }
150
151 fn counts(&self) -> (usize, usize) {
152 let total = self.segments.len();
153 let denied = self
154 .segments
155 .iter()
156 .filter(|s| !s.verdict.is_allowed())
157 .count();
158 (total, denied)
159 }
160
161 pub fn should_surface(&self) -> bool {
167 if !self.parsed || self.segments.len() < 2 {
168 return false;
169 }
170 let (total, denied) = self.counts();
171 denied > 0 && denied < total
172 }
173
174 pub fn render(&self) -> String {
177 if !self.parsed {
178 return "safe-chains: could not parse this command, so it will not be auto-approved.\n"
179 .to_string();
180 }
181 if self.segments.is_empty() {
182 return "safe-chains: no command to check.\n".to_string();
183 }
184
185 let (total, denied) = self.counts();
186 let mut out = String::new();
187 out.push_str(&header(total, denied));
188 for s in &self.segments {
189 out.push_str(&render_line(s));
190 }
191 if let Some(tip) = self.guidance(total, denied) {
192 out.push_str(tip);
193 out.push('\n');
194 }
195 out
196 }
197
198 fn guidance(&self, total: usize, denied: usize) -> Option<&'static str> {
199 if denied == 0 {
200 return None;
201 }
202 if total == 1 {
207 return Some(
208 "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.",
209 );
210 }
211 if denied == total {
212 return Some(
213 "This is not a block — these just need manual approval; none auto-approve on their own.",
214 );
215 }
216 if self.stateful {
217 return Some(
218 "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.",
219 );
220 }
221 Some(
222 "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.",
223 )
224 }
225}
226
227fn header(total: usize, denied: usize) -> String {
228 if denied == 0 {
229 if total == 1 {
230 return "safe-chains: auto-approves.\n".to_string();
231 }
232 return format!("safe-chains: all {total} segments auto-approve.\n");
233 }
234 if total == 1 {
235 return "safe-chains: this command is not on the allowlist, so it is not auto-approved:\n"
236 .to_string();
237 }
238 format!("safe-chains: not auto-approved — {denied} of {total} segments are not on the allowlist:\n")
239}
240
241fn render_line(s: &SegmentReport) -> String {
242 let mark = if s.verdict.is_allowed() { '✓' } else { '✗' };
243 match &s.culprit {
244 Some(culprit) if !s.verdict.is_allowed() => format!(" {mark} {} ({culprit})\n", s.text),
245 _ => format!(" {mark} {}\n", s.text),
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 fn marks(input: &str) -> Vec<bool> {
254 explain(input)
255 .segments
256 .iter()
257 .map(|s| s.verdict.is_allowed())
258 .collect()
259 }
260
261 #[test]
262 fn single_safe_command_one_allowed_segment() {
263 let e = explain("ls -la");
264 assert!(e.is_allowed());
265 assert_eq!(e.segments.len(), 1);
266 assert!(e.segments[0].verdict.is_allowed());
267 assert_eq!(e.segments[0].culprit, None);
268 }
269
270 #[test]
271 fn single_unsafe_command_is_denied_without_redundant_culprit() {
272 let e = explain("rm -rf /tmp/x");
273 assert!(!e.is_allowed());
274 assert_eq!(e.segments.len(), 1);
275 assert_eq!(e.segments[0].culprit, None);
276 }
277
278 #[test]
279 fn one_torpedo_marks_only_that_segment() {
280 let e = explain("git status && rm -rf /tmp/x && echo done");
281 assert!(!e.is_allowed());
282 assert_eq!(marks("git status && rm -rf /tmp/x && echo done"), vec![true, false, true]);
283 assert!(e.segments.iter().all(|s| s.culprit.is_none()));
284 }
285
286 #[test]
287 fn all_safe_chain_is_allowed() {
288 let e = explain("git status && ls && echo hi");
289 assert!(e.is_allowed());
290 assert_eq!(marks("git status && ls && echo hi"), vec![true, true, true]);
291 }
292
293 #[test]
294 fn semicolons_and_or_split_into_segments() {
295 assert_eq!(explain("ls; pwd; whoami").segments.len(), 3);
296 assert_eq!(explain("ls || rm -rf /").segments.len(), 2);
297 }
298
299 #[test]
300 fn culprit_is_first_denied_in_a_pipeline() {
301 let e = explain("grep foo file | rm -rf /tmp/x");
302 assert!(!e.is_allowed());
303 assert_eq!(e.segments.len(), 1);
304 assert_eq!(e.segments[0].culprit.as_deref(), Some("rm"));
305 }
306
307 #[test]
308 fn segment_text_round_trips() {
309 let e = explain("git status && echo done");
310 assert_eq!(e.segments[0].text, "git status");
311 assert_eq!(e.segments[1].text, "echo done");
312 }
313
314 #[test]
315 fn unparseable_input_is_a_single_unparsed_segment() {
316 let e = explain("echo 'unterminated");
317 assert!(!e.parsed);
318 assert!(!e.is_allowed());
319 }
320
321 #[test]
324 fn cd_chain_is_marked_stateful() {
325 assert!(explain("cd build && rm -rf x").stateful);
326 assert!(explain("export FOO=bar && rm -rf x").stateful);
327 assert!(explain("FOO=bar && rm -rf x").stateful);
328 assert!(explain("source ./env && rm -rf x").stateful);
329 }
330
331 #[test]
332 fn independent_chain_is_not_stateful() {
333 assert!(!explain("git status && rm -rf x && echo done").stateful);
334 assert!(!explain("ls && pwd").stateful);
335 }
336
337 #[test]
338 fn single_segment_is_never_stateful() {
339 assert!(!explain("cd build").stateful);
340 }
341
342 #[test]
345 fn surfaces_only_the_mixed_bundling_case() {
346 assert!(explain("git status && rm -rf x && echo done").should_surface());
347 assert!(!explain("ls && pwd").should_surface(), "all-safe: nothing to teach");
348 assert!(!explain("rm -rf a && rm -rf b").should_surface(), "all-denied: no rescue");
349 assert!(!explain("rm -rf x").should_surface(), "single denied: no chaining lesson");
350 assert!(!explain("echo 'unterminated").should_surface(), "unparseable");
351 }
352
353 #[test]
356 fn coverage_overlay_flips_a_user_allowed_segment() {
357 let patterns = Matcher::from_allow_patterns(&["rm *"]);
358 let e = explain_with_coverage("git status && rm -rf /tmp/x && echo done", &patterns);
359 assert!(e.is_allowed(), "user allowlisted rm, so the chain auto-approves");
360 assert!(e.segments.iter().all(|s| s.verdict.is_allowed()));
361 assert!(!e.should_surface());
362 }
363
364 #[test]
365 fn coverage_overlay_leaves_uncovered_segments_denied() {
366 let patterns = Matcher::from_allow_patterns(&["rm *"]);
367 let e = explain_with_coverage("rm -rf /tmp/x && cargo publish", &patterns);
368 assert!(!e.is_allowed());
369 assert_eq!(marks_cov("rm -rf /tmp/x && cargo publish", &patterns), vec![true, false]);
370 }
371
372 fn marks_cov(input: &str, patterns: &Matcher) -> Vec<bool> {
373 explain_with_coverage(input, patterns)
374 .segments
375 .iter()
376 .map(|s| s.verdict.is_allowed())
377 .collect()
378 }
379
380 #[test]
383 fn render_mixed_chain_lists_marks_and_split_tip() {
384 let out = explain("git status && rm -rf /tmp/x && echo done").render();
385 assert!(out.contains("✓ git status"));
386 assert!(out.contains("✗ rm -rf /tmp/x"));
387 assert!(out.contains("✓ echo done"));
388 assert!(out.contains("1 of 3 segments"));
389 assert!(out.contains("not a block"), "must clarify it is not a block: {out}");
390 assert!(out.contains("not a request to re-run"), "must not invite a re-run: {out}");
391 assert!(out.contains("separate tool calls"));
392 }
393
394 #[test]
395 fn render_stateful_chain_says_belongs_in_one_call() {
396 let out = explain("cd build && rm -rf x && echo done").render();
397 assert!(out.contains("belong in one call"), "stateful chain must not advise splitting: {out}");
398 assert!(out.contains("not a request to re-run"));
399 assert!(!out.contains("separate tool calls"));
400 }
401
402 #[test]
403 fn render_pipeline_culprit_disambiguates_failing_stage() {
404 let out = explain("grep foo file | rm -rf /tmp/x").render();
405 assert!(out.contains("(rm)"), "pipeline should name the failing stage: {out}");
406 }
407
408 #[test]
409 fn render_all_safe_has_no_tip() {
410 let out = explain("ls && pwd").render();
411 assert!(out.contains("all 2 segments auto-approve"));
412 assert!(!out.contains('✗'));
413 assert!(!out.contains("approval"));
414 }
415
416 #[test]
417 fn render_single_denied_keeps_it_alone() {
418 let out = explain("cargo publish").render();
419 assert!(out.contains("not auto-approved"));
420 assert!(out.contains("not a block"));
421 assert!(out.contains("needs manual approval"));
422 }
423
424 #[test]
425 fn render_unparseable_is_explicit() {
426 let out = explain("echo 'unterminated").render();
427 assert!(out.contains("could not parse"));
428 }
429
430 #[test]
431 fn empty_input_renders_no_command() {
432 for input in ["", " "] {
433 let e = explain(input);
434 assert!(e.segments.is_empty(), "{input:?} should have no segments");
435 assert!(e.render().contains("no command to check"));
436 }
437 }
438}