pub mod render;
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use std::path::PathBuf;
use crate::engine::Engine;
use crate::{decide, Adjustments, BurstDetector, WorkspaceContext};
#[derive(Debug, Clone, Default)]
pub struct ExplainOptions {
pub workspace_root: Option<PathBuf>,
pub force_workspace_prod: Option<bool>,
pub force_burst: Option<bool>,
pub force_repeatedly_approved: bool,
pub force_recently_denied: bool,
}
#[derive(Debug, Clone)]
pub struct ToolCallDescriptor {
pub tool: String,
pub arguments: Value,
pub arguments_pretty: String,
}
impl ToolCallDescriptor {
pub fn from_json(v: Value) -> Result<Self> {
let tool = v
.get("name")
.or_else(|| v.get("tool"))
.and_then(|x| x.as_str())
.ok_or_else(|| {
anyhow!("input descriptor must have a `name` (or `tool`) string field")
})?
.to_string();
let arguments = v
.get("arguments")
.or_else(|| v.get("params"))
.cloned()
.unwrap_or_else(|| Value::Object(serde_json::Map::new()));
let arguments_pretty = serde_json::to_string_pretty(&arguments)
.unwrap_or_else(|_| "{}".to_string());
Ok(Self {
tool,
arguments,
arguments_pretty,
})
}
}
pub fn explain(
engine: &Engine,
descriptor: &ToolCallDescriptor,
opts: &ExplainOptions,
) -> Result<render::ExplainReport> {
let canonical = serde_json::json!({
"name": descriptor.tool,
"arguments": descriptor.arguments,
});
let adj = build_adjustments(engine, opts)?;
let eval = engine.evaluate(&descriptor.tool, &canonical, adj.clone());
let decision = decide(&eval);
Ok(render::ExplainReport {
descriptor: descriptor.clone(),
adjustments: adj,
evaluation: eval,
decision,
options: opts.clone(),
})
}
fn build_adjustments(engine: &Engine, opts: &ExplainOptions) -> Result<Adjustments> {
let workspace_is_prod = match opts.force_workspace_prod {
Some(b) => b,
None => {
let probe_root = opts
.workspace_root
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
WorkspaceContext::probe_at(&engine.policy, &probe_root).is_prod
}
};
let burst_in_progress = match opts.force_burst {
Some(b) => b,
None => {
BurstDetector::new(engine.policy.burst_detector.clone()).in_burst()
}
};
Ok(Adjustments {
workspace_is_prod,
burst_in_progress,
fingerprint_repeatedly_approved: opts.force_repeatedly_approved,
fingerprint_recently_denied: opts.force_recently_denied,
})
}
pub fn read_descriptor_from(path: &str) -> Result<ToolCallDescriptor> {
let raw = if path == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("couldn't read stdin")?;
buf
} else {
std::fs::read_to_string(path)
.with_context(|| format!("couldn't read --input {}", path))?
};
let v: Value = serde_json::from_str(&raw)
.with_context(|| format!("couldn't parse --input {} as JSON", path))?;
ToolCallDescriptor::from_json(v)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn descriptor_parses_mcp_style_shape() {
let v = json!({"name": "shell", "arguments": {"command": "ls"}});
let d = ToolCallDescriptor::from_json(v).unwrap();
assert_eq!(d.tool, "shell");
assert_eq!(d.arguments, json!({"command": "ls"}));
}
#[test]
fn descriptor_parses_legacy_tool_params_shape() {
let v = json!({"tool": "execute_sql", "params": {"query": "SELECT 1"}});
let d = ToolCallDescriptor::from_json(v).unwrap();
assert_eq!(d.tool, "execute_sql");
assert_eq!(d.arguments, json!({"query": "SELECT 1"}));
}
#[test]
fn descriptor_rejects_missing_tool_name() {
let v = json!({"arguments": {"command": "ls"}});
assert!(ToolCallDescriptor::from_json(v).is_err());
}
#[test]
fn descriptor_tolerates_missing_arguments() {
let v = json!({"name": "ping"});
let d = ToolCallDescriptor::from_json(v).unwrap();
assert_eq!(d.arguments, json!({}));
}
#[test]
fn explain_on_clean_call_yields_allow_with_no_matches() {
let engine = Engine::builtin_default();
let d = ToolCallDescriptor::from_json(
json!({"name": "shell", "arguments": {"command": "echo hi"}}),
)
.unwrap();
let report = explain(&engine, &d, &ExplainOptions::default()).unwrap();
assert!(report.evaluation.matches.is_empty());
assert!(matches!(report.decision, crate::Decision::Allow));
}
#[test]
fn explain_on_rm_rf_root_yields_block() {
let engine = Engine::builtin_default();
let d = ToolCallDescriptor::from_json(
json!({"name": "shell", "arguments": {"command": "rm -rf /"}}),
)
.unwrap();
let report = explain(&engine, &d, &ExplainOptions::default()).unwrap();
assert!(!report.evaluation.matches.is_empty());
assert!(matches!(
report.decision,
crate::Decision::Block { .. } | crate::Decision::Approval { .. }
));
}
#[test]
fn force_workspace_prod_overrides_probe() {
let engine = Engine::builtin_default();
let d = ToolCallDescriptor::from_json(
json!({"name": "shell", "arguments": {"command": "ls"}}),
)
.unwrap();
let mut opts = ExplainOptions::default();
opts.force_workspace_prod = Some(true);
let report = explain(&engine, &d, &opts).unwrap();
assert!(report.adjustments.workspace_is_prod);
}
#[test]
fn force_burst_is_honoured() {
let engine = Engine::builtin_default();
let d = ToolCallDescriptor::from_json(
json!({"name": "shell", "arguments": {"command": "ls"}}),
)
.unwrap();
let mut opts = ExplainOptions::default();
opts.force_burst = Some(true);
let report = explain(&engine, &d, &opts).unwrap();
assert!(report.adjustments.burst_in_progress);
}
}