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 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 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 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
267pub fn evaluate_sequence(policy_regex: &str, tool_names: &[String]) -> Verdict {
272 let trace_str = tool_names.join(" ");
276
277 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 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}