ccppccpp home
Browse the documentation

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

EffectClaude readsThe user seesBlocks
block[<policy>] Not allowed for you (agent: <type>). <body>userMessage, if setyes
denyNot available., never the policy or its bodyCPO ⛔ <policy>: <body>, also loggedyes
asknothingthe permission prompt: [<policy>] <body>no: the user decides
stopthe block textstopReason; Claude's loop endsyes
  • block tells Claude the rule exists and that it applies to this agent (main or the subagent type).
  • deny hides the rule. Use it when Claude should not learn that something is guarded.
  • After a block or stop, Claude's text ends with a pointer to ccpp cpo view <project dir>. A refusal made only of deny never gets it.
  • ask works at tool.call (and its action events) and model.switch. Anywhere else it blocks.
  • Within one hook call, any refusal wins over ask, and ask wins over allow. Several block reasons are joined; deny adds one Not 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 at tool.call and its action events only.
  • Claude reads [<policy>] Your tool input was rewritten before it ran (command: npm i → pnpm i).; the user sees CPO ✎ ….
  • Claude Code runs PreToolUse once, 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: rewrite approves nothing.

Allow

allow (or allow: "reason") skips the permission prompt. CPO is a barrier first, so:

  • allow needs a when;
  • any refusal or ask of 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).

Edit this page on GitHub