car-policy 0.52.0

Policy engine for Common Agent Runtime
Documentation
//! Gate a proposed tool call on CAR's policy layer.
//!
//! ## Two callers, one definition
//!
//! `car-mcp` exposes this as the `policy_check` tool, and `car-cli` exposes it
//! as `car policy-check-hook` for a host's `PreToolUse` hook. It lives here
//! rather than in either of them because two copies of a *deny* decision that
//! agree only by coincidence is the failure mode a policy engine exists to
//! prevent.
//!
//! ## What this is for
//!
//! CAR's thesis is that models propose and the runtime validates. Inside CAR
//! that is true but has little to bite on: `StaticVerificationGate` is close to
//! inert on the single-action proposals most surfaces produce.
//!
//! This tool points the same machinery at **another agent's** tool call. Claude
//! Code and Codex both fire a `PreToolUse` hook that can block or rewrite a
//! call before it runs, and Claude Code's hooks can dispatch directly to an MCP
//! tool — so an operator's `.car/policies/*.toml` becomes enforceable against
//! the agent in their editor, not only against CAR's own loop. That is the
//! first place the thesis governs somebody else's model.
//!
//! ## What it evaluates
//!
//! Two sources, reported separately so a caller can tell them apart:
//!
//! - **`policy_rules`** — the operator's declarative deny rules, merged from
//!   `<CAR_HOME>/policies/` (what the daemon reads) and `.car/policies/` under
//!   the working directory (what `car do` reads). Authored by a human, and the
//!   reason they are authoritative.
//! - **`inspector:*`** — CAR's built-in stateless guardrails, currently egress.
//!   Nobody authored these; by default they *warn* rather than deny.
//!
//! The stateful `RepetitionInspector` is deliberately **excluded**. A gate that
//! changes its answer based on hidden history is not something an operator can
//! reason about, and this server sees only the calls a host happens to route
//! through it — so its "history" would be a partial view presented as a
//! complete one.
//!
//! ## An allow is not always a pass
//!
//! `basis` distinguishes `passed_rules` from `no_rules_configured`. A caller
//! that treats "allow with nothing loaded" as "reviewed and approved" has
//! turned an unconfigured system into a rubber stamp, and the wire format
//! should not let that mistake be silent. Same reasoning as
//! `ungrounded_claims` on `car do --json`.
//!
//! ## Freshness over caching
//!
//! Rules are re-read from disk on every call. A `PreToolUse` hook fires often
//! and TOML in the page cache is cheap, but that is not the reason — the reason
//! is that an operator who adds a deny rule mid-session must not find it
//! silently unenforced until something restarts. Staleness in a security
//! control is the worse failure.

use std::collections::HashMap;
use std::path::{Path, PathBuf};

use car_ir::Action;
use car_state::StateStore;

use crate::{InspectionResult, InspectorChain, PolicyEngine};
use serde_json::{json, Value};

/// One reason a call was flagged.
struct Finding {
    source: String,
    rule: String,
    severity: &'static str,
    reason: String,
}

impl Finding {
    fn to_json(&self) -> Value {
        json!({
            "source": self.source,
            "rule": self.rule,
            "severity": self.severity,
            "reason": self.reason,
        })
    }
}

/// Where policy rules are read from, in merge order.
///
/// Mirrors the two locations CAR already documents rather than inventing a
/// third: the daemon reads `<CAR_HOME>/policies/`, and `car do` reads
/// `.car/policies/` under its working directory.
///
/// Neither is walked upward. That matches `car do`'s documented behavior, and
/// the surprise it causes there (run from a subdirectory, the repo-root rules
/// are simply not loaded) is exactly why the response reports which
/// directories were consulted and how many rules each contributed — a caller
/// can see an empty policy set instead of inferring one from a clean result.
fn policy_dirs(cwd: &Path) -> Vec<PathBuf> {
    vec![
        car_home::root_or_relative().join("policies"),
        cwd.join(".car").join("policies"),
    ]
}

/// Evaluate `tool`/`params` and return the decision document.
pub fn check(tool: &str, params: &Value, cwd: &Path) -> Value {
    let mut findings: Vec<Finding> = Vec::new();
    let mut sources: Vec<Value> = Vec::new();
    let mut rules_loaded = 0usize;
    let mut load_errors: Vec<Value> = Vec::new();

    // 1) The operator's declarative rules.
    let mut merged = crate::PolicyRules::default();
    for dir in policy_dirs(cwd) {
        match crate::load_policy_dir(&dir) {
            Ok(rules) => {
                let n = rules.len();
                rules_loaded += n;
                sources.push(json!({
                    "path": dir.display().to_string(),
                    "exists": dir.exists(),
                    "rules": n,
                }));
                merged.merge(rules);
            }
            Err(e) => {
                // A policy file that will not parse is NOT treated as "no
                // rules". An operator wrote a rule; failing open on a typo
                // would enforce nothing while reporting a clean result, which
                // is the failure mode declarative policy exists to avoid.
                load_errors.push(json!({
                    "path": dir.display().to_string(),
                    "error": e.to_string(),
                }));
            }
        }
    }

    if !load_errors.is_empty() {
        return json!({
            "decision": "deny",
            "basis": "policy_load_failed",
            "tool": tool,
            "findings": [],
            "policy_sources": sources,
            "load_errors": load_errors,
            "advice": "A policy file could not be parsed. Fix it, or the gate cannot \
                       tell an allowed call from an ungoverned one — it fails closed.",
        });
    }

    let mut engine = PolicyEngine::new();
    merged.apply(&mut engine);

    // `Action` is `#[non_exhaustive]` (car#855), so it is built through its
    // constructor and mutated rather than with a struct literal — which is the
    // point: a field added to the IR cannot silently go unset here.
    let mut action = Action::tool_call(tool);
    action.id = "policy-check".to_string();
    action.parameters = params
        .as_object()
        .map(|m| {
            m.iter()
                .map(|(k, v)| (k.clone(), v.clone()))
                .collect::<HashMap<_, _>>()
        })
        .unwrap_or_default();
    let state = StateStore::new();
    for violation in engine.check(&action, &state) {
        findings.push(Finding {
            source: "policy_rules".to_string(),
            rule: violation.policy_name,
            severity: "deny",
            reason: violation.reason,
        });
    }

    // 2) CAR's built-in stateless guardrails. Egress only — see the module
    //    docs on why repetition is excluded.
    let chain = InspectorChain::new().with(Box::new(crate::EgressInspector::default()));
    for (name, result) in chain.inspect(tool, params) {
        match result {
            InspectionResult::Allow => {}
            InspectionResult::Warn(reason) => findings.push(Finding {
                source: format!("inspector:{name}"),
                rule: name.clone(),
                severity: "warn",
                reason,
            }),
            InspectionResult::Deny(reason) => findings.push(Finding {
                source: format!("inspector:{name}"),
                rule: name.clone(),
                severity: "deny",
                reason,
            }),
        }
    }

    let denied = findings.iter().any(|f| f.severity == "deny");
    let basis = if denied {
        "denied_by_rule"
    } else if rules_loaded == 0 {
        // The distinction that keeps an unconfigured system from reading as a
        // reviewed one.
        "no_rules_configured"
    } else {
        "passed_rules"
    };

    json!({
        "decision": if denied { "deny" } else { "allow" },
        "basis": basis,
        "tool": tool,
        "findings": findings.iter().map(Finding::to_json).collect::<Vec<_>>(),
        "rules_loaded": rules_loaded,
        "policy_sources": sources,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Write a policy file into a temp cwd's `.car/policies/`.
    fn with_policy(dir: &Path, toml: &str) {
        let p = dir.join(".car").join("policies");
        std::fs::create_dir_all(&p).unwrap();
        std::fs::write(p.join("rules.toml"), toml).unwrap();
    }

    #[test]
    fn an_allow_with_no_rules_says_so() {
        // The guard that matters: a caller must not read "nothing configured"
        // as "reviewed and approved".
        let dir = tempfile::tempdir().unwrap();
        let out = check("Bash", &json!({ "command": "ls" }), dir.path());
        assert_eq!(out["decision"], "allow");
        assert_eq!(out["basis"], "no_rules_configured");
        assert_eq!(out["rules_loaded"], 0);
    }

    #[test]
    fn a_denied_tool_is_denied_with_the_rule_that_did_it() {
        let dir = tempfile::tempdir().unwrap();
        with_policy(dir.path(), "deny_tool = [\"Bash\"]\n");
        let out = check("Bash", &json!({ "command": "ls" }), dir.path());
        assert_eq!(out["decision"], "deny");
        assert_eq!(out["basis"], "denied_by_rule");
        assert_eq!(out["findings"][0]["source"], "policy_rules");
        assert_eq!(out["findings"][0]["severity"], "deny");
    }

    #[test]
    fn a_passing_call_under_real_rules_is_distinguishable_from_an_ungoverned_one() {
        let dir = tempfile::tempdir().unwrap();
        with_policy(dir.path(), "deny_tool = [\"Write\"]\n");
        let out = check("Bash", &json!({ "command": "ls" }), dir.path());
        assert_eq!(out["decision"], "allow");
        assert_eq!(out["basis"], "passed_rules");
        assert!(out["rules_loaded"].as_u64().unwrap() >= 1);
    }

    #[test]
    fn a_denied_keyword_is_caught_in_any_string_parameter() {
        let dir = tempfile::tempdir().unwrap();
        with_policy(dir.path(), "deny_keyword = [\"rm -rf /\"]\n");
        let out = check(
            "Bash",
            &json!({ "command": "sudo rm -rf / --no-preserve-root" }),
            dir.path(),
        );
        assert_eq!(out["decision"], "deny", "{out}");
    }

    #[test]
    fn an_unparseable_policy_file_fails_closed() {
        // Failing open on a typo would enforce nothing while reporting a clean
        // result — the exact failure declarative policy exists to prevent.
        let dir = tempfile::tempdir().unwrap();
        with_policy(dir.path(), "deny_tool = [ this is not toml\n");
        let out = check("Bash", &json!({ "command": "ls" }), dir.path());
        assert_eq!(out["decision"], "deny");
        assert_eq!(out["basis"], "policy_load_failed");
        assert!(!out["load_errors"].as_array().unwrap().is_empty());
    }

    #[test]
    fn the_response_names_every_directory_it_consulted() {
        // `car do` does not walk up to find `.car/policies/`, and neither does
        // this. Reporting the paths is how a caller notices they are governed
        // by nothing rather than inferring it from a clean result.
        let dir = tempfile::tempdir().unwrap();
        let out = check("Bash", &json!({}), dir.path());
        let sources = out["policy_sources"].as_array().unwrap();
        assert_eq!(sources.len(), 2, "{out}");
        // Built with `Path::join`, not a literal `".car/policies"` suffix: the
        // reported path is `Path::display()`, so it carries the platform's own
        // separator and a hard-coded `/` matched only on Unix. Comparing the
        // whole path rather than its tail is also the stronger claim — the
        // response must name the directory it was handed, not merely something
        // ending the same way.
        let expected = dir.path().join(".car").join("policies");
        assert!(
            sources
                .iter()
                .any(|s| Path::new(s["path"].as_str().unwrap()) == expected),
            "{out}"
        );
    }

    #[test]
    fn params_that_are_not_an_object_do_not_panic() {
        let dir = tempfile::tempdir().unwrap();
        let out = check("Bash", &json!("just a string"), dir.path());
        assert_eq!(out["decision"], "allow");
    }
}