Plugins · CPO
Actions
What then does once a condition holds: refuse, ask, stop, escalate, rewrite the input or allow it.
Once when holds, the then effects run in order (default [block]). Each effect returns actions; the
engine merges the actions of every policy evaluated for the hook and answers in that hook's protocol.
then:
- block # the body is the reason
- warn: "{{ file.relative }} is generated" # a template instead of the body
- message: { to: both, level: warn } # text defaults to the body
- log: { text: "write {{ file.relative }}", data: { lines: "lines(event.data.after)" } }Refusals
| Effect | Claude reads | The user sees | Blocks |
|---|---|---|---|
block | [<policy>] Not allowed for you (agent: <type>). <body> | userMessage, if set | yes |
deny | Not available., never the policy or its body | CPO ⛔ <policy>: <body>, also logged | yes |
ask | nothing | the permission prompt: [<policy>] <body> | no: the user decides |
stop | the block text | stopReason; Claude's loop ends | yes |
blocktells Claude the rule exists and that it applies to this agent (mainor the subagent type).denyhides the rule. Use it when Claude should not learn that something is guarded.- After a
blockorstop, Claude's text ends with a pointer toccpp cpo view <project dir>. A refusal made only ofdenynever gets it. askworks attool.call(and its action events) andmodel.switch. Anywhere else it blocks.- Within one hook call, any refusal wins over
ask, andaskwins overallow. Severalblockreasons are joined;denyadds oneNot available.. - On hooks that cannot block (gates marked none), refusals are only reported.
At the end of a turn
At turn.end, a block keeps Claude working: it reads the reason as feedback and continues. This is how a
policy says "not done yet":
on: [turn.end]
when: { cel: "size(work.uncommitted) > 0" }
---
Commit your changes before you stop.Escalate
escalate counts the matches of its policy in the session. From the Nth on, it adds a stop and a notify:
Claude's loop ends and the user is alerted.
then: [block, escalate] # 3rd match on: stop + notify
then: [block, { escalate: 5 }]
then: [warn, { escalate: { after: 2, text: "{{ policy.id }} keeps firing", notify: false } }]Rewrite
on: [shell.exec]
when: { cel: "event.data.command.startsWith('npm ')" }
then:
- rewrite: { command: "event.data.command.replace('npm ', 'pnpm ')" }rewrite: { <field>: <CEL> }sets fields of the tool input (command,file_path,content…) instead of refusing the call. Other fields are kept. It works attool.calland its action events only.- Claude reads
[<policy>] Your tool input was rewritten before it ran (command: npm i → pnpm i).; the user seesCPO ✎ …. - Claude Code runs
PreToolUseonce, so CPO checks the rewritten input again: every policy is evaluated on it, only refusals count, and nothing is rewritten twice. A refusal there blocks the call. - Two policies setting one field to different values block the call.
- The normal permission flow then applies:
rewriteapproves nothing.
Allow
allow (or allow: "reason") skips the permission prompt. CPO is a barrier first, so:
allowneeds awhen;- any refusal or
askof the same hook call wins over it; - Claude Code still applies the deny and ask rules of its settings.
It only works at tool.call, its action events and tool.permission. Elsewhere it is ignored and cpo lint
warns.
State effects
set, unset and snapshot produce no action (State and git). restore
undoes what a tool use changed and warns Claude and the user (Work).