1pub fn degrades(success_count: u64, fail_count: u64, threshold: u64) -> bool {
27 fail_count > success_count + threshold
28}
29
30pub const DEGRADE_THRESHOLD: u64 = 2;
32
33pub mod agent_permissions;
34mod command_position;
35pub mod flow_gate;
36pub mod inspectors;
37pub mod intent_gate;
38pub mod permission;
39pub mod rules;
40pub mod skill_trust;
41pub mod tool_gate;
42
43pub use agent_permissions::{AgentPermissionPolicy, ApprovalMode, ApprovalPreset, TierPosture};
44pub use flow_gate::{enforce_flow, flow_fingerprint, FlowEnforcement, PendingFlowApproval};
45pub use intent_gate::{
46 enforce_intent, intent_fingerprint, IntentEnforcement, PendingIntentApproval,
47};
48
49pub use inspectors::{
50 load_adversary_rules_from, AdversaryInspector, EgressInspector, InspectionResult, Inspector,
51 InspectorChain, RepetitionInspector,
52};
53pub use permission::{
54 action_fingerprint, action_text, classify_reversibility, classify_reversibility_with_haystack,
55 ActionAxes, ApprovalDecision, ApprovalLedger, ApprovalRecord, GateDecision, PermissionGate,
56 PermissionTier, RiskClassifier,
57};
58pub use rules::{load_policy_dir, DenyToolParam, PolicyLoadError, PolicyRules};
59
60use car_ir::Action;
61use car_state::StateStore;
62use std::collections::BTreeSet;
63use std::panic::{self, AssertUnwindSafe};
64
65#[derive(Debug, Clone)]
67pub struct PolicyViolation {
68 pub policy_name: String,
69 pub action_id: String,
70 pub reason: String,
71}
72
73pub type PolicyCheck = Box<dyn Fn(&Action, &StateStore) -> Option<String> + Send + Sync>;
75
76pub struct PolicyEngine {
78 policies: Vec<(String, String, PolicyCheck)>, blanket_tool_denies: Vec<(String, String)>,
86}
87
88impl PolicyEngine {
89 pub fn new() -> Self {
90 Self {
91 policies: Vec::new(),
92 blanket_tool_denies: Vec::new(),
93 }
94 }
95
96 pub fn register(&mut self, name: &str, check: PolicyCheck, description: &str) {
97 self.policies
98 .push((name.to_string(), description.to_string(), check));
99 }
100
101 pub fn register_tool_deny(
115 &mut self,
116 name: &str,
117 tool: &str,
118 check: PolicyCheck,
119 description: &str,
120 ) {
121 self.register(name, check, description);
122 self.blanket_tool_denies
123 .push((name.to_string(), tool.to_string()));
124 }
125
126 pub fn check(&self, action: &Action, state: &StateStore) -> Vec<PolicyViolation> {
130 let mut violations = Vec::new();
131
132 for (name, _, check_fn) in &self.policies {
133 let result = panic::catch_unwind(AssertUnwindSafe(|| check_fn(action, state)));
134
135 match result {
136 Ok(Some(reason)) => {
137 violations.push(PolicyViolation {
138 policy_name: name.clone(),
139 action_id: action.id.clone(),
140 reason,
141 });
142 }
143 Ok(None) => {} Err(_) => {
145 violations.push(PolicyViolation {
146 policy_name: name.clone(),
147 action_id: action.id.clone(),
148 reason: format!("policy '{}' panicked during check", name),
149 });
150 }
151 }
152 }
153
154 violations
155 }
156
157 pub fn unregister(&mut self, name: &str) -> usize {
165 let before = self.policies.len();
166 self.policies.retain(|(n, _, _)| n != name);
167 self.blanket_tool_denies.retain(|(n, _)| n != name);
170 before - self.policies.len()
171 }
172
173 pub fn clear(&mut self) -> usize {
175 let n = self.policies.len();
176 self.policies.clear();
177 self.blanket_tool_denies.clear();
178 n
179 }
180
181 pub fn blanket_denied_tools(&self) -> BTreeSet<String> {
220 self.blanket_tool_denies
221 .iter()
222 .map(|(_, tool)| tool.clone())
223 .collect()
224 }
225
226 pub fn policy_names(&self) -> Vec<String> {
229 self.policies.iter().map(|(n, _, _)| n.clone()).collect()
230 }
231
232 pub fn policy_details(&self) -> Vec<(String, String)> {
235 self.policies
236 .iter()
237 .map(|(n, d, _)| (n.clone(), d.clone()))
238 .collect()
239 }
240
241 pub fn is_empty(&self) -> bool {
242 self.policies.is_empty()
243 }
244}
245
246impl Default for PolicyEngine {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use car_ir::ActionType;
256 use serde_json::Value;
257
258 fn make_action(tool: &str) -> Action {
259 {
260 let mut a = Action::new(ActionType::ToolCall);
261 a.id = "test".to_string();
262 a.tool = Some(tool.to_string());
263 a
264 }
265 }
266
267 #[test]
268 fn no_policies_passes() {
269 let engine = PolicyEngine::new();
270 let state = StateStore::new();
271 let violations = engine.check(&make_action("echo"), &state);
272 assert!(violations.is_empty());
273 }
274
275 #[test]
276 fn policy_blocks_action() {
277 let mut engine = PolicyEngine::new();
278 engine.register(
279 "no_echo",
280 Box::new(|action, _state| {
281 if action.tool.as_deref() == Some("echo") {
282 Some("echo is forbidden".to_string())
283 } else {
284 None
285 }
286 }),
287 "Block echo tool",
288 );
289
290 let state = StateStore::new();
291 let violations = engine.check(&make_action("echo"), &state);
292 assert_eq!(violations.len(), 1);
293 assert!(violations[0].reason.contains("forbidden"));
294 }
295
296 #[test]
297 fn policy_allows_other_tools() {
298 let mut engine = PolicyEngine::new();
299 engine.register(
300 "no_echo",
301 Box::new(|action, _state| {
302 if action.tool.as_deref() == Some("echo") {
303 Some("forbidden".to_string())
304 } else {
305 None
306 }
307 }),
308 "",
309 );
310
311 let state = StateStore::new();
312 let violations = engine.check(&make_action("add"), &state);
313 assert!(violations.is_empty());
314 }
315
316 #[test]
317 fn policy_checks_state() {
318 let mut engine = PolicyEngine::new();
319 engine.register(
320 "require_auth",
321 Box::new(|_action, state| {
322 if state.get("auth") != Some(Value::Bool(true)) {
323 Some("auth required".to_string())
324 } else {
325 None
326 }
327 }),
328 "",
329 );
330
331 let state = StateStore::new();
332 let violations = engine.check(&make_action("deploy"), &state);
333 assert_eq!(violations.len(), 1);
334
335 state.set("auth", Value::Bool(true), "setup");
336 let violations2 = engine.check(&make_action("deploy"), &state);
337 assert!(violations2.is_empty());
338 }
339
340 #[test]
341 fn panicking_policy_caught() {
342 let mut engine = PolicyEngine::new();
343 engine.register(
344 "crasher",
345 Box::new(|_action, _state| {
346 panic!("policy crashed");
347 }),
348 "",
349 );
350
351 let state = StateStore::new();
352 let violations = engine.check(&make_action("anything"), &state);
353 assert_eq!(violations.len(), 1);
354 assert!(violations[0].reason.contains("panicked"));
355 }
356
357 #[test]
358 fn multiple_policies() {
359 let mut engine = PolicyEngine::new();
360 engine.register("p1", Box::new(|_, _| Some("fail 1".to_string())), "");
361 engine.register("p2", Box::new(|_, _| None), "");
362 engine.register("p3", Box::new(|_, _| Some("fail 3".to_string())), "");
363
364 let state = StateStore::new();
365 let violations = engine.check(&make_action("x"), &state);
366 assert_eq!(violations.len(), 2);
367 }
368
369 #[test]
370 fn policy_names() {
371 let mut engine = PolicyEngine::new();
372 engine.register("alpha", Box::new(|_, _| None), "");
373 engine.register("beta", Box::new(|_, _| None), "");
374 assert_eq!(engine.policy_names(), vec!["alpha", "beta"]);
375 }
376
377 #[test]
379 fn unregister_removes_the_policy_and_stops_enforcement() {
380 let mut engine = PolicyEngine::new();
381 engine.register("deny", Box::new(|_, _| Some("nope".to_string())), "");
382 engine.register("keep", Box::new(|_, _| None), "");
383 let state = StateStore::new();
384 assert_eq!(engine.check(&make_action("x"), &state).len(), 1);
385
386 assert_eq!(engine.unregister("deny"), 1);
387 assert_eq!(engine.policy_names(), vec!["keep"]);
388 assert!(
389 engine.check(&make_action("x"), &state).is_empty(),
390 "an unregistered policy must stop being enforced"
391 );
392 }
393
394 #[test]
395 fn unregister_reports_zero_when_nothing_matched() {
396 let mut engine = PolicyEngine::new();
397 engine.register("alpha", Box::new(|_, _| None), "");
398 assert_eq!(engine.unregister("nosuch"), 0);
399 assert_eq!(engine.policy_names(), vec!["alpha"]);
400 }
401
402 #[test]
405 fn unregister_removes_every_policy_sharing_the_name() {
406 let mut engine = PolicyEngine::new();
407 engine.register("dup", Box::new(|_, _| Some("a".to_string())), "");
408 engine.register("dup", Box::new(|_, _| Some("b".to_string())), "");
409 let state = StateStore::new();
410 assert_eq!(engine.check(&make_action("x"), &state).len(), 2);
411
412 assert_eq!(engine.unregister("dup"), 2);
413 assert!(engine.is_empty());
414 assert!(engine.check(&make_action("x"), &state).is_empty());
415 }
416
417 #[test]
418 fn clear_drops_everything() {
419 let mut engine = PolicyEngine::new();
420 engine.register("a", Box::new(|_, _| None), "");
421 engine.register("b", Box::new(|_, _| None), "");
422 assert_eq!(engine.clear(), 2);
423 assert!(engine.is_empty());
424 }
425
426 fn engine_from_toml(src: &str) -> PolicyEngine {
431 let mut engine = PolicyEngine::new();
432 PolicyRules::from_toml(src)
433 .expect("fixture policy must parse")
434 .apply(&mut engine);
435 engine
436 }
437
438 #[test]
439 fn blanket_denied_tools_reads_back_what_apply_registered() {
440 let engine = engine_from_toml("deny_tool = [\"shell\", \"deploy\"]\n");
441 let denied = engine.blanket_denied_tools();
442 assert!(denied.contains("shell"), "got {denied:?}");
443 assert!(denied.contains("deploy"), "got {denied:?}");
444 assert!(!denied.contains("read_file"), "got {denied:?}");
446 assert_eq!(denied.len(), 2, "got {denied:?}");
447 }
448
449 #[test]
450 fn blanket_denied_tools_excludes_argument_dependent_rules() {
451 let engine = engine_from_toml(
454 "deny_keyword = [\"rm -rf /\"]\n\n\
455 [[deny_tool_param]]\n\
456 tool = \"shell\"\n\
457 param = \"command\"\n\
458 contains = \"sudo\"\n\n\
459 [[allow_tool_param]]\n\
460 tool = \"deploy\"\n\
461 param = \"env\"\n\
462 allow = [\"staging\"]\n",
463 );
464 assert!(
465 engine.blanket_denied_tools().is_empty(),
466 "argument-dependent rules must not hide their tool: {:?}",
467 engine.blanket_denied_tools()
468 );
469
470 let state = StateStore::new();
474 let mut sudo = make_action("shell");
475 sudo.parameters.insert(
476 "command".to_string(),
477 Value::String("sudo reboot".to_string()),
478 );
479 assert!(
480 !engine.check(&sudo, &state).is_empty(),
481 "deny_tool_param must still refuse the offending call"
482 );
483
484 let mut staging = make_action("deploy");
495 staging
496 .parameters
497 .insert("env".to_string(), Value::String("staging".to_string()));
498 assert!(
499 engine.check(&staging, &state).is_empty(),
500 "the allowlisted value must be permitted"
501 );
502 let mut prod = make_action("deploy");
503 prod.parameters
504 .insert("env".to_string(), Value::String("prod".to_string()));
505 assert!(
506 !engine.check(&prod, &state).is_empty(),
507 "a value outside the allowlist must be refused"
508 );
509 }
510
511 #[test]
512 fn blanket_denied_tools_follows_unregister() {
513 let mut engine = engine_from_toml("deny_tool = [\"shell\"]\n");
517 assert!(engine.blanket_denied_tools().contains("shell"));
518
519 let name = engine
523 .policy_names()
524 .into_iter()
525 .find(|n| n.contains("shell"))
526 .expect("apply must register a named check for the denied tool");
527 assert_eq!(engine.unregister(&name), 1);
528 assert!(engine.blanket_denied_tools().is_empty());
529
530 let state = StateStore::new();
532 assert!(engine.check(&make_action("shell"), &state).is_empty());
533 }
534
535 #[test]
543 fn a_tool_deny_is_found_under_any_policy_name() {
544 let mut engine = PolicyEngine::new();
545 engine.register_tool_deny(
546 "no_shell",
547 "shell",
548 Box::new(|action, _| {
549 (action.tool.as_deref() == Some("shell")).then(|| "tool 'shell' denied".to_string())
550 }),
551 "",
552 );
553 assert!(engine.blanket_denied_tools().contains("shell"));
554
555 let mut spoof = PolicyEngine::new();
560 spoof.register("deny_tool:shell", Box::new(|_, _| None), "");
561 assert!(
562 spoof.blanket_denied_tools().is_empty(),
563 "a name is not a declaration"
564 );
565 }
566
567 #[test]
574 fn a_misspelled_policy_field_is_refused_not_reinterpreted() {
575 let err = PolicyRules::from_toml(
576 "[[allow_tool_param]]\n\
577 tool = \"deploy\"\n\
578 param = \"env\"\n\
579 values = [\"staging\"]\n",
580 )
581 .expect_err("an unknown field must be an error");
582 let msg = err.to_string();
583 assert!(
584 msg.contains("values"),
585 "the error must name the field: {msg}"
586 );
587
588 PolicyRules::from_toml(
591 "[[allow_tool_param]]\n\
592 tool = \"deploy\"\n\
593 param = \"env\"\n\
594 allow = [\"staging\"]\n",
595 )
596 .expect("the correct spelling must still parse");
597 }
598
599 #[test]
600 fn policy_details_carries_descriptions() {
601 let mut engine = PolicyEngine::new();
602 engine.register("alpha", Box::new(|_, _| None), "first one");
603 assert_eq!(
604 engine.policy_details(),
605 vec![("alpha".to_string(), "first one".to_string())]
606 );
607 }
608}