Guard & governance
Policies and escalation#
A policy turns a detector incident into at most one enforcement decision per session per tick. The evaluator is pure: it does no I/O, opens no store and sends no signals. It takes incidents, policies and facts, and returns decisions. The daemon does the reading, the dispatching and the auditing.
That split means the whole matching surface is testable without a daemon, a database or a live agent.
A policy#
{
"policy_id": "stop-codex-loops",
"enabled": true,
"scope_runtime": "codex",
"scope_agent_id": "",
"trigger_kind": "stuck_loop",
"min_severity": "warning",
"min_repeat": 2,
"min_duration_s": 120,
"min_spend_usd": 0,
"min_spend_at_risk_usd": 1.50,
"action": "pause"
}| Field | Meaning |
|---|---|
enabled | Off policies are not evaluated |
scope_runtime | Empty string means every runtime |
scope_agent_id | Empty string means every agent |
trigger_kind | Empty string means any detector kind |
min_severity | info or warning |
min_repeat | The incident must have been seen at least this many times |
min_duration_s | The session must have been bad for at least this long |
min_spend_usd | The session's total cost must be at least this |
min_spend_at_risk_usd | The flagged stretch must be worth at least this. 0 or missing means unused. |
action | monitor, alert, pause, stop or kill |
steps | Optional escalation ladder |
min_spend_at_risk_usd is usually the right filter. It is the difference
between "this session has cost $80 in total" and "the part that is currently
going nowhere is worth $6".
The five actions#
| Action | Effect |
|---|---|
monitor | Record what it would have done. Changes nothing. The default for a new policy. |
alert | Fire an alert. Does not touch the process. |
pause | Suspend the process. Resumable. |
stop | Ask it to stop cleanly. |
kill | Terminate the process tree. |
Escalation ladders#
A single action fired once is not how anyone actually responds to a stuck agent. The real shape is pause it, tell me, give it five minutes, then kill it if it is still stuck.
"steps": [
{"action": "pause", "after_secs": 0},
{"action": "alert", "after_secs": 0},
{"action": "kill", "after_secs": 300}
]The semantics are chosen so a ladder can never act faster than a plain policy:
- Step 0 fires when the policy first matches. Its
after_secsis ignored —
use min_duration_s for a delay before the first action.
- Step n becomes due
after_secsafter step n-1 actually fired, not
after the incident started. A ladder measures the time the agent was given to recover.
- A due step only fires if the session is still matching this tick. That is
what makes "kill if still stuck" mean still stuck. If the detector stops reporting, the ladder simply stops.
- Every step passes the same three locks. A
killstep on a node with
enforcement off is a recorded dry run, exactly like a kill policy.
Ladders are capped at a maximum number of steps, so a policy cannot become an open-ended scheduler.
The three locks#
Every autonomous action requires all three:
- The policy's own action must be
pause,stoporkill. A new policy
defaults to monitor.
CLAWMETRY_POLICY_ENFORCE=1on the node. Default0. One environment
variable disables every policy on that machine.
- An entitlement check that fails closed. If the check cannot be answered,
the answer is no.
# The node-wide switch. Nothing autonomous happens without this.
export CLAWMETRY_POLICY_ENFORCE=1
# Skip the evaluation pass entirely.
export CLAWMETRY_GUARD_POLICIES=0The durable latch#
Each rung fires at most once per session, latched on
(session_id, policy_id, step_index) as a primary key in the store.
That is durable across a daemon restart. A daemon that dies mid-ladder resumes
at the right rung instead of replaying it — which, for a ladder ending in
kill, is the difference between a considered escalation and a restart loop
that kills every session it sees.
Managing policies#
# list
curl -s localhost:8900/api/guard/policies | jq
# create
curl -s localhost:8900/api/guard/policies -X POST \
-H 'content-type: application/json' \
-d '{"scope_runtime":"codex","trigger_kind":"stuck_loop",
"min_severity":"warning","min_repeat":2,
"min_spend_at_risk_usd":1.5,"action":"monitor"}'
# delete
curl -s -X DELETE localhost:8900/api/guard/policies/<policy_id>
# what has fired, including dry runs
curl -s localhost:8900/api/guard/actions | jq '.actions[:20]'Rolling one out#
The order that avoids surprises:
- Create it as
monitor. That is the default; do not change it yet. - Leave enforcement off. Also the default.
- Read the decision log for a week.
GET /api/guard/actionsshows exactly
what would have happened.
- Check the false positives. Would you have been happy for it to pause
those sessions? If not, tune the policy, not the detector.
- Change the action to
pause. Still nothing happens — enforcement is off.
Verify the log now shows the intended action.
- Turn on
CLAWMETRY_POLICY_ENFORCE=1. - Only then consider a ladder ending in
kill.
Skipping to step 6 is how you find out that a policy you thought matched three sessions a week actually matches thirty.
Naming#
/api/guard/policies is deliberately distinct from /api/tool-policy. This one
is mid-run enforcement; the other is the pre-tool permission surface.
Different axis, different table, no shared state.
→ Tool permissions