car-policy 0.15.2

Policy engine for Common Agent Runtime
Documentation
//! Policy engine — rules that govern what actions the runtime will allow.
//!
//! Two complementary mechanisms live here:
//!
//! 1. [`PolicyEngine`] — evaluates static policies against `(Action, StateStore)`
//!    and collects every violation. Designed for pre-execution batch validation.
//! 2. [`inspectors::InspectorChain`] — evaluates a short-circuiting chain of
//!    inspectors against `(tool_name, params)` at dispatch time. Stops on the
//!    first Deny. Designed for hot-path guardrails (egress, repetition,
//!    adversary review).

pub mod inspectors;

pub use inspectors::{
    load_adversary_rules_from, AdversaryInspector, EgressInspector, InspectionResult, Inspector,
    InspectorChain, RepetitionInspector,
};

use car_ir::Action;
use car_state::StateStore;
use std::panic::{self, AssertUnwindSafe};

/// A policy violation found during checking.
#[derive(Debug, Clone)]
pub struct PolicyViolation {
    pub policy_name: String,
    pub action_id: String,
    pub reason: String,
}

/// Policy check function: `(action, state) -> Option<violation_reason>`
pub type PolicyCheck = Box<dyn Fn(&Action, &StateStore) -> Option<String> + Send + Sync>;

/// Evaluates actions against registered policies.
pub struct PolicyEngine {
    policies: Vec<(String, String, PolicyCheck)>, // (name, description, check_fn)
}

impl PolicyEngine {
    pub fn new() -> Self {
        Self {
            policies: Vec::new(),
        }
    }

    pub fn register(&mut self, name: &str, check: PolicyCheck, description: &str) {
        self.policies
            .push((name.to_string(), description.to_string(), check));
    }

    /// Check an action against all policies.
    ///
    /// If a policy check panics, the panic is caught and treated as a violation.
    pub fn check(&self, action: &Action, state: &StateStore) -> Vec<PolicyViolation> {
        let mut violations = Vec::new();

        for (name, _, check_fn) in &self.policies {
            let result = panic::catch_unwind(AssertUnwindSafe(|| check_fn(action, state)));

            match result {
                Ok(Some(reason)) => {
                    violations.push(PolicyViolation {
                        policy_name: name.clone(),
                        action_id: action.id.clone(),
                        reason,
                    });
                }
                Ok(None) => {} // passed
                Err(_) => {
                    violations.push(PolicyViolation {
                        policy_name: name.clone(),
                        action_id: action.id.clone(),
                        reason: format!("policy '{}' panicked during check", name),
                    });
                }
            }
        }

        violations
    }

    pub fn policy_names(&self) -> Vec<String> {
        self.policies.iter().map(|(n, _, _)| n.clone()).collect()
    }

    pub fn is_empty(&self) -> bool {
        self.policies.is_empty()
    }
}

impl Default for PolicyEngine {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use car_ir::{ActionType, FailureBehavior};
    use serde_json::Value;
    use std::collections::HashMap;

    fn make_action(tool: &str) -> Action {
        Action {
            id: "test".to_string(),
            action_type: ActionType::ToolCall,
            tool: Some(tool.to_string()),
            parameters: HashMap::new(),
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            idempotent: false,
            max_retries: 3,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: None,
            metadata: HashMap::new(),
        }
    }

    #[test]
    fn no_policies_passes() {
        let engine = PolicyEngine::new();
        let state = StateStore::new();
        let violations = engine.check(&make_action("echo"), &state);
        assert!(violations.is_empty());
    }

    #[test]
    fn policy_blocks_action() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "no_echo",
            Box::new(|action, _state| {
                if action.tool.as_deref() == Some("echo") {
                    Some("echo is forbidden".to_string())
                } else {
                    None
                }
            }),
            "Block echo tool",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("echo"), &state);
        assert_eq!(violations.len(), 1);
        assert!(violations[0].reason.contains("forbidden"));
    }

    #[test]
    fn policy_allows_other_tools() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "no_echo",
            Box::new(|action, _state| {
                if action.tool.as_deref() == Some("echo") {
                    Some("forbidden".to_string())
                } else {
                    None
                }
            }),
            "",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("add"), &state);
        assert!(violations.is_empty());
    }

    #[test]
    fn policy_checks_state() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "require_auth",
            Box::new(|_action, state| {
                if state.get("auth") != Some(Value::Bool(true)) {
                    Some("auth required".to_string())
                } else {
                    None
                }
            }),
            "",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("deploy"), &state);
        assert_eq!(violations.len(), 1);

        state.set("auth", Value::Bool(true), "setup");
        let violations2 = engine.check(&make_action("deploy"), &state);
        assert!(violations2.is_empty());
    }

    #[test]
    fn panicking_policy_caught() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "crasher",
            Box::new(|_action, _state| {
                panic!("policy crashed");
            }),
            "",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("anything"), &state);
        assert_eq!(violations.len(), 1);
        assert!(violations[0].reason.contains("panicked"));
    }

    #[test]
    fn multiple_policies() {
        let mut engine = PolicyEngine::new();
        engine.register("p1", Box::new(|_, _| Some("fail 1".to_string())), "");
        engine.register("p2", Box::new(|_, _| None), "");
        engine.register("p3", Box::new(|_, _| Some("fail 3".to_string())), "");

        let state = StateStore::new();
        let violations = engine.check(&make_action("x"), &state);
        assert_eq!(violations.len(), 2);
    }

    #[test]
    fn policy_names() {
        let mut engine = PolicyEngine::new();
        engine.register("alpha", Box::new(|_, _| None), "");
        engine.register("beta", Box::new(|_, _| None), "");
        assert_eq!(engine.policy_names(), vec!["alpha", "beta"]);
    }
}