use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::spec_ai_config::persistence::Persistence;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PolicyEffect {
Allow,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRule {
pub agent: String,
pub action: String,
pub resource: String,
pub effect: PolicyEffect,
}
impl PolicyRule {
pub fn matches(&self, agent: &str, action: &str, resource: &str) -> bool {
wildcard_match(&self.agent, agent)
&& wildcard_match(&self.action, action)
&& wildcard_match(&self.resource, resource)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PolicySet {
pub rules: Vec<PolicyRule>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyDecision {
Allow,
Deny(String),
}
#[derive(Debug, Clone)]
pub struct PolicyEngine {
policy_set: PolicySet,
}
impl PolicyEngine {
pub fn new() -> Self {
Self {
policy_set: PolicySet::default(),
}
}
pub fn with_policy_set(policy_set: PolicySet) -> Self {
Self { policy_set }
}
pub fn load_from_persistence(persistence: &Persistence) -> Result<Self> {
match persistence.policy_get("policies")? {
Some(entry) => {
let policy_set: PolicySet = serde_json::from_value(entry.value)
.context("deserializing policy set from cache")?;
Ok(Self::with_policy_set(policy_set))
}
None => {
Ok(Self::new())
}
}
}
pub fn save_to_persistence(&self, persistence: &Persistence) -> Result<()> {
let value = serde_json::to_value(&self.policy_set).context("serializing policy set")?;
persistence.policy_upsert("policies", &value)?;
Ok(())
}
pub fn reload(&mut self, persistence: &Persistence) -> Result<()> {
let engine = Self::load_from_persistence(persistence)?;
self.policy_set = engine.policy_set;
Ok(())
}
pub fn check(&self, agent: &str, action: &str, resource: &str) -> PolicyDecision {
for rule in &self.policy_set.rules {
if rule.matches(agent, action, resource) {
return match rule.effect {
PolicyEffect::Allow => PolicyDecision::Allow,
PolicyEffect::Deny => PolicyDecision::Deny(format!(
"Policy denies {} action {} on resource {}",
agent, action, resource
)),
};
}
}
PolicyDecision::Deny(format!(
"No policy rule matches agent '{}', action '{}', resource '{}' (default deny)",
agent, action, resource
))
}
pub fn rule_count(&self) -> usize {
self.policy_set.rules.len()
}
pub fn add_rule(&mut self, rule: PolicyRule) {
self.policy_set.rules.push(rule);
}
pub fn policy_set(&self) -> &PolicySet {
&self.policy_set
}
}
impl Default for PolicyEngine {
fn default() -> Self {
Self::new()
}
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
if pattern == "*" {
return true;
}
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
let mut text_pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if let Some(pos) = text[text_pos..].find(part) {
text_pos += pos + part.len();
} else {
return false;
}
if i == parts.len() - 1 && !pattern.ends_with('*') {
return text.ends_with(part);
}
}
if !pattern.starts_with('*') && !parts.is_empty() && !parts[0].is_empty() {
return text.starts_with(parts[0]);
}
true
} else {
pattern == text
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wildcard_match_exact() {
assert!(wildcard_match("hello", "hello"));
assert!(!wildcard_match("hello", "world"));
assert!(!wildcard_match("hello", "hello_world"));
}
#[test]
fn test_wildcard_match_star() {
assert!(wildcard_match("*", "anything"));
assert!(wildcard_match("*", ""));
assert!(wildcard_match("*", "foo/bar/baz"));
}
#[test]
fn test_wildcard_match_prefix() {
assert!(wildcard_match("hello*", "hello"));
assert!(wildcard_match("hello*", "hello_world"));
assert!(wildcard_match("hello*", "hello123"));
assert!(!wildcard_match("hello*", "hi_world"));
}
#[test]
fn test_wildcard_match_suffix() {
assert!(wildcard_match("*world", "world"));
assert!(wildcard_match("*world", "hello_world"));
assert!(!wildcard_match("*world", "world_hello"));
}
#[test]
fn test_wildcard_match_middle() {
assert!(wildcard_match("hello*world", "helloworld"));
assert!(wildcard_match("hello*world", "hello_beautiful_world"));
assert!(!wildcard_match("hello*world", "hello"));
assert!(!wildcard_match("hello*world", "world"));
}
#[test]
fn test_wildcard_match_multiple() {
assert!(wildcard_match("/etc/*/*.conf", "/etc/nginx/nginx.conf"));
assert!(wildcard_match("/etc/*/*.conf", "/etc/apache2/apache2.conf"));
assert!(!wildcard_match(
"/etc/*/*.conf",
"/etc/nginx/sites-available/default"
));
}
#[test]
fn test_policy_rule_matches() {
let rule = PolicyRule {
agent: "coder".to_string(),
action: "tool_call".to_string(),
resource: "echo".to_string(),
effect: PolicyEffect::Allow,
};
assert!(rule.matches("coder", "tool_call", "echo"));
assert!(!rule.matches("assistant", "tool_call", "echo"));
assert!(!rule.matches("coder", "file_write", "echo"));
assert!(!rule.matches("coder", "tool_call", "calculator"));
}
#[test]
fn test_policy_rule_wildcard_agent() {
let rule = PolicyRule {
agent: "*".to_string(),
action: "tool_call".to_string(),
resource: "echo".to_string(),
effect: PolicyEffect::Allow,
};
assert!(rule.matches("coder", "tool_call", "echo"));
assert!(rule.matches("assistant", "tool_call", "echo"));
assert!(rule.matches("any_agent", "tool_call", "echo"));
}
#[test]
fn test_policy_rule_wildcard_resource() {
let rule = PolicyRule {
agent: "coder".to_string(),
action: "tool_call".to_string(),
resource: "*".to_string(),
effect: PolicyEffect::Allow,
};
assert!(rule.matches("coder", "tool_call", "echo"));
assert!(rule.matches("coder", "tool_call", "calculator"));
assert!(rule.matches("coder", "tool_call", "any_tool"));
}
#[test]
fn test_policy_engine_allow() {
let mut engine = PolicyEngine::new();
engine.add_rule(PolicyRule {
agent: "coder".to_string(),
action: "tool_call".to_string(),
resource: "echo".to_string(),
effect: PolicyEffect::Allow,
});
assert_eq!(
engine.check("coder", "tool_call", "echo"),
PolicyDecision::Allow
);
}
#[test]
fn test_policy_engine_deny() {
let mut engine = PolicyEngine::new();
engine.add_rule(PolicyRule {
agent: "coder".to_string(),
action: "bash".to_string(),
resource: "/etc/*".to_string(),
effect: PolicyEffect::Deny,
});
match engine.check("coder", "bash", "/etc/passwd") {
PolicyDecision::Deny(_) => {}
_ => panic!("Expected deny decision"),
}
}
#[test]
fn test_policy_engine_first_match_wins() {
let mut engine = PolicyEngine::new();
engine.add_rule(PolicyRule {
agent: "*".to_string(),
action: "bash".to_string(),
resource: "*".to_string(),
effect: PolicyEffect::Deny,
});
engine.add_rule(PolicyRule {
agent: "coder".to_string(),
action: "bash".to_string(),
resource: "*".to_string(),
effect: PolicyEffect::Allow,
});
match engine.check("coder", "bash", "/tmp/test.sh") {
PolicyDecision::Deny(_) => {}
_ => panic!("Expected deny decision from first rule"),
}
}
#[test]
fn test_policy_engine_default_deny() {
let engine = PolicyEngine::new();
match engine.check("agent", "action", "resource") {
PolicyDecision::Deny(reason) => {
assert!(reason.contains("No policy rule matches"));
}
_ => panic!("Expected default deny"),
}
}
#[test]
fn test_policy_engine_rule_count() {
let mut engine = PolicyEngine::new();
assert_eq!(engine.rule_count(), 0);
engine.add_rule(PolicyRule {
agent: "*".to_string(),
action: "*".to_string(),
resource: "*".to_string(),
effect: PolicyEffect::Allow,
});
assert_eq!(engine.rule_count(), 1);
}
#[test]
fn test_policy_serialization() {
let policy_set = PolicySet {
rules: vec![
PolicyRule {
agent: "coder".to_string(),
action: "tool_call".to_string(),
resource: "echo".to_string(),
effect: PolicyEffect::Allow,
},
PolicyRule {
agent: "*".to_string(),
action: "bash".to_string(),
resource: "/etc/*".to_string(),
effect: PolicyEffect::Deny,
},
],
};
let json = serde_json::to_value(&policy_set).unwrap();
let deserialized: PolicySet = serde_json::from_value(json).unwrap();
assert_eq!(deserialized.rules.len(), 2);
assert_eq!(deserialized.rules[0].agent, "coder");
assert_eq!(deserialized.rules[1].effect, PolicyEffect::Deny);
}
#[test]
fn test_policy_persistence() {
use crate::spec_ai_config::test_utils::create_test_db;
let persistence = create_test_db();
let mut engine = PolicyEngine::new();
engine.add_rule(PolicyRule {
agent: "coder".to_string(),
action: "tool_call".to_string(),
resource: "echo".to_string(),
effect: PolicyEffect::Allow,
});
engine.add_rule(PolicyRule {
agent: "*".to_string(),
action: "bash".to_string(),
resource: "*".to_string(),
effect: PolicyEffect::Deny,
});
engine.save_to_persistence(&persistence).unwrap();
let loaded = PolicyEngine::load_from_persistence(&persistence).unwrap();
assert_eq!(loaded.rule_count(), 2);
assert_eq!(
loaded.check("coder", "tool_call", "echo"),
PolicyDecision::Allow
);
match loaded.check("coder", "bash", "/tmp/test.sh") {
PolicyDecision::Deny(_) => {}
_ => panic!("Expected deny"),
}
}
#[test]
fn test_policy_reload() {
use crate::spec_ai_config::test_utils::create_test_db;
let persistence = create_test_db();
let mut engine = PolicyEngine::new();
engine.add_rule(PolicyRule {
agent: "coder".to_string(),
action: "tool_call".to_string(),
resource: "echo".to_string(),
effect: PolicyEffect::Allow,
});
engine.save_to_persistence(&persistence).unwrap();
let mut engine2 = PolicyEngine::new();
engine2.add_rule(PolicyRule {
agent: "*".to_string(),
action: "*".to_string(),
resource: "*".to_string(),
effect: PolicyEffect::Deny,
});
engine2.save_to_persistence(&persistence).unwrap();
engine.reload(&persistence).unwrap();
assert_eq!(engine.rule_count(), 1);
match engine.check("coder", "tool_call", "echo") {
PolicyDecision::Deny(_) => {}
_ => panic!("Expected deny after reload"),
}
}
#[test]
fn test_load_empty_persistence() {
use crate::spec_ai_config::test_utils::create_test_db;
let persistence = create_test_db();
let engine = PolicyEngine::load_from_persistence(&persistence).unwrap();
assert_eq!(engine.rule_count(), 0);
match engine.check("agent", "action", "resource") {
PolicyDecision::Deny(_) => {}
_ => panic!("Expected default deny"),
}
}
}