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