aperion_shield/explain/
mod.rs1pub 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#[derive(Debug, Clone, Default)]
76pub struct ExplainOptions {
77 pub workspace_root: Option<PathBuf>,
80 pub force_workspace_prod: Option<bool>,
83 pub force_burst: Option<bool>,
86 pub force_repeatedly_approved: bool,
89 pub force_recently_denied: bool,
92}
93
94#[derive(Debug, Clone)]
99pub struct ToolCallDescriptor {
100 pub tool: String,
101 pub arguments: Value,
102 pub arguments_pretty: String,
105}
106
107impl ToolCallDescriptor {
108 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
136pub fn explain(
140 engine: &Engine,
141 descriptor: &ToolCallDescriptor,
142 opts: &ExplainOptions,
143) -> Result<render::ExplainReport> {
144 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 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
197pub 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}