use regex::Regex;
use crate::action::Action;
use crate::decision::Decision;
use crate::error::KvlarError;
use crate::policy::{Condition, ConditionOperator, Effect, MatchCriteria, Policy, Rule};
const GLOB_META_CHARS: &[char] = &['*', '?', '['];
fn glob_matches(pattern: &str, value: &str) -> bool {
if !pattern.contains(GLOB_META_CHARS) {
return pattern == value;
}
match glob_to_regex(pattern) {
Some(re) => re.is_match(value),
None => pattern == value,
}
}
fn glob_to_regex(pattern: &str) -> Option<Regex> {
let mut regex_str = String::with_capacity(pattern.len() + 4);
regex_str.push('^');
let mut chars = pattern.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => regex_str.push_str(".*"),
'?' => regex_str.push('.'),
'[' => {
regex_str.push('[');
if chars.peek() == Some(&'!') {
chars.next();
regex_str.push('^');
}
let mut found_close = false;
for inner in chars.by_ref() {
regex_str.push(inner);
if inner == ']' {
found_close = true;
break;
}
}
if !found_close {
return None;
}
}
'.' | '+' | '^' | '$' | '|' | '\\' | '(' | ')' | '{' | '}' => {
regex_str.push('\\');
regex_str.push(c);
}
_ => regex_str.push(c),
}
}
regex_str.push('$');
Regex::new(®ex_str).ok()
}
fn any_pattern_matches(patterns: &[String], value: &str) -> bool {
patterns.iter().any(|p| glob_matches(p, value))
}
#[derive(Debug, Clone)]
pub struct Engine {
policies: Vec<Policy>,
}
impl Engine {
pub fn new() -> Self {
Self {
policies: Vec::new(),
}
}
pub fn load_policy(&mut self, policy: Policy) {
self.policies.push(policy);
}
pub fn load_policy_yaml(&mut self, yaml: &str) -> Result<(), KvlarError> {
let policy = Policy::from_yaml(yaml)?;
self.load_policy(policy);
Ok(())
}
pub fn policy_count(&self) -> usize {
self.policies.len()
}
pub fn rule_count(&self) -> usize {
self.policies.iter().map(|p| p.rules.len()).sum()
}
pub fn evaluate(&self, action: &Action) -> Decision {
for policy in &self.policies {
for rule in &policy.rules {
if self.matches_rule(action, rule) {
return self.rule_to_decision(rule);
}
}
}
Decision::Deny {
reason: "no matching policy rule — denied by default (fail-closed)".into(),
matched_rule: "_default_deny".into(),
}
}
fn matches_rule(&self, action: &Action, rule: &Rule) -> bool {
self.matches_criteria(action, &rule.match_on)
}
fn matches_criteria(&self, action: &Action, criteria: &MatchCriteria) -> bool {
if !criteria.action_types.is_empty()
&& !any_pattern_matches(&criteria.action_types, &action.action_type)
{
return false;
}
if !criteria.resources.is_empty()
&& !any_pattern_matches(&criteria.resources, &action.resource)
{
return false;
}
if !criteria.agent_ids.is_empty()
&& !any_pattern_matches(&criteria.agent_ids, &action.agent_id)
{
return false;
}
for (key, pattern) in &criteria.parameters {
match action.parameters.get(key) {
Some(value) => {
let value_str = match value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
match Regex::new(pattern) {
Ok(re) => {
if !re.is_match(&value_str) {
return false;
}
}
Err(_) => return false, }
}
None => return false, }
}
for condition in &criteria.conditions {
if !self.evaluate_condition(action, condition) {
return false;
}
}
true
}
fn evaluate_condition(&self, action: &Action, condition: &Condition) -> bool {
let field_value = self.resolve_field(action, &condition.field);
match &condition.operator {
ConditionOperator::Exists => field_value.is_some(),
ConditionOperator::NotExists => field_value.is_none(),
_ => {
let Some(field_val) = field_value else {
return false;
};
self.compare_values(&field_val, &condition.operator, &condition.value)
}
}
}
fn resolve_field(&self, action: &Action, field: &str) -> Option<serde_json::Value> {
if let Some(value) = action.parameters.get(field) {
return Some(value.clone());
}
let parts: Vec<&str> = field.splitn(2, '.').collect();
if parts.len() == 2
&& let Some(parent) = action.parameters.get(parts[0])
{
return Self::resolve_nested(parent, parts[1]);
}
None
}
fn resolve_nested(value: &serde_json::Value, path: &str) -> Option<serde_json::Value> {
let parts: Vec<&str> = path.splitn(2, '.').collect();
match value.get(parts[0]) {
Some(child) => {
if parts.len() == 1 {
Some(child.clone())
} else {
Self::resolve_nested(child, parts[1])
}
}
None => None,
}
}
fn compare_values(
&self,
field_val: &serde_json::Value,
operator: &ConditionOperator,
cond_val: &serde_json::Value,
) -> bool {
match operator {
ConditionOperator::Equals => field_val == cond_val,
ConditionOperator::NotEquals => field_val != cond_val,
ConditionOperator::Contains => {
let field_str = field_val.as_str().unwrap_or("");
let cond_str = cond_val.as_str().unwrap_or("");
field_str.contains(cond_str)
}
ConditionOperator::StartsWith => {
let field_str = field_val.as_str().unwrap_or("");
let cond_str = cond_val.as_str().unwrap_or("");
field_str.starts_with(cond_str)
}
ConditionOperator::EndsWith => {
let field_str = field_val.as_str().unwrap_or("");
let cond_str = cond_val.as_str().unwrap_or("");
field_str.ends_with(cond_str)
}
ConditionOperator::GreaterThan => {
let a = field_val.as_f64();
let b = cond_val.as_f64();
matches!((a, b), (Some(a), Some(b)) if a > b)
}
ConditionOperator::LessThan => {
let a = field_val.as_f64();
let b = cond_val.as_f64();
matches!((a, b), (Some(a), Some(b)) if a < b)
}
ConditionOperator::OneOf => {
if let Some(arr) = cond_val.as_array() {
arr.contains(field_val)
} else {
false
}
}
ConditionOperator::Exists | ConditionOperator::NotExists => {
unreachable!("handled above")
}
}
}
fn rule_to_decision(&self, rule: &Rule) -> Decision {
match &rule.effect {
Effect::Allow => Decision::Allow {
matched_rule: rule.id.clone(),
},
Effect::Deny { reason } => Decision::Deny {
reason: reason.clone(),
matched_rule: rule.id.clone(),
},
Effect::RequireApproval { reason } => Decision::RequireApproval {
reason: reason.clone(),
matched_rule: rule.id.clone(),
},
}
}
}
impl Default for Engine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::action::Action;
fn test_policy_yaml() -> &'static str {
r#"
name: test-policy
description: Policy for unit tests
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"
- id: approve-email
description: Require approval for sending emails
match_on:
action_types: ["tool_call"]
resources: ["send_email"]
effect:
type: require_approval
reason: "Email sending requires human approval"
- id: allow-read
description: Allow file reads
match_on:
action_types: ["tool_call"]
resources: ["read_file"]
effect:
type: allow
- id: deny-rm-rf
description: Deny destructive rm commands
match_on:
action_types: ["tool_call"]
resources: ["bash"]
parameters:
command: "rm\\s+(-rf|--force)"
effect:
type: deny
reason: "Destructive rm commands are prohibited"
"#
}
#[test]
fn test_engine_default_deny() {
let engine = Engine::new();
let action = Action::new("tool_call", "bash", "agent-1");
let decision = engine.evaluate(&action);
assert!(decision.is_denied());
}
#[test]
fn test_engine_deny_bash() {
let mut engine = Engine::new();
engine.load_policy_yaml(test_policy_yaml()).unwrap();
let action = Action::new("tool_call", "bash", "agent-1");
let decision = engine.evaluate(&action);
assert!(decision.is_denied());
if let Decision::Deny { matched_rule, .. } = &decision {
assert_eq!(matched_rule, "deny-bash");
}
}
#[test]
fn test_engine_require_approval_email() {
let mut engine = Engine::new();
engine.load_policy_yaml(test_policy_yaml()).unwrap();
let action = Action::new("tool_call", "send_email", "agent-1");
let decision = engine.evaluate(&action);
assert!(decision.requires_approval());
if let Decision::RequireApproval { matched_rule, .. } = &decision {
assert_eq!(matched_rule, "approve-email");
}
}
#[test]
fn test_engine_allow_read() {
let mut engine = Engine::new();
engine.load_policy_yaml(test_policy_yaml()).unwrap();
let action = Action::new("tool_call", "read_file", "agent-1");
let decision = engine.evaluate(&action);
assert!(decision.is_allowed());
if let Decision::Allow { matched_rule } = &decision {
assert_eq!(matched_rule, "allow-read");
}
}
#[test]
fn test_engine_unmatched_action_denied() {
let mut engine = Engine::new();
engine.load_policy_yaml(test_policy_yaml()).unwrap();
let action = Action::new("data_access", "database", "agent-1");
let decision = engine.evaluate(&action);
assert!(decision.is_denied());
if let Decision::Deny { matched_rule, .. } = &decision {
assert_eq!(matched_rule, "_default_deny");
}
}
#[test]
fn test_engine_parameter_matching() {
let mut engine = Engine::new();
engine.load_policy_yaml(test_policy_yaml()).unwrap();
let action = Action::new("tool_call", "bash", "agent-1")
.with_param("command", serde_json::Value::String("rm -rf /".into()));
let decision = engine.evaluate(&action);
assert!(decision.is_denied());
}
#[test]
fn test_engine_policy_count() {
let mut engine = Engine::new();
assert_eq!(engine.policy_count(), 0);
assert_eq!(engine.rule_count(), 0);
engine.load_policy_yaml(test_policy_yaml()).unwrap();
assert_eq!(engine.policy_count(), 1);
assert_eq!(engine.rule_count(), 4);
}
#[test]
fn test_engine_multiple_policies() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: policy-1
description: First policy
version: "1.0"
rules:
- id: deny-bash
description: Deny bash
match_on:
resources: ["bash"]
effect:
type: deny
reason: "No bash"
"#,
)
.unwrap();
engine
.load_policy_yaml(
r#"
name: policy-2
description: Second policy
version: "1.0"
rules:
- id: allow-all
description: Allow everything
match_on: {}
effect:
type: allow
"#,
)
.unwrap();
assert_eq!(engine.policy_count(), 2);
let bash_action = Action::new("tool_call", "bash", "agent-1");
assert!(engine.evaluate(&bash_action).is_denied());
let read_action = Action::new("tool_call", "read_file", "agent-1");
assert!(engine.evaluate(&read_action).is_allowed());
}
#[test]
fn test_condition_equals() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: cond-test
description: Condition test
version: "1"
rules:
- id: deny-sensitive-path
description: Deny access to /etc/passwd
match_on:
resources: ["read_file"]
conditions:
- field: path
operator: equals
value: "/etc/passwd"
effect:
type: deny
reason: "Sensitive file"
- id: allow-all
description: Allow everything else
match_on: {}
effect:
type: allow
"#,
)
.unwrap();
let action = Action::new("tool_call", "read_file", "agent-1")
.with_param("path", serde_json::json!("/etc/passwd"));
assert!(engine.evaluate(&action).is_denied());
let action2 = Action::new("tool_call", "read_file", "agent-1")
.with_param("path", serde_json::json!("/tmp/safe.txt"));
assert!(engine.evaluate(&action2).is_allowed());
}
#[test]
fn test_condition_contains() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: cond-contains
description: test
version: "1"
rules:
- id: deny-secret
description: Deny commands containing 'secret'
match_on:
conditions:
- field: command
operator: contains
value: "secret"
effect:
type: deny
reason: "Contains secret"
- id: allow-all
description: allow
match_on: {}
effect:
type: allow
"#,
)
.unwrap();
let action = Action::new("tool_call", "bash", "a")
.with_param("command", serde_json::json!("cat /tmp/secret.txt"));
assert!(engine.evaluate(&action).is_denied());
let action2 = Action::new("tool_call", "bash", "a")
.with_param("command", serde_json::json!("ls /tmp"));
assert!(engine.evaluate(&action2).is_allowed());
}
#[test]
fn test_condition_greater_than() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: cond-gt
description: test
version: "1"
rules:
- id: deny-large-request
description: Deny large requests
match_on:
conditions:
- field: size
operator: greater_than
value: 1000
effect:
type: deny
reason: "Too large"
- id: allow-all
description: allow
match_on: {}
effect:
type: allow
"#,
)
.unwrap();
let action =
Action::new("tool_call", "upload", "a").with_param("size", serde_json::json!(5000));
assert!(engine.evaluate(&action).is_denied());
let action2 =
Action::new("tool_call", "upload", "a").with_param("size", serde_json::json!(500));
assert!(engine.evaluate(&action2).is_allowed());
}
#[test]
fn test_condition_exists() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: cond-exists
description: test
version: "1"
rules:
- id: require-token
description: Deny if no auth token present
match_on:
conditions:
- field: auth_token
operator: not_exists
value: null
effect:
type: deny
reason: "Missing auth token"
- id: allow-all
description: allow
match_on: {}
effect:
type: allow
"#,
)
.unwrap();
let action = Action::new("tool_call", "api_call", "a");
assert!(engine.evaluate(&action).is_denied());
let action2 = Action::new("tool_call", "api_call", "a")
.with_param("auth_token", serde_json::json!("abc123"));
assert!(engine.evaluate(&action2).is_allowed());
}
#[test]
fn test_condition_one_of() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: cond-oneof
description: test
version: "1"
rules:
- id: deny-unsafe-methods
description: Deny unsafe HTTP methods
match_on:
conditions:
- field: method
operator: one_of
value: ["DELETE", "PUT", "PATCH"]
effect:
type: deny
reason: "Unsafe HTTP method"
- id: allow-all
description: allow
match_on: {}
effect:
type: allow
"#,
)
.unwrap();
let action =
Action::new("tool_call", "http", "a").with_param("method", serde_json::json!("DELETE"));
assert!(engine.evaluate(&action).is_denied());
let action2 =
Action::new("tool_call", "http", "a").with_param("method", serde_json::json!("GET"));
assert!(engine.evaluate(&action2).is_allowed());
}
#[test]
fn test_condition_nested_field() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: cond-nested
description: test
version: "1"
rules:
- id: deny-admin
description: Deny admin role
match_on:
conditions:
- field: user.role
operator: equals
value: "admin"
effect:
type: deny
reason: "Admin access denied"
- id: allow-all
description: allow
match_on: {}
effect:
type: allow
"#,
)
.unwrap();
let action = Action::new("tool_call", "api", "a")
.with_param("user", serde_json::json!({"name": "root", "role": "admin"}));
assert!(engine.evaluate(&action).is_denied());
let action2 = Action::new("tool_call", "api", "a")
.with_param("user", serde_json::json!({"name": "bob", "role": "viewer"}));
assert!(engine.evaluate(&action2).is_allowed());
}
#[test]
fn test_glob_matches_helper() {
assert!(glob_matches("read_*", "read_file"));
assert!(glob_matches("read_*", "read_"));
assert!(!glob_matches("read_*", "write_file"));
assert!(glob_matches("?ead", "read"));
assert!(!glob_matches("?ead", "bread"));
assert!(glob_matches("[abc]_file", "a_file"));
assert!(!glob_matches("[abc]_file", "d_file"));
assert!(glob_matches("exact", "exact"));
assert!(!glob_matches("exact", "not_exact"));
assert!(glob_matches("file.txt", "file.txt"));
assert!(!glob_matches("file.txt", "filextxt"));
}
#[test]
fn test_glob_wildcard_star() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: glob-test
description: test
version: "1"
rules:
- id: allow-reads
description: Allow all read operations
match_on:
resources: ["read_*"]
effect:
type: allow
"#,
)
.unwrap();
assert!(
engine
.evaluate(&Action::new("t", "read_file", "a"))
.is_allowed()
);
assert!(
engine
.evaluate(&Action::new("t", "read_text_file", "a"))
.is_allowed()
);
assert!(
engine
.evaluate(&Action::new("t", "read_media_file", "a"))
.is_allowed()
);
assert!(
engine
.evaluate(&Action::new("t", "write_file", "a"))
.is_denied()
);
assert!(
engine
.evaluate(&Action::new("t", "pre_read_file", "a"))
.is_denied()
);
}
#[test]
fn test_glob_question_mark() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: glob-qmark
description: test
version: "1"
rules:
- id: deny-db-x
description: Deny db single-char suffix
match_on:
resources: ["db_?"]
effect:
type: deny
reason: "denied"
- id: allow-all
match_on: {}
description: allow
effect:
type: allow
"#,
)
.unwrap();
assert!(engine.evaluate(&Action::new("t", "db_x", "a")).is_denied());
assert!(
engine
.evaluate(&Action::new("t", "db_xy", "a"))
.is_allowed()
);
}
#[test]
fn test_glob_char_class() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: glob-class
description: test
version: "1"
rules:
- id: deny-levels
description: Deny log levels
match_on:
resources: ["log_[abc]"]
effect:
type: deny
reason: "denied"
- id: allow-all
match_on: {}
description: allow
effect:
type: allow
"#,
)
.unwrap();
assert!(engine.evaluate(&Action::new("t", "log_a", "a")).is_denied());
assert!(engine.evaluate(&Action::new("t", "log_b", "a")).is_denied());
assert!(
engine
.evaluate(&Action::new("t", "log_d", "a"))
.is_allowed()
);
}
#[test]
fn test_glob_exact_match_fast_path() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: exact
description: test
version: "1"
rules:
- id: deny-bash
match_on:
resources: ["bash"]
description: deny
effect:
type: deny
reason: "no"
"#,
)
.unwrap();
assert!(engine.evaluate(&Action::new("t", "bash", "a")).is_denied());
let decision = engine.evaluate(&Action::new("t", "basher", "a"));
assert!(decision.is_denied());
assert_eq!(decision.matched_rule(), "_default_deny");
}
#[test]
fn test_glob_on_agent_ids() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: agent-glob
description: test
version: "1"
rules:
- id: allow-trusted
description: Allow trusted agents
match_on:
agent_ids: ["trusted-*"]
effect:
type: allow
"#,
)
.unwrap();
assert!(
engine
.evaluate(&Action::new("t", "x", "trusted-agent-1"))
.is_allowed()
);
assert!(
engine
.evaluate(&Action::new("t", "x", "trusted-bot"))
.is_allowed()
);
assert!(
engine
.evaluate(&Action::new("t", "x", "untrusted"))
.is_denied()
);
}
#[test]
fn test_glob_on_action_types() {
let mut engine = Engine::new();
engine
.load_policy_yaml(
r#"
name: action-glob
description: test
version: "1"
rules:
- id: deny-file-ops
description: Deny file operations
match_on:
action_types: ["file_*"]
effect:
type: deny
reason: "no file ops"
- id: allow-all
match_on: {}
description: allow
effect:
type: allow
"#,
)
.unwrap();
assert!(
engine
.evaluate(&Action::new("file_read", "x", "a"))
.is_denied()
);
assert!(
engine
.evaluate(&Action::new("file_write", "x", "a"))
.is_denied()
);
assert!(
engine
.evaluate(&Action::new("tool_call", "x", "a"))
.is_allowed()
);
}
}