1use serde::Serialize;
14use serde_json::Value;
15
16use crate::engine::{Adjustments, Evaluation, MatchInfo, Severity};
17use crate::explain::{ExplainOptions, ToolCallDescriptor};
18use crate::Decision;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ExplainFormat {
23 Text,
24 Markdown,
25 Json,
26}
27
28impl ExplainFormat {
29 pub fn parse(raw: &str) -> anyhow::Result<Self> {
30 match raw {
31 "text" | "txt" => Ok(Self::Text),
32 "markdown" | "md" => Ok(Self::Markdown),
33 "json" => Ok(Self::Json),
34 other => Err(anyhow::anyhow!(
35 "unknown --explain-format '{}' (expected text|markdown|json)",
36 other
37 )),
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
47pub struct ExplainReport {
48 pub descriptor: ToolCallDescriptor,
49 pub adjustments: Adjustments,
50 pub evaluation: Evaluation,
51 pub decision: Decision,
52 pub options: ExplainOptions,
53}
54
55impl ExplainReport {
56 pub fn exit_code(&self) -> u8 {
61 match &self.decision {
62 Decision::Allow | Decision::Warn { .. } => 0,
63 Decision::Block { .. } => 1,
64 Decision::Approval { .. } | Decision::IdentityVerification { .. } => 2,
65 }
66 }
67}
68
69pub fn render(report: &ExplainReport, format: ExplainFormat) -> String {
70 match format {
71 ExplainFormat::Text => render_text(report),
72 ExplainFormat::Markdown => render_markdown(report),
73 ExplainFormat::Json => render_json(report),
74 }
75}
76
77fn render_text(r: &ExplainReport) -> String {
82 let mut out = String::new();
83 out.push_str("shield --explain\n");
84 out.push_str("────────────────\n");
85 out.push_str(&format!("tool : {}\n", r.descriptor.tool));
86 let args_one_line = r
87 .descriptor
88 .arguments
89 .to_string()
90 .chars()
91 .take(180)
92 .collect::<String>();
93 out.push_str(&format!("call : {}\n", args_one_line));
94 out.push('\n');
95
96 out.push_str(&format!(
98 "rules matched ............................. {}\n",
99 r.evaluation.matches.len()
100 ));
101 if r.evaluation.matches.is_empty() {
102 out.push_str(" (none)\n");
103 } else {
104 for m in sorted_matches(&r.evaluation.matches) {
105 out.push_str(&format!(
106 " {:<32} {:<10} pts={}\n",
107 m.rule_id,
108 m.severity.as_str(),
109 m.points,
110 ));
111 }
112 }
113 out.push('\n');
114
115 let adj_lines = describe_adjustments_text(&r.adjustments, &r.evaluation.adjustments_applied);
117 out.push_str(&format!(
118 "adjustments applied ....................... {}\n",
119 adj_lines.len()
120 ));
121 if adj_lines.is_empty() {
122 out.push_str(" (none)\n");
123 } else {
124 for line in &adj_lines {
125 out.push_str(&format!(" {}\n", line));
126 }
127 }
128 out.push('\n');
129
130 out.push_str("severities\n");
132 out.push_str(&format!(
133 " raw : {}\n",
134 r.evaluation.raw_severity.as_str()
135 ));
136 out.push_str(&format!(
137 " composite : {} (composite_points={})\n",
138 r.evaluation.composite_severity.as_str(),
139 r.evaluation.composite_points
140 ));
141 out.push_str(&format!(
142 " final : {}\n",
143 r.evaluation.final_severity.as_str()
144 ));
145 out.push('\n');
146
147 let (label, detail) = describe_decision(&r.decision);
149 out.push_str(&format!(
150 "decision .................................. {}\n",
151 label
152 ));
153 for (k, v) in detail {
154 out.push_str(&format!(" {:<8} : {}\n", k, v));
155 }
156
157 out
158}
159
160fn render_markdown(r: &ExplainReport) -> String {
165 let mut out = String::new();
166 out.push_str("### `aperion-shield --explain`\n\n");
167 out.push_str(&format!(
168 "| field | value |\n|---|---|\n| tool | `{}` |\n",
169 r.descriptor.tool
170 ));
171 let args_one_line = r
172 .descriptor
173 .arguments
174 .to_string()
175 .chars()
176 .take(120)
177 .collect::<String>();
178 out.push_str(&format!("| call | `{}` |\n", md_escape_table(&args_one_line)));
179 out.push_str(&format!(
180 "| decision | **{}** |\n",
181 describe_decision(&r.decision).0
182 ));
183 out.push_str(&format!(
184 "| final severity | `{}` |\n\n",
185 r.evaluation.final_severity.as_str()
186 ));
187
188 out.push_str(&format!(
190 "**Rules matched ({}):**\n\n",
191 r.evaluation.matches.len()
192 ));
193 if r.evaluation.matches.is_empty() {
194 out.push_str("_(none)_\n\n");
195 } else {
196 out.push_str("| rule | severity | points | reason |\n|---|---|---|---|\n");
197 for m in sorted_matches(&r.evaluation.matches) {
198 out.push_str(&format!(
199 "| `{}` | `{}` | {} | {} |\n",
200 m.rule_id,
201 m.severity.as_str(),
202 m.points,
203 md_escape_table(&m.reason),
204 ));
205 }
206 out.push('\n');
207 }
208
209 let adj_lines = describe_adjustments_text(&r.adjustments, &r.evaluation.adjustments_applied);
211 out.push_str(&format!(
212 "**Adjustments applied ({}):**\n\n",
213 adj_lines.len()
214 ));
215 if adj_lines.is_empty() {
216 out.push_str("_(none)_\n\n");
217 } else {
218 for line in &adj_lines {
219 out.push_str(&format!("- {}\n", line));
220 }
221 out.push('\n');
222 }
223
224 out.push_str("**Severities:**\n\n");
226 out.push_str("| stage | severity |\n|---|---|\n");
227 out.push_str(&format!(
228 "| raw | `{}` |\n",
229 r.evaluation.raw_severity.as_str()
230 ));
231 out.push_str(&format!(
232 "| composite (points={}) | `{}` |\n",
233 r.evaluation.composite_points,
234 r.evaluation.composite_severity.as_str()
235 ));
236 out.push_str(&format!(
237 "| final | `{}` |\n\n",
238 r.evaluation.final_severity.as_str()
239 ));
240
241 let (_, detail) = describe_decision(&r.decision);
243 if !detail.is_empty() {
244 out.push_str("**Decision detail:**\n\n");
245 for (k, v) in detail {
246 out.push_str(&format!("- **{}**: {}\n", k, v));
247 }
248 out.push('\n');
249 }
250
251 out
252}
253
254fn md_escape_table(s: &str) -> String {
255 s.replace('|', "\\|").replace('\n', " ")
256}
257
258#[derive(Debug, Serialize)]
263pub struct ExplainJson<'a> {
264 pub tool: &'a str,
265 pub arguments: &'a Value,
266 pub rules_matched: Vec<RuleMatchJson>,
267 pub adjustments_applied: Vec<&'static str>,
268 pub adjustment_signals: AdjustmentSignalsJson,
269 pub severity_raw: &'static str,
270 pub severity_composite: &'static str,
271 pub severity_final: &'static str,
272 pub composite_points: u32,
273 pub decision: DecisionJson,
274}
275
276#[derive(Debug, Serialize)]
277pub struct RuleMatchJson {
278 pub rule_id: String,
279 pub severity: &'static str,
280 pub points: u32,
281 pub reason: String,
282 pub safer_alternative: Option<String>,
283}
284
285#[derive(Debug, Serialize)]
286pub struct AdjustmentSignalsJson {
287 pub workspace_is_prod: bool,
288 pub burst_in_progress: bool,
289 pub fingerprint_repeatedly_approved: bool,
290 pub fingerprint_recently_denied: bool,
291}
292
293#[derive(Debug, Serialize)]
294#[serde(tag = "kind")]
295pub enum DecisionJson {
296 #[serde(rename = "allow")]
297 Allow,
298 #[serde(rename = "warn")]
299 Warn {
300 rule_id: String,
301 severity: &'static str,
302 safer_alternative: Option<String>,
303 },
304 #[serde(rename = "block")]
305 Block {
306 rule_id: String,
307 severity: &'static str,
308 reason: String,
309 safer_alternative: Option<String>,
310 contributing_rules: Vec<String>,
311 },
312 #[serde(rename = "approval")]
313 Approval {
314 rule_id: String,
315 severity: &'static str,
316 reason: String,
317 safer_alternative: Option<String>,
318 contributing_rules: Vec<String>,
319 },
320 #[serde(rename = "identity-verification")]
321 IdentityVerification {
322 rule_id: String,
323 severity: &'static str,
324 reason: String,
325 safer_alternative: Option<String>,
326 },
327}
328
329impl DecisionJson {
330 fn from(decision: &Decision) -> Self {
331 match decision {
332 Decision::Allow => Self::Allow,
333 Decision::Warn {
334 rule_id,
335 severity,
336 safer_alternative,
337 ..
338 } => Self::Warn {
339 rule_id: rule_id.clone(),
340 severity: severity.as_str(),
341 safer_alternative: safer_alternative.clone(),
342 },
343 Decision::Block {
344 rule_id,
345 severity,
346 reason,
347 safer_alternative,
348 contributing_rules,
349 } => Self::Block {
350 rule_id: rule_id.clone(),
351 severity: severity.as_str(),
352 reason: reason.clone(),
353 safer_alternative: safer_alternative.clone(),
354 contributing_rules: contributing_rules.clone(),
355 },
356 Decision::Approval {
357 rule_id,
358 severity,
359 reason,
360 safer_alternative,
361 contributing_rules,
362 } => Self::Approval {
363 rule_id: rule_id.clone(),
364 severity: severity.as_str(),
365 reason: reason.clone(),
366 safer_alternative: safer_alternative.clone(),
367 contributing_rules: contributing_rules.clone(),
368 },
369 Decision::IdentityVerification {
370 rule_id,
371 severity,
372 reason,
373 safer_alternative,
374 ..
375 } => Self::IdentityVerification {
376 rule_id: rule_id.clone(),
377 severity: severity.as_str(),
378 reason: reason.clone(),
379 safer_alternative: safer_alternative.clone(),
380 },
381 }
382 }
383}
384
385fn render_json(r: &ExplainReport) -> String {
386 let matches = sorted_matches(&r.evaluation.matches);
387 let rules_matched = matches
388 .into_iter()
389 .map(|m| RuleMatchJson {
390 rule_id: m.rule_id,
391 severity: m.severity.as_str(),
392 points: m.points,
393 reason: m.reason,
394 safer_alternative: m.safer_alternative,
395 })
396 .collect();
397 let report = ExplainJson {
398 tool: &r.descriptor.tool,
399 arguments: &r.descriptor.arguments,
400 rules_matched,
401 adjustments_applied: r.evaluation.adjustments_applied.clone(),
402 adjustment_signals: AdjustmentSignalsJson {
403 workspace_is_prod: r.adjustments.workspace_is_prod,
404 burst_in_progress: r.adjustments.burst_in_progress,
405 fingerprint_repeatedly_approved: r.adjustments.fingerprint_repeatedly_approved,
406 fingerprint_recently_denied: r.adjustments.fingerprint_recently_denied,
407 },
408 severity_raw: r.evaluation.raw_severity.as_str(),
409 severity_composite: r.evaluation.composite_severity.as_str(),
410 severity_final: r.evaluation.final_severity.as_str(),
411 composite_points: r.evaluation.composite_points,
412 decision: DecisionJson::from(&r.decision),
413 };
414 serde_json::to_string_pretty(&report).expect("ExplainJson must serialise")
415}
416
417fn sorted_matches(matches: &[MatchInfo]) -> Vec<MatchInfo> {
426 let mut out: Vec<MatchInfo> = matches.to_vec();
427 out.sort_by(|a, b| {
428 sev_rank(b.severity)
429 .cmp(&sev_rank(a.severity))
430 .then(b.points.cmp(&a.points))
431 .then(a.rule_id.cmp(&b.rule_id))
432 });
433 out
434}
435
436fn sev_rank(s: Severity) -> u8 {
437 match s {
438 Severity::Critical => 4,
439 Severity::High => 3,
440 Severity::Medium => 2,
441 Severity::Low => 1,
442 }
443}
444
445fn describe_adjustments_text(
454 adj: &Adjustments,
455 applied: &[&'static str],
456) -> Vec<String> {
457 let mut out = Vec::new();
458 for name in applied {
459 out.push(format!("APPLIED {}", name));
460 }
461
462 let present_but_unused = |label: &str, applied_name: &str, value: bool| -> Option<String> {
463 if value && !applied.iter().any(|a| *a == applied_name) {
464 Some(format!("present {} (no rule was eligible)", label))
465 } else {
466 None
467 }
468 };
469
470 if let Some(s) = present_but_unused("workspace_is_prod", "workspace_is_prod", adj.workspace_is_prod) {
471 out.push(s);
472 }
473 if let Some(s) = present_but_unused("burst_in_progress", "burst_in_progress", adj.burst_in_progress) {
474 out.push(s);
475 }
476 if let Some(s) = present_but_unused(
477 "fingerprint_recently_denied",
478 "fingerprint_recently_denied",
479 adj.fingerprint_recently_denied,
480 ) {
481 out.push(s);
482 }
483 if let Some(s) = present_but_unused(
484 "fingerprint_repeatedly_approved",
485 "fingerprint_repeatedly_approved",
486 adj.fingerprint_repeatedly_approved,
487 ) {
488 out.push(s);
489 }
490
491 out
492}
493
494fn describe_decision(d: &Decision) -> (&'static str, Vec<(&'static str, String)>) {
495 match d {
496 Decision::Allow => ("ALLOW", vec![]),
497 Decision::Warn {
498 rule_id,
499 severity,
500 safer_alternative,
501 ..
502 } => (
503 "WARN",
504 vec![
505 ("rule_id", rule_id.clone()),
506 ("severity", severity.as_str().to_string()),
507 ("suggest", safer_alternative.clone().unwrap_or_default()),
508 ],
509 ),
510 Decision::Block {
511 rule_id,
512 severity,
513 reason,
514 safer_alternative,
515 ..
516 } => (
517 "BLOCK",
518 vec![
519 ("rule_id", rule_id.clone()),
520 ("severity", severity.as_str().to_string()),
521 ("reason", reason.clone()),
522 ("suggest", safer_alternative.clone().unwrap_or_default()),
523 ],
524 ),
525 Decision::Approval {
526 rule_id,
527 severity,
528 reason,
529 safer_alternative,
530 ..
531 } => (
532 "APPROVAL",
533 vec![
534 ("rule_id", rule_id.clone()),
535 ("severity", severity.as_str().to_string()),
536 ("reason", reason.clone()),
537 ("suggest", safer_alternative.clone().unwrap_or_default()),
538 ],
539 ),
540 Decision::IdentityVerification {
541 rule_id,
542 severity,
543 reason,
544 safer_alternative,
545 ..
546 } => (
547 "IDENTITY-VERIFICATION",
548 vec![
549 ("rule_id", rule_id.clone()),
550 ("severity", severity.as_str().to_string()),
551 ("reason", reason.clone()),
552 ("suggest", safer_alternative.clone().unwrap_or_default()),
553 ],
554 ),
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use crate::explain::{explain, ExplainOptions, ToolCallDescriptor};
562 use crate::Engine;
563 use serde_json::json;
564
565 fn run_with(payload: serde_json::Value) -> ExplainReport {
566 let engine = Engine::builtin_default();
567 let d = ToolCallDescriptor::from_json(payload).unwrap();
568 explain(&engine, &d, &ExplainOptions::default()).unwrap()
569 }
570
571 #[test]
572 fn format_parse_accepts_aliases() {
573 assert_eq!(ExplainFormat::parse("text").unwrap(), ExplainFormat::Text);
574 assert_eq!(ExplainFormat::parse("txt").unwrap(), ExplainFormat::Text);
575 assert_eq!(ExplainFormat::parse("md").unwrap(), ExplainFormat::Markdown);
576 assert_eq!(
577 ExplainFormat::parse("markdown").unwrap(),
578 ExplainFormat::Markdown
579 );
580 assert_eq!(ExplainFormat::parse("json").unwrap(), ExplainFormat::Json);
581 assert!(ExplainFormat::parse("yaml").is_err());
582 }
583
584 #[test]
585 fn text_output_includes_decision_and_rule_for_block() {
586 let report = run_with(json!({"name": "shell", "arguments": {"command": "rm -rf /"}}));
587 let text = render(&report, ExplainFormat::Text);
588 assert!(text.contains("shield --explain"));
589 assert!(text.contains("decision"));
590 assert!(text.contains("fs.recursive_delete_root"), "got: {}", text);
593 }
594
595 #[test]
596 fn markdown_output_renders_a_table_per_section() {
597 let report = run_with(json!({"name": "shell", "arguments": {"command": "rm -rf /"}}));
598 let md = render(&report, ExplainFormat::Markdown);
599 assert!(md.starts_with("### `aperion-shield --explain`"));
600 assert!(md.contains("**Rules matched"));
601 assert!(md.contains("**Severities:**"));
602 }
603
604 #[test]
605 fn json_output_is_a_stable_schema() {
606 let report = run_with(json!({"name": "shell", "arguments": {"command": "rm -rf /"}}));
607 let s = render(&report, ExplainFormat::Json);
608 let v: Value = serde_json::from_str(&s).expect("json output must parse");
609 assert!(v.get("tool").is_some());
611 assert!(v.get("rules_matched").is_some());
612 assert!(v.get("decision").is_some());
613 assert!(v.get("adjustment_signals").is_some());
614 assert!(v.get("severity_final").is_some());
615 let dec_kind = v["decision"]["kind"].as_str().unwrap_or("");
616 assert!(
617 matches!(dec_kind, "block" | "approval"),
618 "got dec kind: {} full json: {}",
619 dec_kind,
620 s
621 );
622 }
623
624 #[test]
625 fn allow_decision_has_no_detail_block_in_text() {
626 let report = run_with(json!({"name": "shell", "arguments": {"command": "echo hi"}}));
627 let text = render(&report, ExplainFormat::Text);
628 assert!(text.contains("ALLOW"));
629 assert!(!text.contains("rule_id"));
631 }
632
633 #[test]
634 fn adjustment_signals_present_but_unused_show_up_in_text() {
635 let engine = Engine::builtin_default();
636 let d = ToolCallDescriptor::from_json(
637 json!({"name": "shell", "arguments": {"command": "echo hi"}}),
638 )
639 .unwrap();
640 let mut opts = ExplainOptions::default();
641 opts.force_burst = Some(true);
642 let report = explain(&engine, &d, &opts).unwrap();
643 let text = render(&report, ExplainFormat::Text);
644 assert!(
645 text.contains("burst_in_progress"),
646 "burst signal should appear in adjustments section; got:\n{}",
647 text
648 );
649 }
650
651 #[test]
652 fn exit_code_mirrors_check_cmd_policy() {
653 let allow = ExplainReport {
654 descriptor: ToolCallDescriptor::from_json(json!({"name": "x"})).unwrap(),
655 adjustments: Adjustments::default(),
656 evaluation: Evaluation {
657 matches: vec![],
658 composite_points: 0,
659 raw_severity: Severity::Low,
660 composite_severity: Severity::Low,
661 final_severity: Severity::Low,
662 adjustments_applied: vec![],
663 },
664 decision: Decision::Allow,
665 options: ExplainOptions::default(),
666 };
667 assert_eq!(allow.exit_code(), 0);
668 }
669}