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.
124pub(crate) fn prepare_schema_map(policy: &Value) -> Result<Value, String> {
125    let Some(schemas) = policy.as_object() else {
126        return Ok(policy.clone());
127    };
128    let root_defs = shared_defs(schemas)?;
129    let has_object_tool = schemas
130        .iter()
131        .any(|(tool, schema)| tool != "$defs" && schema.is_object());
132    if let Some(root_defs) = root_defs.filter(|_| !has_object_tool) {
133        validate_unscoped_shared_defs(root_defs)?;
134    }
135    let mut prepared = serde_json::Map::new();
136    for tool in schemas.keys().filter(|tool| tool.as_str() != "$defs") {
137        prepared.insert(tool.clone(), prepare_tool_schema(policy, tool)?);
138    }
139    Ok(Value::Object(prepared))
140}
141
142fn shared_defs(
143    schemas: &serde_json::Map<String, Value>,
144) -> Result<Option<&serde_json::Map<String, Value>>, String> {
145    Ok(match schemas.get("$defs") {
146        Some(Value::Object(defs)) => Some(defs),
147        Some(_) => return Err("shared $defs must be a mapping".to_string()),
148        None => None,
149    })
150}
151
152pub(crate) fn prepare_tool_schema(policy: &Value, tool: &str) -> Result<Value, String> {
153    let schemas = policy
154        .as_object()
155        .ok_or_else(|| "policy must be a tool-name-to-schema mapping".to_string())?;
156    let root_defs = shared_defs(schemas)?;
157    let mut schema = schemas
158        .get(tool)
159        .cloned()
160        .ok_or_else(|| format!("tool '{tool}' is not present"))?;
161    if let Some(root_defs) = root_defs {
162        match &mut schema {
163            Value::Object(schema_object) => {
164                let local_defs = match schema_object.get_mut("$defs") {
165                    Some(Value::Object(defs)) => defs,
166                    Some(_) => return Err("tool-local $defs must be a mapping".to_string()),
167                    None => {
168                        schema_object.insert("$defs".to_string(), Value::Object(root_defs.clone()));
169                        return Ok(schema);
170                    }
171                };
172                for (name, definition) in root_defs {
173                    if local_defs.contains_key(name) {
174                        return Err(
175                            "shared and tool-local $defs entries must not overlap".to_string()
176                        );
177                    }
178                    local_defs.insert(name.clone(), definition.clone());
179                }
180            }
181            Value::Bool(_) => validate_unscoped_shared_defs(root_defs)?,
182            _ => {}
183        }
184    }
185    Ok(schema)
186}
187
188fn validate_unscoped_shared_defs(root_defs: &serde_json::Map<String, Value>) -> Result<(), String> {
189    let definitions_schema = serde_json::json!({"$defs": root_defs});
190    compile_schema(&definitions_schema)
191        .map(|_| ())
192        .map_err(|error| format!("shared $defs failed to compile: {error}"))
193}
194
195pub(crate) fn compile_schema(schema: &Value) -> Result<jsonschema::Validator, String> {
196    jsonschema::options()
197        .with_retriever(LocalOnlyRetriever)
198        .build(schema)
199        .map_err(|error| error.to_string())
200}
201
202fn schema_compile_error(tool_name: &str, error: &str) -> Verdict {
203    Verdict {
204        status: VerdictStatus::Blocked,
205        reason_code: "E_SCHEMA_COMPILE".to_string(),
206        details: serde_json::json!({
207            "message": format!("Invalid schema for tool '{}': {}", tool_name, error)
208        }),
209    }
210}
211
212impl PolicyState {
213    /// Compile every tool schema in the policy once. A tool whose schema fails to compile is recorded
214    /// as an error and only surfaces (as `E_SCHEMA_COMPILE`) if that tool is later evaluated, matching
215    /// the one-shot `evaluate_tool_args` behavior of only compiling the requested tool's schema.
216    pub fn compile(policy: &Value) -> Self {
217        let mut validators = HashMap::new();
218        let tool_names: Vec<_> = policy
219            .as_object()
220            .into_iter()
221            .flat_map(|schemas| schemas.keys())
222            .filter(|tool| tool.as_str() != "$defs")
223            .cloned()
224            .collect();
225        for tool in &tool_names {
226            let compiled =
227                prepare_tool_schema(policy, tool).and_then(|schema| compile_schema(&schema));
228            validators.insert(tool.clone(), compiled);
229        }
230        Self {
231            validators,
232            tool_names,
233        }
234    }
235
236    /// Evaluate one tool call against the pre-compiled validators.
237    pub fn evaluate(&self, tool_name: &str, tool_args: &Value) -> Verdict {
238        if !self.tool_names.iter().any(|tool| tool == tool_name) {
239            return {
240                let mut message = format!("Tool '{}' not defined in policy", tool_name);
241                if let Some(match_) =
242                    crate::errors::similarity::closest_prompt(tool_name, self.tool_names.iter())
243                {
244                    message.push_str(&format!(". Did you mean '{}'?", match_.prompt));
245                }
246                Verdict {
247                    status: VerdictStatus::Blocked,
248                    reason_code: "E_POLICY_MISSING_TOOL".to_string(),
249                    details: serde_json::json!({ "message": message }),
250                }
251            };
252        }
253        match self.validators.get(tool_name) {
254            None => schema_compile_error(tool_name, "schema preparation produced no validator"),
255            Some(Err(e)) => schema_compile_error(tool_name, e),
256            Some(Ok(compiled)) => evaluate_schema(compiled, tool_args),
257        }
258    }
259}
260
261/// Evaluates a sequence of tool calls against a sequence policy (regex-like).
262/// For v0.9, simplified: the policy is just a string (regex) of tool names.
263/// E.g. "^search (analyze )*report$"
264/// The input is a list of tool names invoked in order.
265pub fn evaluate_sequence(policy_regex: &str, tool_names: &[String]) -> Verdict {
266    // 1. Construct the sequence string
267    // We join tool names with space. Note: tool names should not contain spaces ideally.
268    // If they do, this simple approach might be ambiguous, but standard tools usually don't.
269    let trace_str = tool_names.join(" ");
270
271    // 2. Compile Regex
272    // Again, efficiency concern: compile once.
273    let re = match regex::Regex::new(policy_regex) {
274        Ok(r) => r,
275        Err(e) => {
276            return Verdict {
277                status: VerdictStatus::Blocked,
278                reason_code: "E_POLICY_REGEX_INVALID".to_string(),
279                details: serde_json::json!({
280                    "message": format!("Invalid regex policy '{}': {}", policy_regex, e)
281                }),
282            };
283        }
284    };
285
286    // 3. Match
287    if re.is_match(&trace_str) {
288        Verdict {
289            status: VerdictStatus::Allowed,
290            reason_code: "OK".to_string(),
291            details: serde_json::json!({}),
292        }
293    } else {
294        Verdict {
295            status: VerdictStatus::Blocked,
296            reason_code: "E_SEQUENCE_VIOLATION".to_string(),
297            details: serde_json::json!({
298                "expected": policy_regex,
299                "found": trace_str
300            }),
301        }
302    }
303}