car-policy 0.49.0

Policy engine for Common Agent Runtime
Documentation

car-policy

Declarative policy engine for the Common Agent Runtime.

What it does

Evaluates actions against registered policy rules before execution. Policies are closures that inspect the action and current state, returning a violation reason or passing. Panicking policies are caught and treated as violations. This is the enforcement layer for agent guardrails.

Usage

use car_policy::PolicyEngine;

let mut engine = PolicyEngine::new();
engine.register("no_rm", Box::new(|action, _state| {
    if action.tool.as_deref() == Some("shell_exec") {
        Some("shell execution is forbidden".into())
    } else {
        None
    }
}), "Block shell execution");

let violations = engine.check(&action, &state);

Two axes, not one: authority and reversibility

permission::PermissionTier (ReadOnly < SandboxEdit < FullAccess) answers who may authorize this action. It does not answer whether the action can be undone, though its documentation used to read as though it did — SandboxEdit was "reversible local mutation" and FullAccess was "externally-consequential or irreversible", and that or fused two independent questions onto one rung. car_ir::Reversibility is the second axis, and classify_reversibility() is this crate's classifier for it:

use car_policy::{classify_reversibility, RiskClassifier};

let tier = RiskClassifier::default().classify(&action); // who may authorize it
let rollback = classify_reversibility(&action);         // can it be undone

Both classifiers read the same flattened text — the tool name plus every nested string in the parameters, lowercased. If you need both answers for the same action, ask the gate once rather than calling the two classifiers separately:

let axes = gate.evaluate_axes(&action, None, None);
axes.decision;      // may this run (carrying the required tier)
axes.reversibility; // could it be taken back

evaluate_axes flattens the parameters once. Doing it the long way flattens them twice, which is not free when a parameter carries a whole document or a diff, and every caller that records a PermissionDecision needs both.

They are computed independently — classify_reversibility reads nothing from RiskClassifier, PermissionTier, or FULL_ACCESS_COMMAND_KEYWORDS — because deriving one from the other would rebuild the conflation the split exists to remove. A database INSERT and a sent email are indistinguishable on the authority ladder and have nothing in common on this one. The disagreements are the point:

Action Authority Rollback contract
state_read ReadOnly Reversible
write a scratch file under /tmp SandboxEdit Reversible
read_secret FullAccess Reversible — a read leaves nothing to undo
db_insert SandboxEdit Compensable — delete the row
git_push FullAccess Compensable — force-push the prior ref
send_email, charge_card FullAccess Irreversible

read_secret being Reversible is correct, not a bug: this axis is about effects, and reading has none to reverse. An exfiltrating read is maximally dangerous and maximally reversible at once — which is precisely why the reversibility answer must never be used as a stand-in for the tier.

Like the tier classifier it is a keyword heuristic, not a decision procedure: it reads a tool name and a flattened parameter blob, and an unrecognized tool comes back Irreversible. The known misses are enumerated in the function's docs and pinned by tests (list_deployments over-classifies as Compensable; get_payment as Irreversible, since money is matched by its object; git push is called Compensable unconditionally where the honest hedge is "if nobody has pulled yet"). Every miss errs toward over-asking.

car-engine's TierPermissionHandler puts both values on every PermissionDecision event, allow rows included. Neither the gate nor anything else in the runtime acts on the reversibility field yet — it is classified and recorded, not enforced. Background: docs/proposals/shepherd-substrate-adoption.md.

Part of CAR -- see the main repo for full documentation.