Skip to main content

assay_core/
policy_engine.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::HashMap;
4
5#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
6#[serde(rename_all = "snake_case")]
7pub enum VerdictStatus {
8    Allowed,
9    Blocked,
10}
11
12#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
13pub struct Verdict {
14    pub status: VerdictStatus,
15    pub reason_code: String, // e.g., "OK", "E_ARG_SCHEMA", "E_TOOL_NOT_ALLOWED"
16    pub details: Value,      // JSON details, violations, etc.
17}
18
19/// Evaluates tool arguments against a policy (JSON/YAML Value).
20/// The policy is expected to be a map of tool_name -> schema.
21pub fn evaluate_tool_args(policy: &Value, tool_name: &str, tool_args: &Value) -> Verdict {
22    // 1. Check if tool exists in policy
23    let schema_val = match policy.get(tool_name) {
24        Some(s) => s,
25        None => {
26            // Check for potential typos
27            let mut message = format!("Tool '{}' not defined in policy", tool_name);
28            if let Some(obj) = policy.as_object() {
29                // Use our similarity helper
30                if let Some(match_) =
31                    crate::errors::similarity::closest_prompt(tool_name, obj.keys())
32                {
33                    message.push_str(&format!(". Did you mean '{}'?", match_.prompt));
34                }
35            }
36
37            return Verdict {
38                status: VerdictStatus::Blocked,
39                reason_code: "E_POLICY_MISSING_TOOL".to_string(),
40                details: serde_json::json!({
41                    "message": message
42                }),
43            };
44        }
45    };
46
47    // 2. Compile Schema
48    // In a real high-perf scenario, we'd cache this (Compilation is expensive).
49    // For this core function, we compile on the fly or need a cached compilation context.
50    // User Step 1.2: "Compile JSON Schema validators één keer bij policy load".
51    // Since this function takes `&Value`, it implies per-call.
52    // To support caching, we'd need a `PolicyState` struct.
53    // For now, I'll compile on the fly (parity correctness first).
54
55    let compiled = match jsonschema::validator_for(schema_val) {
56        Ok(c) => c,
57        Err(e) => {
58            return Verdict {
59                status: VerdictStatus::Blocked,
60                reason_code: "E_SCHEMA_COMPILE".to_string(),
61                details: serde_json::json!({
62                    "message": format!("Invalid schema for tool '{}': {}", tool_name, e)
63                }),
64            };
65        }
66    };
67
68    // 3. Validate
69    evaluate_schema(&compiled, tool_args)
70}
71
72/// Evaluates tool arguments against a compiled schema.
73pub fn evaluate_schema(compiled: &jsonschema::Validator, tool_args: &Value) -> Verdict {
74    if compiled.is_valid(tool_args) {
75        return Verdict {
76            status: VerdictStatus::Allowed,
77            reason_code: "OK".to_string(),
78            details: serde_json::json!({}),
79        };
80    }
81    let violations: Vec<Value> = compiled
82        .iter_errors(tool_args)
83        .map(|e| {
84            serde_json::json!({
85                "path": e.instance_path().to_string(),
86                "constraint": e.to_string(),
87                "message": e.to_string()
88            })
89        })
90        .collect();
91    Verdict {
92        status: VerdictStatus::Blocked,
93        reason_code: "E_ARG_SCHEMA".to_string(),
94        details: serde_json::json!({
95            "violations": violations
96        }),
97    }
98}
99
100/// A policy whose per-tool JSON Schema validators are compiled ONCE, so a caller evaluating many tool
101/// calls against the same policy does not recompile per call (`jsonschema::validator_for` is the
102/// expensive step). `evaluate_tool_args` stays the one-shot convenience that compiles on the fly; this
103/// is the compile-once path for hot loops, matching how the MCP proxy compiles all schemas at policy
104/// load. Verdicts are identical to `evaluate_tool_args` for the same policy and call.
105pub struct PolicyState {
106    validators: HashMap<String, Result<jsonschema::Validator, String>>,
107    tool_names: Vec<String>,
108}
109
110impl PolicyState {
111    /// Compile every tool schema in the policy once. A tool whose schema fails to compile is recorded
112    /// as an error and only surfaces (as `E_SCHEMA_COMPILE`) if that tool is later evaluated, matching
113    /// the one-shot `evaluate_tool_args` behavior of only compiling the requested tool's schema.
114    pub fn compile(policy: &Value) -> Self {
115        let mut validators = HashMap::new();
116        let mut tool_names = Vec::new();
117        if let Some(obj) = policy.as_object() {
118            for (tool, schema_val) in obj {
119                tool_names.push(tool.clone());
120                validators.insert(
121                    tool.clone(),
122                    jsonschema::validator_for(schema_val).map_err(|e| e.to_string()),
123                );
124            }
125        }
126        Self {
127            validators,
128            tool_names,
129        }
130    }
131
132    /// Evaluate one tool call against the pre-compiled validators.
133    pub fn evaluate(&self, tool_name: &str, tool_args: &Value) -> Verdict {
134        match self.validators.get(tool_name) {
135            None => {
136                let mut message = format!("Tool '{}' not defined in policy", tool_name);
137                if let Some(match_) =
138                    crate::errors::similarity::closest_prompt(tool_name, self.tool_names.iter())
139                {
140                    message.push_str(&format!(". Did you mean '{}'?", match_.prompt));
141                }
142                Verdict {
143                    status: VerdictStatus::Blocked,
144                    reason_code: "E_POLICY_MISSING_TOOL".to_string(),
145                    details: serde_json::json!({ "message": message }),
146                }
147            }
148            Some(Err(e)) => Verdict {
149                status: VerdictStatus::Blocked,
150                reason_code: "E_SCHEMA_COMPILE".to_string(),
151                details: serde_json::json!({
152                    "message": format!("Invalid schema for tool '{}': {}", tool_name, e)
153                }),
154            },
155            Some(Ok(compiled)) => evaluate_schema(compiled, tool_args),
156        }
157    }
158}
159
160/// Evaluates a sequence of tool calls against a sequence policy (regex-like).
161/// For v0.9, simplified: the policy is just a string (regex) of tool names.
162/// E.g. "^search (analyze )*report$"
163/// The input is a list of tool names invoked in order.
164pub fn evaluate_sequence(policy_regex: &str, tool_names: &[String]) -> Verdict {
165    // 1. Construct the sequence string
166    // We join tool names with space. Note: tool names should not contain spaces ideally.
167    // If they do, this simple approach might be ambiguous, but standard tools usually don't.
168    let trace_str = tool_names.join(" ");
169
170    // 2. Compile Regex
171    // Again, efficiency concern: compile once.
172    let re = match regex::Regex::new(policy_regex) {
173        Ok(r) => r,
174        Err(e) => {
175            return Verdict {
176                status: VerdictStatus::Blocked,
177                reason_code: "E_POLICY_REGEX_INVALID".to_string(),
178                details: serde_json::json!({
179                    "message": format!("Invalid regex policy '{}': {}", policy_regex, e)
180                }),
181            };
182        }
183    };
184
185    // 3. Match
186    if re.is_match(&trace_str) {
187        Verdict {
188            status: VerdictStatus::Allowed,
189            reason_code: "OK".to_string(),
190            details: serde_json::json!({}),
191        }
192    } else {
193        Verdict {
194            status: VerdictStatus::Blocked,
195            reason_code: "E_SEQUENCE_VIOLATION".to_string(),
196            details: serde_json::json!({
197                "expected": policy_regex,
198                "found": trace_str
199            }),
200        }
201    }
202}