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 PolicyEngine;
let mut engine = new;
engine.register;
let violations = engine.check;
File-loaded rules
PolicyRules loads operator-authored TOML from .car/policies/*.toml. Regex
parameter rules normally deny a match; negate = true provides the fail-closed
"unless" form for open-ended allowlists:
[[]]
= "docker.rm"
= "name"
= "^parslee-"
= true # deny `foo`; allow `parslee-x`; deny a missing `name`
See docs/cookbook/08-policies.md for
the complete grammar and loader scopes.
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 ;
let tier = default.classify; // who may authorize it
let rollback = classify_reversibility; // 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;
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.