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,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extends: Vec<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 resolve_extends(
&mut self,
resolve_fn: &dyn Fn(&str) -> Result<String, crate::error::KvlarError>,
) -> Result<(), crate::error::KvlarError> {
if self.extends.is_empty() {
return Ok(());
}
let extends = std::mem::take(&mut self.extends);
for base_name in &extends {
let base_yaml = resolve_fn(base_name)?;
let mut base_policy = Policy::from_yaml(&base_yaml)?;
base_policy.resolve_extends(resolve_fn)?;
self.rules.append(&mut base_policy.rules);
}
Ok(())
}
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(),
extends: vec![],
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());
}
#[test]
fn test_extends_merges_rules() {
let base_yaml = r#"
name: base
description: Base policy
version: "1.0"
rules:
- id: base-deny
description: Base deny rule
match_on:
resources: ["dangerous"]
effect:
type: deny
reason: "Blocked by base"
"#;
let mut policy = Policy::from_yaml(
r#"
name: child
description: Child policy
version: "1.0"
extends:
- base
rules:
- id: child-allow
description: Child allow rule
match_on:
resources: ["safe"]
effect:
type: allow
"#,
)
.unwrap();
policy
.resolve_extends(&|name| {
if name == "base" {
Ok(base_yaml.to_string())
} else {
Err(crate::error::KvlarError::PolicyParse(format!(
"unknown: {}",
name
)))
}
})
.unwrap();
assert_eq!(policy.rules.len(), 2);
assert_eq!(policy.rules[0].id, "child-allow");
assert_eq!(policy.rules[1].id, "base-deny");
assert!(policy.extends.is_empty());
}
#[test]
fn test_extends_empty_is_noop() {
let mut policy = Policy::from_yaml(
r#"
name: standalone
description: No extends
version: "1.0"
rules:
- id: my-rule
description: A rule
match_on:
resources: ["*"]
effect:
type: allow
"#,
)
.unwrap();
policy
.resolve_extends(&|_| panic!("should not be called"))
.unwrap();
assert_eq!(policy.rules.len(), 1);
}
#[test]
fn test_extends_multiple_bases() {
let mut policy = Policy::from_yaml(
r#"
name: multi
description: Extends two bases
version: "1.0"
extends:
- alpha
- beta
rules:
- id: my-rule
description: My rule
match_on:
resources: ["mine"]
effect:
type: allow
"#,
)
.unwrap();
policy
.resolve_extends(&|name| {
let yaml = format!(
r#"
name: {name}
description: Base {name}
version: "1.0"
rules:
- id: {name}-rule
description: Rule from {name}
match_on:
resources: ["{name}"]
effect:
type: deny
reason: "From {name}"
"#
);
Ok(yaml)
})
.unwrap();
assert_eq!(policy.rules.len(), 3);
assert_eq!(policy.rules[0].id, "my-rule");
assert_eq!(policy.rules[1].id, "alpha-rule");
assert_eq!(policy.rules[2].id, "beta-rule");
}
#[test]
fn test_extends_serialization_skips_empty() {
let policy = Policy {
name: "test".into(),
description: "Test".into(),
version: "1.0".into(),
extends: vec![],
rules: vec![],
};
let yaml = policy.to_yaml().unwrap();
assert!(!yaml.contains("extends"));
}
}