Skip to main content

assay_core/
policy_engine.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5struct LocalOnlyRetriever;
6
7impl jsonschema::Retrieve for LocalOnlyRetriever {
8    fn retrieve(
9        &self,
10        _uri: &jsonschema::Uri<String>,
11    ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
12        Err("external JSON Schema retrieval is disabled".into())
13    }
14}
15
16#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
17#[serde(rename_all = "snake_case")]
18pub enum VerdictStatus {
19    Allowed,
20    Blocked,
21}
22
23#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
24pub struct Verdict {
25    pub status: VerdictStatus,
26    pub reason_code: String, // e.g., "OK", "E_ARG_SCHEMA", "E_TOOL_NOT_ALLOWED"
27    pub details: Value,      // JSON details, violations, etc.
28}
29
30/// Evaluates tool arguments against a policy (JSON/YAML Value).
31/// The policy is expected to be a map of tool_name -> schema.
32pub fn evaluate_tool_args(policy: &Value, tool_name: &str, tool_args: &Value) -> Verdict {
33    // 1. Check if tool exists in policy
34    if policy
35        .as_object()
36        .and_then(|schemas| schemas.get(tool_name))
37        .filter(|_| tool_name != "$defs")
38        .is_none()
39    {
40        // Check for potential typos
41        let mut message = format!("Tool '{}' not defined in policy", tool_name);
42        if let Some(obj) = policy.as_object() {
43            // Use our similarity helper
44            if let Some(match_) = crate::errors::similarity::closest_prompt(
45                tool_name,
46                obj.keys().filter(|name| name.as_str() != "$defs"),
47            ) {
48                message.push_str(&format!(". Did you mean '{}'?", match_.prompt));
49            }
50        }
51        return Verdict {
52            status: VerdictStatus::Blocked,
53            reason_code: "E_POLICY_MISSING_TOOL".to_string(),
54            details: serde_json::json!({
55                "message": message
56            }),
57        };
58    }
59
60    // 2. Compile Schema
61    // In a real high-perf scenario, we'd cache this (Compilation is expensive).
62    // For this core function, we compile on the fly or need a cached compilation context.
63    // User Step 1.2: "Compile JSON Schema validators één keer bij policy load".
64    // Since this function takes `&Value`, it implies per-call.
65    // To support caching, we'd need a `PolicyState` struct.
66    // For now, I'll compile on the fly (parity correctness first).
67
68    let schema_val = match prepare_tool_schema(policy, tool_name) {
69        Ok(schema) => schema,
70        Err(error) => return schema_compile_error(tool_name, &error),
71    };
72    let compiled = match compile_schema(&schema_val) {
73        Ok(c) => c,
74        Err(e) => return schema_compile_error(tool_name, &e),
75    };
76
77    // 3. Validate
78    evaluate_schema(&compiled, tool_args)
79}
80
81/// Evaluates tool arguments against a compiled schema.
82pub fn evaluate_schema(compiled: &jsonschema::Validator, tool_args: &Value) -> Verdict {
83    if compiled.is_valid(tool_args) {
84        return Verdict {
85            status: VerdictStatus::Allowed,
86            reason_code: "OK".to_string(),
87            details: serde_json::json!({}),
88        };
89    }
90    let violations: Vec<Value> = compiled
91        .iter_errors(tool_args)
92        .map(|e| {
93            serde_json::json!({
94                "path": e.instance_path().to_string(),
95                "constraint": e.to_string(),
96                "message": e.to_string()
97            })
98        })
99        .collect();
100    Verdict {
101        status: VerdictStatus::Blocked,
102        reason_code: "E_ARG_SCHEMA".to_string(),
103        details: serde_json::json!({
104            "violations": violations
105        }),
106    }
107}
108
109/// A policy whose per-tool JSON Schema validators are compiled ONCE, so a caller evaluating many tool
110/// calls against the same policy does not recompile per call (`jsonschema::validator_for` is the
111/// expensive step). `evaluate_tool_args` stays the one-shot convenience that compiles on the fly; this
112/// is the compile-once path for hot loops, matching how the MCP proxy compiles all schemas at policy
113/// load. Verdicts are identical to `evaluate_tool_args` for the same policy and call.
114pub struct PolicyState {
115    validators: HashMap<String, Result<jsonschema::Validator, String>>,
116    tool_names: Vec<String>,
117}
118
119/// Prepare a tool-schema map for compilation without permitting external retrieval.
120///
121/// Root `$defs` are merged into each object-valued tool schema and therefore compile under that
122/// schema's declared dialect. A shared definition may not replace a tool-local definition with the
123/// same name. With only boolean tools, the otherwise-unscoped definitions use the default dialect.
124///
125/// This is the ONE preparation semantics for `$defs` in a tool-schema map. Every consumer that
126/// compiles or reasons about such a map (the MCP proxy's load-time compiler, `check_tool_args`,
127/// the `tool_output_valid` metric, and config-validation vacuity checks) must go through it, or a
128/// `$defs` entry means different things on different paths. The returned map contains only tool
129/// entries: `$defs` is consumed by the merge and is never itself a tool schema.
130pub fn prepare_schema_map(policy: &Value) -> Result<Value, String> {
131    let Some(schemas) = policy.as_object() else {
132        return Ok(policy.clone());
133    };
134    let root_defs = shared_defs(schemas)?;
135    let has_object_tool = schemas
136        .iter()
137        .any(|(tool, schema)| tool != "$defs" && schema.is_object());
138    if let Some(root_defs) = root_defs.filter(|_| !has_object_tool) {
139        validate_unscoped_shared_defs(root_defs)?;
140    }
141    let mut prepared = serde_json::Map::new();
142    for tool in schemas.keys().filter(|tool| tool.as_str() != "$defs") {
143        prepared.insert(tool.clone(), prepare_tool_schema(policy, tool)?);
144    }
145    Ok(Value::Object(prepared))
146}
147
148fn shared_defs(
149    schemas: &serde_json::Map<String, Value>,
150) -> Result<Option<&serde_json::Map<String, Value>>, String> {
151    Ok(match schemas.get("$defs") {
152        Some(Value::Object(defs)) => Some(defs),
153        Some(_) => return Err("shared $defs must be a mapping".to_string()),
154        None => None,
155    })
156}
157
158pub fn prepare_tool_schema(policy: &Value, tool: &str) -> Result<Value, String> {
159    let schemas = policy
160        .as_object()
161        .ok_or_else(|| "policy must be a tool-name-to-schema mapping".to_string())?;
162    let root_defs = shared_defs(schemas)?;
163    let mut schema = schemas
164        .get(tool)
165        .cloned()
166        .ok_or_else(|| format!("tool '{tool}' is not present"))?;
167    if let Some(root_defs) = root_defs {
168        match &mut schema {
169            Value::Object(schema_object) => {
170                let local_defs = match schema_object.get_mut("$defs") {
171                    Some(Value::Object(defs)) => defs,
172                    Some(_) => return Err("tool-local $defs must be a mapping".to_string()),
173                    None => {
174                        schema_object.insert("$defs".to_string(), Value::Object(root_defs.clone()));
175                        return Ok(schema);
176                    }
177                };
178                for (name, definition) in root_defs {
179                    if local_defs.contains_key(name) {
180                        return Err(
181                            "shared and tool-local $defs entries must not overlap".to_string()
182                        );
183                    }
184                    local_defs.insert(name.clone(), definition.clone());
185                }
186            }
187            Value::Bool(_) => validate_unscoped_shared_defs(root_defs)?,
188            _ => {}
189        }
190    }
191    Ok(schema)
192}
193
194fn validate_unscoped_shared_defs(root_defs: &serde_json::Map<String, Value>) -> Result<(), String> {
195    let definitions_schema = serde_json::json!({"$defs": root_defs});
196    compile_schema(&definitions_schema)
197        .map(|_| ())
198        .map_err(|error| format!("shared $defs failed to compile: {error}"))
199}
200
201pub(crate) fn compile_schema(schema: &Value) -> Result<jsonschema::Validator, String> {
202    jsonschema::options()
203        .with_retriever(LocalOnlyRetriever)
204        .build(schema)
205        .map_err(|error| error.to_string())
206}
207
208fn schema_compile_error(tool_name: &str, error: &str) -> Verdict {
209    Verdict {
210        status: VerdictStatus::Blocked,
211        reason_code: "E_SCHEMA_COMPILE".to_string(),
212        details: serde_json::json!({
213            "message": format!("Invalid schema for tool '{}': {}", tool_name, error)
214        }),
215    }
216}
217
218impl PolicyState {
219    /// Compile every tool schema in the policy once. A tool whose schema fails to compile is recorded
220    /// as an error and only surfaces (as `E_SCHEMA_COMPILE`) if that tool is later evaluated, matching
221    /// the one-shot `evaluate_tool_args` behavior of only compiling the requested tool's schema.
222    pub fn compile(policy: &Value) -> Self {
223        let mut validators = HashMap::new();
224        let tool_names: Vec<_> = policy
225            .as_object()
226            .into_iter()
227            .flat_map(|schemas| schemas.keys())
228            .filter(|tool| tool.as_str() != "$defs")
229            .cloned()
230            .collect();
231        for tool in &tool_names {
232            let compiled =
233                prepare_tool_schema(policy, tool).and_then(|schema| compile_schema(&schema));
234            validators.insert(tool.clone(), compiled);
235        }
236        Self {
237            validators,
238            tool_names,
239        }
240    }
241
242    /// Evaluate one tool call against the pre-compiled validators.
243    pub fn evaluate(&self, tool_name: &str, tool_args: &Value) -> Verdict {
244        if !self.tool_names.iter().any(|tool| tool == tool_name) {
245            return {
246                let mut message = format!("Tool '{}' not defined in policy", tool_name);
247                if let Some(match_) =
248                    crate::errors::similarity::closest_prompt(tool_name, self.tool_names.iter())
249                {
250                    message.push_str(&format!(". Did you mean '{}'?", match_.prompt));
251                }
252                Verdict {
253                    status: VerdictStatus::Blocked,
254                    reason_code: "E_POLICY_MISSING_TOOL".to_string(),
255                    details: serde_json::json!({ "message": message }),
256                }
257            };
258        }
259        match self.validators.get(tool_name) {
260            None => schema_compile_error(tool_name, "schema preparation produced no validator"),
261            Some(Err(e)) => schema_compile_error(tool_name, e),
262            Some(Ok(compiled)) => evaluate_schema(compiled, tool_args),
263        }
264    }
265}
266
267/// Evaluates a sequence of tool calls against a sequence policy (regex-like).
268/// For v0.9, simplified: the policy is just a string (regex) of tool names.
269/// E.g. "^search (analyze )*report$"
270/// The input is a list of tool names invoked in order.
271pub fn evaluate_sequence(policy_regex: &str, tool_names: &[String]) -> Verdict {
272    // 1. Construct the sequence string
273    // We join tool names with space. Note: tool names should not contain spaces ideally.
274    // If they do, this simple approach might be ambiguous, but standard tools usually don't.
275    let trace_str = tool_names.join(" ");
276
277    // 2. Compile Regex
278    // Again, efficiency concern: compile once.
279    let re = match regex::Regex::new(policy_regex) {
280        Ok(r) => r,
281        Err(e) => {
282            return Verdict {
283                status: VerdictStatus::Blocked,
284                reason_code: "E_POLICY_REGEX_INVALID".to_string(),
285                details: serde_json::json!({
286                    "message": format!("Invalid regex policy '{}': {}", policy_regex, e)
287                }),
288            };
289        }
290    };
291
292    // 3. Match
293    if re.is_match(&trace_str) {
294        Verdict {
295            status: VerdictStatus::Allowed,
296            reason_code: "OK".to_string(),
297            details: serde_json::json!({}),
298        }
299    } else {
300        Verdict {
301            status: VerdictStatus::Blocked,
302            reason_code: "E_SEQUENCE_VIOLATION".to_string(),
303            details: serde_json::json!({
304                "expected": policy_regex,
305                "found": trace_str
306            }),
307        }
308    }
309}