1pub 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#[derive(Debug, Clone)]
25pub struct PolicyViolation {
26 pub policy_name: String,
27 pub action_id: String,
28 pub reason: String,
29}
30
31pub type PolicyCheck = Box<dyn Fn(&Action, &StateStore) -> Option<String> + Send + Sync>;
33
34pub struct PolicyEngine {
36 policies: Vec<(String, String, PolicyCheck)>, }
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 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) => {} 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}