use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
struct LocalOnlyRetriever;
impl jsonschema::Retrieve for LocalOnlyRetriever {
fn retrieve(
&self,
_uri: &jsonschema::Uri<String>,
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
Err("external JSON Schema retrieval is disabled".into())
}
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
#[serde(rename_all = "snake_case")]
pub enum VerdictStatus {
Allowed,
Blocked,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
pub struct Verdict {
pub status: VerdictStatus,
pub reason_code: String, pub details: Value, }
pub fn evaluate_tool_args(policy: &Value, tool_name: &str, tool_args: &Value) -> Verdict {
if policy
.as_object()
.and_then(|schemas| schemas.get(tool_name))
.filter(|_| tool_name != "$defs")
.is_none()
{
let mut message = format!("Tool '{}' not defined in policy", tool_name);
if let Some(obj) = policy.as_object() {
if let Some(match_) = crate::errors::similarity::closest_prompt(
tool_name,
obj.keys().filter(|name| name.as_str() != "$defs"),
) {
message.push_str(&format!(". Did you mean '{}'?", match_.prompt));
}
}
return Verdict {
status: VerdictStatus::Blocked,
reason_code: "E_POLICY_MISSING_TOOL".to_string(),
details: serde_json::json!({
"message": message
}),
};
}
let schema_val = match prepare_tool_schema(policy, tool_name) {
Ok(schema) => schema,
Err(error) => return schema_compile_error(tool_name, &error),
};
let compiled = match compile_schema(&schema_val) {
Ok(c) => c,
Err(e) => return schema_compile_error(tool_name, &e),
};
evaluate_schema(&compiled, tool_args)
}
pub fn evaluate_schema(compiled: &jsonschema::Validator, tool_args: &Value) -> Verdict {
if compiled.is_valid(tool_args) {
return Verdict {
status: VerdictStatus::Allowed,
reason_code: "OK".to_string(),
details: serde_json::json!({}),
};
}
let violations: Vec<Value> = compiled
.iter_errors(tool_args)
.map(|e| {
serde_json::json!({
"path": e.instance_path().to_string(),
"constraint": e.to_string(),
"message": e.to_string()
})
})
.collect();
Verdict {
status: VerdictStatus::Blocked,
reason_code: "E_ARG_SCHEMA".to_string(),
details: serde_json::json!({
"violations": violations
}),
}
}
pub struct PolicyState {
validators: HashMap<String, Result<jsonschema::Validator, String>>,
tool_names: Vec<String>,
}
pub fn prepare_schema_map(policy: &Value) -> Result<Value, String> {
let Some(schemas) = policy.as_object() else {
return Ok(policy.clone());
};
let root_defs = shared_defs(schemas)?;
let has_object_tool = schemas
.iter()
.any(|(tool, schema)| tool != "$defs" && schema.is_object());
if let Some(root_defs) = root_defs.filter(|_| !has_object_tool) {
validate_unscoped_shared_defs(root_defs)?;
}
let mut prepared = serde_json::Map::new();
for tool in schemas.keys().filter(|tool| tool.as_str() != "$defs") {
prepared.insert(tool.clone(), prepare_tool_schema(policy, tool)?);
}
Ok(Value::Object(prepared))
}
fn shared_defs(
schemas: &serde_json::Map<String, Value>,
) -> Result<Option<&serde_json::Map<String, Value>>, String> {
Ok(match schemas.get("$defs") {
Some(Value::Object(defs)) => Some(defs),
Some(_) => return Err("shared $defs must be a mapping".to_string()),
None => None,
})
}
pub fn prepare_tool_schema(policy: &Value, tool: &str) -> Result<Value, String> {
let schemas = policy
.as_object()
.ok_or_else(|| "policy must be a tool-name-to-schema mapping".to_string())?;
let root_defs = shared_defs(schemas)?;
let mut schema = schemas
.get(tool)
.cloned()
.ok_or_else(|| format!("tool '{tool}' is not present"))?;
if let Some(root_defs) = root_defs {
match &mut schema {
Value::Object(schema_object) => {
let local_defs = match schema_object.get_mut("$defs") {
Some(Value::Object(defs)) => defs,
Some(_) => return Err("tool-local $defs must be a mapping".to_string()),
None => {
schema_object.insert("$defs".to_string(), Value::Object(root_defs.clone()));
return Ok(schema);
}
};
for (name, definition) in root_defs {
if local_defs.contains_key(name) {
return Err(
"shared and tool-local $defs entries must not overlap".to_string()
);
}
local_defs.insert(name.clone(), definition.clone());
}
}
Value::Bool(_) => validate_unscoped_shared_defs(root_defs)?,
_ => {}
}
}
Ok(schema)
}
fn validate_unscoped_shared_defs(root_defs: &serde_json::Map<String, Value>) -> Result<(), String> {
let definitions_schema = serde_json::json!({"$defs": root_defs});
compile_schema(&definitions_schema)
.map(|_| ())
.map_err(|error| format!("shared $defs failed to compile: {error}"))
}
pub(crate) fn compile_schema(schema: &Value) -> Result<jsonschema::Validator, String> {
jsonschema::options()
.with_retriever(LocalOnlyRetriever)
.build(schema)
.map_err(|error| error.to_string())
}
fn schema_compile_error(tool_name: &str, error: &str) -> Verdict {
Verdict {
status: VerdictStatus::Blocked,
reason_code: "E_SCHEMA_COMPILE".to_string(),
details: serde_json::json!({
"message": format!("Invalid schema for tool '{}': {}", tool_name, error)
}),
}
}
impl PolicyState {
pub fn compile(policy: &Value) -> Self {
let mut validators = HashMap::new();
let tool_names: Vec<_> = policy
.as_object()
.into_iter()
.flat_map(|schemas| schemas.keys())
.filter(|tool| tool.as_str() != "$defs")
.cloned()
.collect();
for tool in &tool_names {
let compiled =
prepare_tool_schema(policy, tool).and_then(|schema| compile_schema(&schema));
validators.insert(tool.clone(), compiled);
}
Self {
validators,
tool_names,
}
}
pub fn evaluate(&self, tool_name: &str, tool_args: &Value) -> Verdict {
if !self.tool_names.iter().any(|tool| tool == tool_name) {
return {
let mut message = format!("Tool '{}' not defined in policy", tool_name);
if let Some(match_) =
crate::errors::similarity::closest_prompt(tool_name, self.tool_names.iter())
{
message.push_str(&format!(". Did you mean '{}'?", match_.prompt));
}
Verdict {
status: VerdictStatus::Blocked,
reason_code: "E_POLICY_MISSING_TOOL".to_string(),
details: serde_json::json!({ "message": message }),
}
};
}
match self.validators.get(tool_name) {
None => schema_compile_error(tool_name, "schema preparation produced no validator"),
Some(Err(e)) => schema_compile_error(tool_name, e),
Some(Ok(compiled)) => evaluate_schema(compiled, tool_args),
}
}
}
pub fn evaluate_sequence(policy_regex: &str, tool_names: &[String]) -> Verdict {
let trace_str = tool_names.join(" ");
let re = match regex::Regex::new(policy_regex) {
Ok(r) => r,
Err(e) => {
return Verdict {
status: VerdictStatus::Blocked,
reason_code: "E_POLICY_REGEX_INVALID".to_string(),
details: serde_json::json!({
"message": format!("Invalid regex policy '{}': {}", policy_regex, e)
}),
};
}
};
if re.is_match(&trace_str) {
Verdict {
status: VerdictStatus::Allowed,
reason_code: "OK".to_string(),
details: serde_json::json!({}),
}
} else {
Verdict {
status: VerdictStatus::Blocked,
reason_code: "E_SEQUENCE_VIOLATION".to_string(),
details: serde_json::json!({
"expected": policy_regex,
"found": trace_str
}),
}
}
}