Skip to main content

aperion_shield/explain/
render.rs

1//! Output formats for `--explain`.
2//!
3//! Three render targets, all driven off the same `ExplainReport`
4//! captured by `crate::explain::explain`:
5//!
6//!  * **text** -- terminal-friendly, fixed-width, designed to render
7//!    cleanly without ANSI in any 80-column shell.
8//!  * **markdown** -- GitHub-flavoured. Drops into a PR review comment
9//!    with collapsible details for the rule list.
10//!  * **json** -- a stable schema (NOT the engine's internal struct)
11//!    suitable for piping into other tooling.
12
13use serde::Serialize;
14use serde_json::Value;
15
16use crate::engine::{Adjustments, Evaluation, MatchInfo, Severity};
17use crate::explain::{ExplainOptions, ToolCallDescriptor};
18use crate::Decision;
19
20/// Output format selector for `--explain-format`.
21#[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/// Everything `--explain` needs to render. Built by `explain::explain`,
43/// passed by reference to the format functions. Captured by value so
44/// callers can pickle reports across format selections without
45/// rebuilding the engine state.
46#[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    /// Process exit code for a CLI invocation that's not just looking
57    /// for the output. Mirrors `--check-cmd` so users can pipe
58    /// `--explain` into the same shell scripts. Allow/Warn → 0,
59    /// Block → 1, Approval/IdentityVerification → 2.
60    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
77// ─────────────────────────────────────────────────────────────────────
78// text
79// ─────────────────────────────────────────────────────────────────────
80
81fn 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    // rules matched
97    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    // adjustments applied
116    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    // severities
131    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    // decision
148    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
160// ─────────────────────────────────────────────────────────────────────
161// markdown
162// ─────────────────────────────────────────────────────────────────────
163
164fn 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    // rules
189    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    // adjustments
210    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    // severities
225    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    // decision detail
242    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// ─────────────────────────────────────────────────────────────────────
259// json
260// ─────────────────────────────────────────────────────────────────────
261
262#[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
417// ─────────────────────────────────────────────────────────────────────
418// helpers shared across formats
419// ─────────────────────────────────────────────────────────────────────
420
421/// Order by severity desc, then points desc, then rule_id asc. Stable
422/// so the same input always renders the same output (matters for CI
423/// snapshot tests and `git diff` of cached `--explain --format json`
424/// outputs).
425fn 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
445/// Build the "human readable" adjustment lines. Order:
446///
447///  1. Anything the engine itself reported in `adjustments_applied`
448///     (these are the names of adjustments that actually changed the
449///     final severity).
450///  2. Signals that were present but did NOT alter the outcome --
451///     useful for "the burst detector was active but didn't matter
452///     here because there were no rules to bump".
453fn 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        // Either BLOCK or APPROVAL is acceptable for the bundled
591        // shieldset depending on tiering; both surface the rule_id.
592        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        // Stable schema -- assert specific keys exist.
610        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        // No rule_id line since we didn't match anything.
630        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}