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
12/// Whether a track record has degraded: failures outrun successes by more than
13/// `threshold`.
14///
15/// **One definition, three callers.** `car-memgine` auto-degrades a skill on
16/// this rule, `skill_trust` demotes a skill's trust tier on it — its own doc
17/// said it was "the same rule car-memgine uses" while hardcoding a second copy
18/// of the threshold — and peer standing is the third. Three copies of a
19/// predicate that must agree is how they stop agreeing; `car-policy` is the
20/// only legal home because `car-memgine` already depends on it and never the
21/// reverse.
22///
23/// Deliberately asymmetric: it takes more than a bare majority of failures to
24/// degrade, so a new record with one failure and no successes is not condemned
25/// on its first bad day.
26pub fn degrades(success_count: u64, fail_count: u64, threshold: u64) -> bool {
27    fail_count > success_count + threshold
28}
29
30/// The threshold every degradation check uses unless a caller has a reason.
31pub 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/// A policy violation found during checking.
65#[derive(Debug, Clone)]
66pub struct PolicyViolation {
67    pub policy_name: String,
68    pub action_id: String,
69    pub reason: String,
70}
71
72/// Policy check function: `(action, state) -> Option<violation_reason>`
73pub type PolicyCheck = Box<dyn Fn(&Action, &StateStore) -> Option<String> + Send + Sync>;
74
75/// Evaluates actions against registered policies.
76pub struct PolicyEngine {
77    policies: Vec<(String, String, PolicyCheck)>, // (name, description, check_fn)
78    /// `(policy name, tool)` for every check registered through
79    /// [`PolicyEngine::register_tool_deny`] — the checks that forbid a tool
80    /// outright. Recorded structurally rather than recovered by parsing
81    /// `policies`' names: a name is caller-chosen on some registration paths,
82    /// so a convention would both miss real denies and let an unrelated check
83    /// registered under the right-looking name claim to be one.
84    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    /// [`Self::register`], plus the standing fact that `check` forbids `tool`
101    /// **outright** — whatever the arguments.
102    ///
103    /// Use this for a rule that denies a whole tool, so it can be read back by
104    /// [`Self::blanket_denied_tools`]. The check itself is still the caller's,
105    /// deliberately: a file-loaded rule and a host-registered one word their
106    /// refusal differently, and centralizing the closure here would make one of
107    /// those messages wrong.
108    ///
109    /// Registering a *conditional* check through this door mislabels it and
110    /// will hide a tool that is only sometimes denied. There is no way to check
111    /// that from here — the check is an opaque closure — so the contract is on
112    /// the caller.
113    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    /// Check an action against all policies.
126    ///
127    /// If a policy check panics, the panic is caught and treated as a violation.
128    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) => {} // passed
143                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    /// Remove every policy registered under `name`. Returns how many were
157    /// dropped — 0 when nothing matched, so the caller can distinguish "removed"
158    /// from "there was nothing by that name".
159    ///
160    /// [`Self::register`] appends without de-duplicating, so the same name can
161    /// legitimately appear more than once; this removes all of them rather than
162    /// leaving a shadowed copy still enforcing (Parslee-ai/car#623).
163    pub fn unregister(&mut self, name: &str) -> usize {
164        let before = self.policies.len();
165        self.policies.retain(|(n, _, _)| n != name);
166        // Lifting the rule must lift the concealment with it, or a tool stays
167        // hidden from the model that the engine no longer blocks.
168        self.blanket_tool_denies.retain(|(n, _)| n != name);
169        before - self.policies.len()
170    }
171
172    /// Drop every registered policy. Returns how many were removed.
173    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    /// The tools this engine forbids **outright**, whatever the arguments —
181    /// the `deny_tool` rule kind, and only that kind.
182    ///
183    /// For callers that assemble the tool list a model is shown. A tool no
184    /// call can ever satisfy is worth removing from that list rather than
185    /// advertising and then refusing: the refusal is correct but costs a turn
186    /// and a schema's worth of context on every request.
187    ///
188    /// # Why only `deny_tool`
189    ///
190    /// Not because the other kinds are "argument-dependent" — several of them
191    /// can forbid a tool outright too:
192    ///
193    /// - `allow_tool_param` with an empty `allow` permits nothing.
194    /// - `rate_limit_tool` with `max_calls = 0` denies every call.
195    /// - `deny_tool_param_matching` whose pattern fails to compile denies the
196    ///   whole tool, fail-closed.
197    /// - `deny_tool_param` with neither `equals` nor `contains` denies on the
198    ///   parameter's mere presence, which is total for a required parameter.
199    ///
200    /// The property that actually separates them is **whether totality is
201    /// decidable from the rule kind alone, without reading the rule's
202    /// contents**. `deny_tool` is the only kind where it is. The others are
203    /// total only by inspection, and a caller assembling a tool list has no
204    /// business inspecting them — so this deliberately under-reports rather
205    /// than guess. Under-reporting is the safe direction: the tool is
206    /// advertised and then refused, which is exactly the old behavior.
207    ///
208    /// A narrow-but-not-total `allow_tool_param` is the case this leaves on
209    /// the table. Hiding is the wrong remedy there (the tool *is* callable,
210    /// just not that way); narrowing the advertised parameter schema to the
211    /// permitted values is, and that is a separate change.
212    ///
213    /// Populated by [`Self::register_tool_deny`] and emptied in step with
214    /// [`Self::unregister`] and [`Self::clear`], so it cannot name a tool the
215    /// engine no longer blocks. A stale entry here would hide a tool that is
216    /// actually allowed, which is the one failure mode worth engineering
217    /// against.
218    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    /// Registered policy names, in registration order. May contain duplicates —
226    /// see [`Self::unregister`].
227    pub fn policy_names(&self) -> Vec<String> {
228        self.policies.iter().map(|(n, _, _)| n.clone()).collect()
229    }
230
231    /// Registered policies as `(name, description)` pairs, in registration
232    /// order. The description is what `register` was given.
233    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    /// Parslee-ai/car#623 — a registered policy had no way back out.
377    #[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    /// `register` appends without de-duplicating, so the same name can appear
402    /// twice; removing only one would leave a shadowed copy still enforcing.
403    #[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    /// The engine is built by the REAL producer — `PolicyRules::apply` — not by
426    /// hand-registering a `deny_tool:` name here. A fixture that spells the
427    /// name itself agrees with any spelling `apply` happens to use, including a
428    /// wrong one.
429    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        // Positive control: the set is not simply "everything".
444        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        // Every rule kind that turns on the ARGUMENTS of a specific call. None
451        // of them forbids the tool, so none may hide it from a model.
452        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        // Positive control: those rules really did load and really do deny, so
470        // the emptiness above is about the RULE KIND and not about a fixture
471        // that quietly failed to register anything.
472        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        // And the NARROWNESS control, which is the whole reason these tools
484        // stay advertised: the allowlisted call is genuinely permitted, so
485        // hiding `deploy` would withdraw a capability that works.
486        //
487        // `AllowToolParam`'s field is `allow`. An earlier draft of this fixture
488        // spelled it `values`; nothing in this crate sets `deny_unknown_fields`,
489        // so serde discarded it, `allow` defaulted to empty, and the rule became
490        // "deny every call to deploy" — a TOTAL prohibition, in the test
491        // asserting no total prohibition is present. It still passed, because
492        // the assertion above only reads rule kinds. Keep both controls.
493        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        // The set is derived, never cached, so lifting a rule must lift the
513        // concealment with it — otherwise a tool stays hidden from the model
514        // that the engine no longer blocks.
515        let mut engine = engine_from_toml("deny_tool = [\"shell\"]\n");
516        assert!(engine.blanket_denied_tools().contains("shell"));
517
518        // Take the name from the producer rather than spelling it here — a
519        // hand-written name agrees with whatever `apply` happens to use,
520        // including a wrong one.
521        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        // Positive control: the action is genuinely allowed again.
530        let state = StateStore::new();
531        assert!(engine.check(&make_action("shell"), &state).is_empty());
532    }
533
534    /// The daemon registers a `deny_tool` rule under a name the CLIENT chose
535    /// (`policy.register {name: "no_shell", rule: "deny_tool", ...}`), and the
536    /// cookbook teaches that call. An earlier draft recovered the denied tool
537    /// by parsing a `deny_tool:` name prefix, so this identical rule was
538    /// invisible — one rule kind, two behaviours depending on how it was
539    /// registered. Recording the tool structurally is what makes the name
540    /// irrelevant.
541    #[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        // The converse: a check registered through the ORDINARY door under a
555        // name that merely looks like one must NOT hide a tool. Under the
556        // prefix scheme this spoofed a blanket deny, concealing a tool the
557        // engine only conditionally blocks.
558        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    /// The exact typo that bit this file's own fixture: `values` instead of
567    /// `allow`. Serde used to discard it, `allow` defaulted to empty, and an
568    /// empty allowlist permits nothing — so a rule meant to permit `staging`
569    /// silently became "deny every call to deploy". A policy file is a security
570    /// control; a misspelling in one must fail loudly, the way an unparseable
571    /// file already does.
572    #[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        // Positive control: the correctly-spelled rule still parses, so the
588        // rejection is about the unknown field and not about the shape.
589        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}