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, pub details: Value, }
29
30pub fn evaluate_tool_args(policy: &Value, tool_name: &str, tool_args: &Value) -> Verdict {
33 if policy
35 .as_object()
36 .and_then(|schemas| schemas.get(tool_name))
37 .filter(|_| tool_name != "$defs")
38 .is_none()
39 {
40 let mut message = format!("Tool '{}' not defined in policy", tool_name);
42 if let Some(obj) = policy.as_object() {
43 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 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 evaluate_schema(&compiled, tool_args)
79}
80
81pub 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
109pub struct PolicyState {
115 validators: HashMap<String, Result<jsonschema::Validator, String>>,
116 tool_names: Vec<String>,
117}
118
119pub(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 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 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
261pub fn evaluate_sequence(policy_regex: &str, tool_names: &[String]) -> Verdict {
266 let trace_str = tool_names.join(" ");
270
271 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 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}