Skip to main content

car_policy/
lib.rs

1//! Policy engine — rules that govern what actions the runtime will allow.
2//!
3//! Two complementary mechanisms live here:
4//!
5//! 1. [`PolicyEngine`] — evaluates static policies against `(Action, StateStore)`
6//!    and collects every violation. Designed for pre-execution batch validation.
7//! 2. [`inspectors::InspectorChain`] — evaluates a short-circuiting chain of
8//!    inspectors against `(tool_name, params)` at dispatch time. Stops on the
9//!    first Deny. Designed for hot-path guardrails (egress, repetition,
10//!    adversary review).
11
12pub mod inspectors;
13
14pub use inspectors::{
15    load_adversary_rules_from, AdversaryInspector, EgressInspector, InspectionResult, Inspector,
16    InspectorChain, RepetitionInspector,
17};
18
19use car_ir::Action;
20use car_state::StateStore;
21use std::panic::{self, AssertUnwindSafe};
22
23/// A policy violation found during checking.
24#[derive(Debug, Clone)]
25pub struct PolicyViolation {
26    pub policy_name: String,
27    pub action_id: String,
28    pub reason: String,
29}
30
31/// Policy check function: `(action, state) -> Option<violation_reason>`
32pub type PolicyCheck = Box<dyn Fn(&Action, &StateStore) -> Option<String> + Send + Sync>;
33
34/// Evaluates actions against registered policies.
35pub struct PolicyEngine {
36    policies: Vec<(String, String, PolicyCheck)>, // (name, description, check_fn)
37}
38
39impl PolicyEngine {
40    pub fn new() -> Self {
41        Self {
42            policies: Vec::new(),
43        }
44    }
45
46    pub fn register(&mut self, name: &str, check: PolicyCheck, description: &str) {
47        self.policies
48            .push((name.to_string(), description.to_string(), check));
49    }
50
51    /// Check an action against all policies.
52    ///
53    /// If a policy check panics, the panic is caught and treated as a violation.
54    pub fn check(&self, action: &Action, state: &StateStore) -> Vec<PolicyViolation> {
55        let mut violations = Vec::new();
56
57        for (name, _, check_fn) in &self.policies {
58            let result = panic::catch_unwind(AssertUnwindSafe(|| check_fn(action, state)));
59
60            match result {
61                Ok(Some(reason)) => {
62                    violations.push(PolicyViolation {
63                        policy_name: name.clone(),
64                        action_id: action.id.clone(),
65                        reason,
66                    });
67                }
68                Ok(None) => {} // passed
69                Err(_) => {
70                    violations.push(PolicyViolation {
71                        policy_name: name.clone(),
72                        action_id: action.id.clone(),
73                        reason: format!("policy '{}' panicked during check", name),
74                    });
75                }
76            }
77        }
78
79        violations
80    }
81
82    pub fn policy_names(&self) -> Vec<String> {
83        self.policies.iter().map(|(n, _, _)| n.clone()).collect()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.policies.is_empty()
88    }
89}
90
91impl Default for PolicyEngine {
92    fn default() -> Self {
93        Self::new()
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use car_ir::{ActionType, FailureBehavior};
101    use serde_json::Value;
102    use std::collections::HashMap;
103
104    fn make_action(tool: &str) -> Action {
105        Action {
106            id: "test".to_string(),
107            action_type: ActionType::ToolCall,
108            tool: Some(tool.to_string()),
109            parameters: HashMap::new(),
110            preconditions: vec![],
111            expected_effects: HashMap::new(),
112            state_dependencies: vec![],
113            idempotent: false,
114            max_retries: 3,
115            failure_behavior: FailureBehavior::Abort,
116            timeout_ms: None,
117            metadata: HashMap::new(),
118        }
119    }
120
121    #[test]
122    fn no_policies_passes() {
123        let engine = PolicyEngine::new();
124        let state = StateStore::new();
125        let violations = engine.check(&make_action("echo"), &state);
126        assert!(violations.is_empty());
127    }
128
129    #[test]
130    fn policy_blocks_action() {
131        let mut engine = PolicyEngine::new();
132        engine.register(
133            "no_echo",
134            Box::new(|action, _state| {
135                if action.tool.as_deref() == Some("echo") {
136                    Some("echo is forbidden".to_string())
137                } else {
138                    None
139                }
140            }),
141            "Block echo tool",
142        );
143
144        let state = StateStore::new();
145        let violations = engine.check(&make_action("echo"), &state);
146        assert_eq!(violations.len(), 1);
147        assert!(violations[0].reason.contains("forbidden"));
148    }
149
150    #[test]
151    fn policy_allows_other_tools() {
152        let mut engine = PolicyEngine::new();
153        engine.register(
154            "no_echo",
155            Box::new(|action, _state| {
156                if action.tool.as_deref() == Some("echo") {
157                    Some("forbidden".to_string())
158                } else {
159                    None
160                }
161            }),
162            "",
163        );
164
165        let state = StateStore::new();
166        let violations = engine.check(&make_action("add"), &state);
167        assert!(violations.is_empty());
168    }
169
170    #[test]
171    fn policy_checks_state() {
172        let mut engine = PolicyEngine::new();
173        engine.register(
174            "require_auth",
175            Box::new(|_action, state| {
176                if state.get("auth") != Some(Value::Bool(true)) {
177                    Some("auth required".to_string())
178                } else {
179                    None
180                }
181            }),
182            "",
183        );
184
185        let state = StateStore::new();
186        let violations = engine.check(&make_action("deploy"), &state);
187        assert_eq!(violations.len(), 1);
188
189        state.set("auth", Value::Bool(true), "setup");
190        let violations2 = engine.check(&make_action("deploy"), &state);
191        assert!(violations2.is_empty());
192    }
193
194    #[test]
195    fn panicking_policy_caught() {
196        let mut engine = PolicyEngine::new();
197        engine.register(
198            "crasher",
199            Box::new(|_action, _state| {
200                panic!("policy crashed");
201            }),
202            "",
203        );
204
205        let state = StateStore::new();
206        let violations = engine.check(&make_action("anything"), &state);
207        assert_eq!(violations.len(), 1);
208        assert!(violations[0].reason.contains("panicked"));
209    }
210
211    #[test]
212    fn multiple_policies() {
213        let mut engine = PolicyEngine::new();
214        engine.register("p1", Box::new(|_, _| Some("fail 1".to_string())), "");
215        engine.register("p2", Box::new(|_, _| None), "");
216        engine.register("p3", Box::new(|_, _| Some("fail 3".to_string())), "");
217
218        let state = StateStore::new();
219        let violations = engine.check(&make_action("x"), &state);
220        assert_eq!(violations.len(), 2);
221    }
222
223    #[test]
224    fn policy_names() {
225        let mut engine = PolicyEngine::new();
226        engine.register("alpha", Box::new(|_, _| None), "");
227        engine.register("beta", Box::new(|_, _| None), "");
228        assert_eq!(engine.policy_names(), vec!["alpha", "beta"]);
229    }
230}