car-policy 0.53.0

Policy engine for Common Agent Runtime
Documentation
//! Policy engine — rules that govern what actions the runtime will allow.
//!
//! Two complementary mechanisms live here:
//!
//! 1. [`PolicyEngine`] — evaluates static policies against `(Action, StateStore)`
//!    and collects every violation. Designed for pre-execution batch validation.
//! 2. [`inspectors::InspectorChain`] — evaluates a short-circuiting chain of
//!    inspectors against `(tool_name, params)` at dispatch time. Stops on the
//!    first Deny. Designed for hot-path guardrails (egress, repetition,
//!    adversary review).

/// Whether a track record has degraded: failures outrun successes by more than
/// `threshold`.
///
/// **One definition, three callers.** `car-memgine` auto-degrades a skill on
/// this rule, `skill_trust` demotes a skill's trust tier on it — its own doc
/// said it was "the same rule car-memgine uses" while hardcoding a second copy
/// of the threshold — and peer standing is the third. Three copies of a
/// predicate that must agree is how they stop agreeing; `car-policy` is the
/// only legal home because `car-memgine` already depends on it and never the
/// reverse.
///
/// Deliberately asymmetric: it takes more than a bare majority of failures to
/// degrade, so a new record with one failure and no successes is not condemned
/// on its first bad day.
pub fn degrades(success_count: u64, fail_count: u64, threshold: u64) -> bool {
    fail_count > success_count + threshold
}

/// The threshold every degradation check uses unless a caller has a reason.
pub const DEGRADE_THRESHOLD: u64 = 2;

pub mod agent_permissions;
pub mod flow_gate;
pub mod inspectors;
pub mod intent_gate;
pub mod permission;
pub mod rules;
pub mod skill_trust;
pub mod tool_gate;

pub use agent_permissions::{AgentPermissionPolicy, ApprovalMode, ApprovalPreset, TierPosture};
pub use flow_gate::{enforce_flow, flow_fingerprint, FlowEnforcement, PendingFlowApproval};
pub use intent_gate::{
    enforce_intent, intent_fingerprint, IntentEnforcement, PendingIntentApproval,
};

pub use inspectors::{
    load_adversary_rules_from, AdversaryInspector, EgressInspector, InspectionResult, Inspector,
    InspectorChain, RepetitionInspector,
};
pub use permission::{
    action_fingerprint, action_text, classify_reversibility, classify_reversibility_with_haystack,
    ActionAxes, ApprovalDecision, ApprovalLedger, ApprovalRecord, GateDecision, PermissionGate,
    PermissionTier, RiskClassifier,
};
pub use rules::{load_policy_dir, DenyToolParam, PolicyLoadError, PolicyRules};

use car_ir::Action;
use car_state::StateStore;
use std::panic::{self, AssertUnwindSafe};

/// A policy violation found during checking.
#[derive(Debug, Clone)]
pub struct PolicyViolation {
    pub policy_name: String,
    pub action_id: String,
    pub reason: String,
}

/// Policy check function: `(action, state) -> Option<violation_reason>`
pub type PolicyCheck = Box<dyn Fn(&Action, &StateStore) -> Option<String> + Send + Sync>;

/// Evaluates actions against registered policies.
pub struct PolicyEngine {
    policies: Vec<(String, String, PolicyCheck)>, // (name, description, check_fn)
}

impl PolicyEngine {
    pub fn new() -> Self {
        Self {
            policies: Vec::new(),
        }
    }

    pub fn register(&mut self, name: &str, check: PolicyCheck, description: &str) {
        self.policies
            .push((name.to_string(), description.to_string(), check));
    }

    /// Check an action against all policies.
    ///
    /// If a policy check panics, the panic is caught and treated as a violation.
    pub fn check(&self, action: &Action, state: &StateStore) -> Vec<PolicyViolation> {
        let mut violations = Vec::new();

        for (name, _, check_fn) in &self.policies {
            let result = panic::catch_unwind(AssertUnwindSafe(|| check_fn(action, state)));

            match result {
                Ok(Some(reason)) => {
                    violations.push(PolicyViolation {
                        policy_name: name.clone(),
                        action_id: action.id.clone(),
                        reason,
                    });
                }
                Ok(None) => {} // passed
                Err(_) => {
                    violations.push(PolicyViolation {
                        policy_name: name.clone(),
                        action_id: action.id.clone(),
                        reason: format!("policy '{}' panicked during check", name),
                    });
                }
            }
        }

        violations
    }

    /// Remove every policy registered under `name`. Returns how many were
    /// dropped — 0 when nothing matched, so the caller can distinguish "removed"
    /// from "there was nothing by that name".
    ///
    /// [`Self::register`] appends without de-duplicating, so the same name can
    /// legitimately appear more than once; this removes all of them rather than
    /// leaving a shadowed copy still enforcing (Parslee-ai/car#623).
    pub fn unregister(&mut self, name: &str) -> usize {
        let before = self.policies.len();
        self.policies.retain(|(n, _, _)| n != name);
        before - self.policies.len()
    }

    /// Drop every registered policy. Returns how many were removed.
    pub fn clear(&mut self) -> usize {
        let n = self.policies.len();
        self.policies.clear();
        n
    }

    /// Registered policy names, in registration order. May contain duplicates —
    /// see [`Self::unregister`].
    pub fn policy_names(&self) -> Vec<String> {
        self.policies.iter().map(|(n, _, _)| n.clone()).collect()
    }

    /// Registered policies as `(name, description)` pairs, in registration
    /// order. The description is what `register` was given.
    pub fn policy_details(&self) -> Vec<(String, String)> {
        self.policies
            .iter()
            .map(|(n, d, _)| (n.clone(), d.clone()))
            .collect()
    }

    pub fn is_empty(&self) -> bool {
        self.policies.is_empty()
    }
}

impl Default for PolicyEngine {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use car_ir::ActionType;
    use serde_json::Value;

    fn make_action(tool: &str) -> Action {
        {
            let mut a = Action::new(ActionType::ToolCall);
            a.id = "test".to_string();
            a.tool = Some(tool.to_string());
            a
        }
    }

    #[test]
    fn no_policies_passes() {
        let engine = PolicyEngine::new();
        let state = StateStore::new();
        let violations = engine.check(&make_action("echo"), &state);
        assert!(violations.is_empty());
    }

    #[test]
    fn policy_blocks_action() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "no_echo",
            Box::new(|action, _state| {
                if action.tool.as_deref() == Some("echo") {
                    Some("echo is forbidden".to_string())
                } else {
                    None
                }
            }),
            "Block echo tool",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("echo"), &state);
        assert_eq!(violations.len(), 1);
        assert!(violations[0].reason.contains("forbidden"));
    }

    #[test]
    fn policy_allows_other_tools() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "no_echo",
            Box::new(|action, _state| {
                if action.tool.as_deref() == Some("echo") {
                    Some("forbidden".to_string())
                } else {
                    None
                }
            }),
            "",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("add"), &state);
        assert!(violations.is_empty());
    }

    #[test]
    fn policy_checks_state() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "require_auth",
            Box::new(|_action, state| {
                if state.get("auth") != Some(Value::Bool(true)) {
                    Some("auth required".to_string())
                } else {
                    None
                }
            }),
            "",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("deploy"), &state);
        assert_eq!(violations.len(), 1);

        state.set("auth", Value::Bool(true), "setup");
        let violations2 = engine.check(&make_action("deploy"), &state);
        assert!(violations2.is_empty());
    }

    #[test]
    fn panicking_policy_caught() {
        let mut engine = PolicyEngine::new();
        engine.register(
            "crasher",
            Box::new(|_action, _state| {
                panic!("policy crashed");
            }),
            "",
        );

        let state = StateStore::new();
        let violations = engine.check(&make_action("anything"), &state);
        assert_eq!(violations.len(), 1);
        assert!(violations[0].reason.contains("panicked"));
    }

    #[test]
    fn multiple_policies() {
        let mut engine = PolicyEngine::new();
        engine.register("p1", Box::new(|_, _| Some("fail 1".to_string())), "");
        engine.register("p2", Box::new(|_, _| None), "");
        engine.register("p3", Box::new(|_, _| Some("fail 3".to_string())), "");

        let state = StateStore::new();
        let violations = engine.check(&make_action("x"), &state);
        assert_eq!(violations.len(), 2);
    }

    #[test]
    fn policy_names() {
        let mut engine = PolicyEngine::new();
        engine.register("alpha", Box::new(|_, _| None), "");
        engine.register("beta", Box::new(|_, _| None), "");
        assert_eq!(engine.policy_names(), vec!["alpha", "beta"]);
    }

    /// Parslee-ai/car#623 — a registered policy had no way back out.
    #[test]
    fn unregister_removes_the_policy_and_stops_enforcement() {
        let mut engine = PolicyEngine::new();
        engine.register("deny", Box::new(|_, _| Some("nope".to_string())), "");
        engine.register("keep", Box::new(|_, _| None), "");
        let state = StateStore::new();
        assert_eq!(engine.check(&make_action("x"), &state).len(), 1);

        assert_eq!(engine.unregister("deny"), 1);
        assert_eq!(engine.policy_names(), vec!["keep"]);
        assert!(
            engine.check(&make_action("x"), &state).is_empty(),
            "an unregistered policy must stop being enforced"
        );
    }

    #[test]
    fn unregister_reports_zero_when_nothing_matched() {
        let mut engine = PolicyEngine::new();
        engine.register("alpha", Box::new(|_, _| None), "");
        assert_eq!(engine.unregister("nosuch"), 0);
        assert_eq!(engine.policy_names(), vec!["alpha"]);
    }

    /// `register` appends without de-duplicating, so the same name can appear
    /// twice; removing only one would leave a shadowed copy still enforcing.
    #[test]
    fn unregister_removes_every_policy_sharing_the_name() {
        let mut engine = PolicyEngine::new();
        engine.register("dup", Box::new(|_, _| Some("a".to_string())), "");
        engine.register("dup", Box::new(|_, _| Some("b".to_string())), "");
        let state = StateStore::new();
        assert_eq!(engine.check(&make_action("x"), &state).len(), 2);

        assert_eq!(engine.unregister("dup"), 2);
        assert!(engine.is_empty());
        assert!(engine.check(&make_action("x"), &state).is_empty());
    }

    #[test]
    fn clear_drops_everything() {
        let mut engine = PolicyEngine::new();
        engine.register("a", Box::new(|_, _| None), "");
        engine.register("b", Box::new(|_, _| None), "");
        assert_eq!(engine.clear(), 2);
        assert!(engine.is_empty());
    }

    #[test]
    fn policy_details_carries_descriptions() {
        let mut engine = PolicyEngine::new();
        engine.register("alpha", Box::new(|_, _| None), "first one");
        assert_eq!(
            engine.policy_details(),
            vec![("alpha".to_string(), "first one".to_string())]
        );
    }
}