use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Policy {
pub name: String,
pub description: String,
pub version: String,
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Rule {
pub id: String,
pub description: String,
pub match_on: MatchCriteria,
pub effect: Effect,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MatchCriteria {
#[serde(default)]
pub action_types: Vec<String>,
#[serde(default)]
pub resources: Vec<String>,
#[serde(default)]
pub agent_ids: Vec<String>,
#[serde(default)]
pub parameters: std::collections::HashMap<String, String>,
#[serde(default)]
pub conditions: Vec<Condition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Condition {
pub field: String,
pub operator: ConditionOperator,
pub value: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConditionOperator {
Equals,
NotEquals,
Contains,
StartsWith,
EndsWith,
GreaterThan,
LessThan,
Exists,
NotExists,
OneOf,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Effect {
Allow,
Deny { reason: String },
RequireApproval { reason: String },
}
impl Policy {
pub fn from_yaml(yaml: &str) -> Result<Self, crate::error::KvlarError> {
let policy: Policy = serde_yaml::from_str(yaml)?;
Ok(policy)
}
pub fn to_yaml(&self) -> Result<String, crate::error::KvlarError> {
let yaml = serde_yaml::to_string(self)?;
Ok(yaml)
}
pub fn from_file(path: &std::path::Path) -> Result<Self, crate::error::KvlarError> {
let yaml = std::fs::read_to_string(path).map_err(|e| {
crate::error::KvlarError::PolicyParse(format!(
"failed to read {}: {}",
path.display(),
e
))
})?;
Self::from_yaml(&yaml)
}
pub fn from_dir(dir: &std::path::Path) -> Result<Vec<Self>, crate::error::KvlarError> {
let entries = std::fs::read_dir(dir).map_err(|e| {
crate::error::KvlarError::PolicyParse(format!(
"failed to read directory {}: {}",
dir.display(),
e
))
})?;
let mut policies = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| {
crate::error::KvlarError::PolicyParse(format!("failed to read entry: {}", e))
})?;
let path = entry.path();
if path.is_file()
&& let Some(ext) = path.extension()
&& (ext == "yaml" || ext == "yml")
{
policies.push(Self::from_file(&path)?);
}
}
Ok(policies)
}
pub fn json_schema() -> schemars::schema::RootSchema {
schemars::schema_for!(Policy)
}
pub fn json_schema_string() -> Result<String, crate::error::KvlarError> {
let schema = Self::json_schema();
serde_json::to_string_pretty(&schema).map_err(crate::error::KvlarError::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_from_yaml() {
let yaml = r#"
name: test-policy
description: A test policy
version: "1.0"
rules:
- id: deny-bash
description: Deny all bash commands
match_on:
action_types: ["tool_call"]
resources: ["bash"]
effect:
type: deny
reason: "Bash commands are not allowed"
"#;
let policy = Policy::from_yaml(yaml).unwrap();
assert_eq!(policy.name, "test-policy");
assert_eq!(policy.rules.len(), 1);
assert_eq!(policy.rules[0].id, "deny-bash");
}
#[test]
fn test_policy_roundtrip() {
let policy = Policy {
name: "roundtrip".into(),
description: "Test roundtrip".into(),
version: "1.0".into(),
rules: vec![],
};
let yaml = policy.to_yaml().unwrap();
let parsed = Policy::from_yaml(&yaml).unwrap();
assert_eq!(parsed.name, "roundtrip");
}
#[test]
fn test_json_schema_generation() {
let schema_str = Policy::json_schema_string().unwrap();
assert!(schema_str.contains("\"Policy\""));
assert!(schema_str.contains("\"name\""));
assert!(schema_str.contains("\"rules\""));
assert!(schema_str.contains("\"effect\""));
let _: serde_json::Value = serde_json::from_str(&schema_str).unwrap();
}
#[test]
fn test_json_schema_has_all_types() {
let schema = Policy::json_schema();
let json = serde_json::to_value(&schema).unwrap();
let defs = json.get("definitions").unwrap();
assert!(defs.get("Effect").is_some());
assert!(defs.get("MatchCriteria").is_some());
assert!(defs.get("Rule").is_some());
}
#[test]
fn test_policy_from_file() {
let dir = std::env::temp_dir().join("kvlar-test-policy-file");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.yaml");
std::fs::write(
&path,
r#"
name: file-policy
description: Loaded from file
version: "1.0"
rules: []
"#,
)
.unwrap();
let policy = Policy::from_file(&path).unwrap();
assert_eq!(policy.name, "file-policy");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_policy_from_dir() {
let dir = std::env::temp_dir().join("kvlar-test-policy-dir");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("a.yaml"),
"name: a\ndescription: A\nversion: '1'\nrules: []\n",
)
.unwrap();
std::fs::write(
dir.join("b.yml"),
"name: b\ndescription: B\nversion: '1'\nrules: []\n",
)
.unwrap();
std::fs::write(dir.join("c.txt"), "not a policy").unwrap();
let policies = Policy::from_dir(&dir).unwrap();
assert_eq!(policies.len(), 2);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_policy_from_file_not_found() {
let result = Policy::from_file(std::path::Path::new("/nonexistent/policy.yaml"));
assert!(result.is_err());
}
}