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