Plugins · CPO
Rules
A policy is a deterministic rule in four parts: on, let, when, then. Conditions are checks and CEL expressions.
on: [file.write] # events
let: # variables
lines: "lines(event.data.after)"
writes: "state.session.?writes.orValue(0)" # persistent state
when: # composable conditions
all:
- cel: "lines > 300"
- not: { builtin: path-match, with: { glob: "**/*.gen.ts" } }
then: # effects, in order
- block
- set: { state.session.writes: "writes + 1" }on: events
The events the policy applies to, such as file.write, shell.exec or turn.end. The full list is on the
Events page and in ccpp cpo catalog.
let: variables
name: <CEL>. Each binding is evaluated lazily, once per policy and event, so a guard such as
event.data.after != null && lines > 3 never evaluates lines on a null. Bindings may use each other; a
cycle is an error. event, file, state, facts and CEL keywords are reserved names.
CEL sees these objects:
| Variable | Content |
|---|---|
event | { name, seq, parent, hook, session, data }; whole JSON numbers are CEL int |
file | { path, relative } for events with a path, else null |
state | state.session, state.turn, state.toolUse: maps of JSON values |
facts | in then and where: the event data merged with the facts of the checks that held |
git | the project's repository, read once per hook call (State and git) |
work | Claude's changes this session (Work) |
Functions: lines(s), glob(path, pattern), readFile(path) (null if missing), exists(path),
diff(snapshot), diff(before, after), snapshotFile(snapshot, path), s.replace(old, new),
s.replaceRegex(pattern, replacement) ($1 for groups), plus standard CEL: size, matches, split,
exists, all, map, filter, optionals. Extensions add their own.
A missing map key is an error in CEL. Read state with a default:
state.session.?n.orValue(0). In YAML double quotes\nis a real newline, which CEL rejects inside a string: uselines(…)or single quotes.
when: conditions
A condition is one check or a combinator. Leaves are any check kind.
when: { builtin: max-lines, with: { max: 400 } }
when: { cel: "event.data.command.contains('--force')" }
when: { all: [<condition>, …] } # every one holds
when: { any: [<condition>, …] } # at least one holds
when: { not: <condition> }| Check kind | Example | From |
|---|---|---|
builtin | { builtin: command-ban, with: { commands: [rm] } } | Builtins |
cel | { cel: "lines(event.data.after) > 400" } | core |
module | { module: ./electron-http-only.policy.ts } | SDK |
tree-sitter, ast-grep, config | { tree-sitter: { query: '…', onlyNew: true } } | Code rules |
- A check holds when it finds what it looks for. For a builtin,
where: <CEL>keeps it only when the expression holds over its facts. - Logic is three-valued. A check that cannot run (content unknown before a shell write, a missing language
server) is unknown.
allis false as soon as one child is false,anytrue as soon as one is true; otherwise unknown propagates and the policy is skipped. - Children run in the written order and short-circuit: put expensive checks last.
- Without
when, the policy applies to every planned event.
then: effects
Effects run in order once when holds. The default is [block].
| Effect | |
|---|---|
block, deny, ask, stop, escalate | Refusals and confirmations (Actions). |
rewrite, allow | Correct the tool input, skip the permission prompt. |
message, info, warn, steer | Text for the user or Claude (Messages). |
log, notify, webhook | A JSON line, a desktop notification, an HTTP POST. |
set, unset, snapshot | Write state, record the work tree (State and git). |
restore | Undo a tool use's changes (Work). |
Outcomes
| Outcome | When |
|---|---|
block | a refusal ran |
ask | the user is asked to confirm |
apply | effects ran, none refused |
allow | when was false |
skip | when was unknown |
error | a check or effect threw or timed out: the action is blocked and the policy's state changes are rolled back |
ccpp cpo trace prints the outcome of every evaluation.