Step-by-step guide
Add a deterministic checkpoint
Put repeatable policy at the exact moment in the lifecycle where it matters.
What this changes
Some decisions should not depend on whether the model remembers an instruction. A checkpoint lets your own process inspect an event and return a clear verdict to the runtime.
-
01
Consistent enforcement
The same check runs every time the relevant moment occurs.
-
02
Policy your team owns
Use the systems and standards already trusted by your organization.
-
03
Visible failure behavior
A blocked action explains why it stopped instead of disappearing into model behavior.
Minimum setup
Start with one working configuration.
Select the lifecycle event, narrow it to the calls that matter, then return one JSON verdict on standard output. Use a hook when the check must run regardless of what the model remembers.
{
"hooks": [
{
"event": "pre_tool_use",
"match": { "tool": "shell" },
"command": "echo '{\"kind\":\"advise\",\"message\":\"a shell command is about to run\"}'",
"timeout_ms": 5000,
"on_failure": "pass"
}
]
}
The block above is the whole file. Settings are parsed as strict JSON, so a comment line anywhere in it discards the entire scope silently; keep the scope note out of the file and put it in ~/.clarvis/settings.json for yourself, or in <workspace>/.clarvis/settings.json to version it with the repository. Keep stdout exclusively for the verdict and send diagnostics to stderr. Choose on_failure deliberately: pass keeps work moving when the hook breaks; deny fails closed at events that are allowed to block.
Working patterns
Adapt it to work you actually do.
Open a pattern to see the complete files and the reason behind each choice.
Pattern 01 Block a recursive delete before it runs Configuration +
You are happy for the agent to use the shell, but a recursive delete is one mistake you do not want to recover from. You want the call refused before it happens, with a message that tells the model what to do instead. Name both command-running tools: shell is the obvious one, and monitor_start takes the same command argument and runs it through the shell too, so a matcher that lists only shell leaves it open.
File: .clarvis/settings.json
{
"hooks": [
{
"event": "pre_tool_use",
"match": {
"tool": ["shell", "monitor_start"],
"args": { "command": "\\brm\\s+(-[A-Za-z]+\\s+)*-[A-Za-z]*[rR]" }
},
"command": "echo '{\"kind\":\"deny\",\"message\":\"Recursive rm is blocked in this workspace. Delete named paths one at a time, or ask me to run it myself.\"}'",
"on_failure": "deny"
}
]
}
The filter selects, the command decides. Because match.args already narrows to
the calls you care about, the command has nothing left to work out and can be a
single echo. `on_failure: "deny"` means that even if the command somehow fails to
run, the call is still refused.
//
The pattern is a JavaScript regular expression tested against the `command`
argument. It matches `rm -rf x`, `rm -r x`, `rm -fr x` and `rm -i -rf x`, and does
not match `rm file.txt`, `npm run build` or `confirm removal`. It does match
`git rm -r pkg`, which you may or may not want.
//
This is scoping, not a security boundary: a determined command can be spelled to
slip past any pattern. For enforceable shell policy, configure the guard.
Pattern 02 Refuse to finish while the tests are failing Configuration +
Agents like to declare victory. You want the run's own test suite to be the thing that decides when the work is done, and you want the failure message to reach the model so it goes back and fixes the code rather than arguing.
File: .clarvis/hooks/tests-must-pass.sh
#!/bin/sh
# stdout is the verdict channel: nothing but the JSON below may be written there.
if bun test >&2; then
echo '{"kind":"pass"}'
else
echo '{"kind":"deny","message":"The test suite is red. Run bun test, fix what fails, then finish."}'
fi
File: .clarvis/settings.json
{
"hooks": [
{
"event": "pre_finalize",
"command": "sh .clarvis/hooks/tests-must-pass.sh",
"timeout_ms": 600000,
"on_failure": "deny"
}
]
}
Three things make this work. The runner's own output is redirected to stderr, so
the only thing on stdout is the verdict. The script exits 0 in both branches, so a
red suite is reported as a deny carrying your message rather than as a hook
failure carrying a generic one. And timeout_ms is raised well above the 30000
millisecond default for this event, which no real suite finishes inside.
//
The hook fires for sub agents as well as the lead, so a delegated agent has to
clear the same bar. On a run where you want to hand back known-failing work, edit
the hook out and re-approve the workspace, or move the run to a workspace that
does not declare it.
Verify the behavior
Check the boundary, not just the happy path.
Use a small disposable task first. Confirm what works and what is deliberately unavailable before relying on the configuration in a real workflow.
- 01
The hook fires only for the selected event and matcher.
- 02
The allowed path produces a pass or no output and the run continues.
- 03
The refused path returns a useful reason to the agent without executing the pending action.