Skip to main content

aperion_shield/explain/
mod.rs

1//! `aperion-shield --explain` -- turn any tool-call descriptor into
2//! a readable walkthrough of what the engine would decide, and why.
3//!
4//! ## Why this exists
5//!
6//! Shield's adaptive scoring (raw severity, composite points,
7//! workspace probe, decision memory, burst detector) is a strength of
8//! the product and a major source of operator confusion. When a call
9//! gets gated, the user wants three things:
10//!
11//!  1. Which rule(s) tripped?
12//!  2. What signals were applied on top? (And in what direction?)
13//!  3. What's the safer alternative they should use instead?
14//!
15//! `--explain` answers all three in one shot, from a JSON descriptor
16//! the user can copy out of a CI log, a Cursor exchange, or the
17//! `--shadow` audit stream.
18//!
19//! ## CLI shape
20//!
21//! ```text
22//! aperion-shield --explain --input call.json            # read from file
23//! cat call.json | aperion-shield --explain --input -    # read from stdin
24//! aperion-shield --explain --input - <<EOF              # heredoc-friendly
25//! {"name": "shell", "arguments": {"command": "rm -rf /"}}
26//! EOF
27//! ```
28//!
29//! Output is text by default; `--explain-format markdown` gives a
30//! GitHub-flavoured markdown block that drops cleanly into a PR
31//! review comment; `--explain-format json` gives a stable schema
32//! suitable for piping into other tooling.
33//!
34//! ## Output structure (text/markdown)
35//!
36//! ```text
37//!   shield --explain
38//!   ────────────────
39//!   tool   : shell
40//!   call   : {"command": "rm -rf /"}
41//!
42//!   rules matched ............................. 1
43//!     fs.recursive_delete_root   Critical  pts=8
44//!
45//!   adjustments applied ....................... 0
46//!     (none)
47//!
48//!   severities
49//!     raw       : Critical
50//!     composite : Critical
51//!     final     : Critical
52//!
53//!   decision .................................. BLOCK
54//!     rule_id  : fs.recursive_delete_root
55//!     reason   : rm -rf on filesystem root is forbidden.
56//!     suggest  : Scope to a specific subdirectory, e.g. `rm -rf ./build/`.
57//! ```
58//!
59//! The JSON output is a stable, machine-readable schema --
60//! intentionally NOT just the engine's internal `Evaluation` struct.
61//! See `render::ExplainJson` for the schema.
62
63pub mod render;
64
65use anyhow::{anyhow, Context, Result};
66use serde_json::Value;
67use std::path::PathBuf;
68
69use crate::engine::Engine;
70use crate::{decide, Adjustments, BurstDetector, WorkspaceContext};
71
72/// Options that shape an explain run beyond just the input descriptor.
73/// Defaults mirror the engine's behaviour on a vanilla `tools/call`
74/// in a non-prod workspace with no burst and no recent denies.
75#[derive(Debug, Clone, Default)]
76pub struct ExplainOptions {
77    /// If Some, probe this directory for prod-ness instead of relying
78    /// on the workspace defaults baked into the policy.
79    pub workspace_root: Option<PathBuf>,
80    /// Override workspace prod inference (useful for "what if this
81    /// was a prod call?" scenarios). Wins over the probe.
82    pub force_workspace_prod: Option<bool>,
83    /// Pretend the burst detector says we're in a burst. Useful for
84    /// reproducing decisions captured during a high-traffic window.
85    pub force_burst: Option<bool>,
86    /// Pretend the same fingerprint has been repeatedly approved.
87    /// Drives the decision-memory demotion path.
88    pub force_repeatedly_approved: bool,
89    /// Pretend the same fingerprint had a recent deny. Drives the
90    /// decision-memory escalation path.
91    pub force_recently_denied: bool,
92}
93
94/// Parsed shape of the input descriptor. We only require `name` and
95/// `arguments` -- additional fields are ignored. This matches both
96/// the MCP `tools/call` payload and the canonical JSON the shims
97/// produce for `shell` calls.
98#[derive(Debug, Clone)]
99pub struct ToolCallDescriptor {
100    pub tool: String,
101    pub arguments: Value,
102    /// Pretty-printed `arguments` for the banner. Cached because we
103    /// render in multiple places.
104    pub arguments_pretty: String,
105}
106
107impl ToolCallDescriptor {
108    /// Parse the descriptor from a JSON value. Accepts either of:
109    ///
110    ///  * `{"name": "shell", "arguments": {"command": "..."}}`  (MCP)
111    ///  * `{"tool": "shell", "params": {"command": "..."}}`     (legacy)
112    pub fn from_json(v: Value) -> Result<Self> {
113        let tool = v
114            .get("name")
115            .or_else(|| v.get("tool"))
116            .and_then(|x| x.as_str())
117            .ok_or_else(|| {
118                anyhow!("input descriptor must have a `name` (or `tool`) string field")
119            })?
120            .to_string();
121        let arguments = v
122            .get("arguments")
123            .or_else(|| v.get("params"))
124            .cloned()
125            .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
126        let arguments_pretty = serde_json::to_string_pretty(&arguments)
127            .unwrap_or_else(|_| "{}".to_string());
128        Ok(Self {
129            tool,
130            arguments,
131            arguments_pretty,
132        })
133    }
134}
135
136/// Run an engine evaluation explicitly preserving the trace, without
137/// invoking any of the side-effects the real proxy path has
138/// (decision-memory writes, audit-sink fan-out, identity gate checks).
139pub fn explain(
140    engine: &Engine,
141    descriptor: &ToolCallDescriptor,
142    opts: &ExplainOptions,
143) -> Result<render::ExplainReport> {
144    // Build the canonical MCP-style call: `{"name":..., "arguments":...}`.
145    // The engine's matcher inspects the raw params, not the wrapped form.
146    let canonical = serde_json::json!({
147        "name": descriptor.tool,
148        "arguments": descriptor.arguments,
149    });
150
151    let adj = build_adjustments(engine, opts)?;
152
153    let eval = engine.evaluate(&descriptor.tool, &canonical, adj.clone());
154    let decision = decide(&eval);
155
156    Ok(render::ExplainReport {
157        descriptor: descriptor.clone(),
158        adjustments: adj,
159        evaluation: eval,
160        decision,
161        options: opts.clone(),
162    })
163}
164
165fn build_adjustments(engine: &Engine, opts: &ExplainOptions) -> Result<Adjustments> {
166    let workspace_is_prod = match opts.force_workspace_prod {
167        Some(b) => b,
168        None => {
169            let probe_root = opts
170                .workspace_root
171                .clone()
172                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
173            WorkspaceContext::probe_at(&engine.policy, &probe_root).is_prod
174        }
175    };
176
177    let burst_in_progress = match opts.force_burst {
178        Some(b) => b,
179        None => {
180            // Fresh BurstDetector with no events is never in a burst,
181            // so the only way to surface burst-driven behaviour in
182            // --explain is via the force flag. That's intentional --
183            // we don't want explain to mutate the user's real burst
184            // state.
185            BurstDetector::new(engine.policy.burst_detector.clone()).in_burst()
186        }
187    };
188
189    Ok(Adjustments {
190        workspace_is_prod,
191        burst_in_progress,
192        fingerprint_repeatedly_approved: opts.force_repeatedly_approved,
193        fingerprint_recently_denied: opts.force_recently_denied,
194    })
195}
196
197/// Helper for the CLI: read the JSON descriptor from the path given
198/// to `--input`. Path `-` reads from stdin.
199pub fn read_descriptor_from(path: &str) -> Result<ToolCallDescriptor> {
200    let raw = if path == "-" {
201        use std::io::Read;
202        let mut buf = String::new();
203        std::io::stdin()
204            .read_to_string(&mut buf)
205            .context("couldn't read stdin")?;
206        buf
207    } else {
208        std::fs::read_to_string(path)
209            .with_context(|| format!("couldn't read --input {}", path))?
210    };
211    let v: Value = serde_json::from_str(&raw)
212        .with_context(|| format!("couldn't parse --input {} as JSON", path))?;
213    ToolCallDescriptor::from_json(v)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::json;
220
221    #[test]
222    fn descriptor_parses_mcp_style_shape() {
223        let v = json!({"name": "shell", "arguments": {"command": "ls"}});
224        let d = ToolCallDescriptor::from_json(v).unwrap();
225        assert_eq!(d.tool, "shell");
226        assert_eq!(d.arguments, json!({"command": "ls"}));
227    }
228
229    #[test]
230    fn descriptor_parses_legacy_tool_params_shape() {
231        let v = json!({"tool": "execute_sql", "params": {"query": "SELECT 1"}});
232        let d = ToolCallDescriptor::from_json(v).unwrap();
233        assert_eq!(d.tool, "execute_sql");
234        assert_eq!(d.arguments, json!({"query": "SELECT 1"}));
235    }
236
237    #[test]
238    fn descriptor_rejects_missing_tool_name() {
239        let v = json!({"arguments": {"command": "ls"}});
240        assert!(ToolCallDescriptor::from_json(v).is_err());
241    }
242
243    #[test]
244    fn descriptor_tolerates_missing_arguments() {
245        let v = json!({"name": "ping"});
246        let d = ToolCallDescriptor::from_json(v).unwrap();
247        assert_eq!(d.arguments, json!({}));
248    }
249
250    #[test]
251    fn explain_on_clean_call_yields_allow_with_no_matches() {
252        let engine = Engine::builtin_default();
253        let d = ToolCallDescriptor::from_json(
254            json!({"name": "shell", "arguments": {"command": "echo hi"}}),
255        )
256        .unwrap();
257        let report = explain(&engine, &d, &ExplainOptions::default()).unwrap();
258        assert!(report.evaluation.matches.is_empty());
259        assert!(matches!(report.decision, crate::Decision::Allow));
260    }
261
262    #[test]
263    fn explain_on_rm_rf_root_yields_block() {
264        let engine = Engine::builtin_default();
265        let d = ToolCallDescriptor::from_json(
266            json!({"name": "shell", "arguments": {"command": "rm -rf /"}}),
267        )
268        .unwrap();
269        let report = explain(&engine, &d, &ExplainOptions::default()).unwrap();
270        assert!(!report.evaluation.matches.is_empty());
271        assert!(matches!(
272            report.decision,
273            crate::Decision::Block { .. } | crate::Decision::Approval { .. }
274        ));
275    }
276
277    #[test]
278    fn force_workspace_prod_overrides_probe() {
279        let engine = Engine::builtin_default();
280        let d = ToolCallDescriptor::from_json(
281            json!({"name": "shell", "arguments": {"command": "ls"}}),
282        )
283        .unwrap();
284        let mut opts = ExplainOptions::default();
285        opts.force_workspace_prod = Some(true);
286        let report = explain(&engine, &d, &opts).unwrap();
287        assert!(report.adjustments.workspace_is_prod);
288    }
289
290    #[test]
291    fn force_burst_is_honoured() {
292        let engine = Engine::builtin_default();
293        let d = ToolCallDescriptor::from_json(
294            json!({"name": "shell", "arguments": {"command": "ls"}}),
295        )
296        .unwrap();
297        let mut opts = ExplainOptions::default();
298        opts.force_burst = Some(true);
299        let report = explain(&engine, &d, &opts).unwrap();
300        assert!(report.adjustments.burst_in_progress);
301    }
302}