From rules to hooks: stop asking the AI, enforce it
Instructions tell an AI coding agent what not to do. Hooks make sure it can't. What belongs where, a minimal example, and why the guard itself needs tests.
By Tenovates5 min read
Written 2026-09 · Claude Code
Available in French
The problem
Every team that works with an AI coding agent ends up with a list of
rules. Never force-push. Never edit the production environment file.
Always run the tests before saying you're done. The natural place to put
them is the instruction file the agent reads at the start of every
session — CLAUDE.md for Claude Code, AGENTS.md for others.
It works. Most of the time.
And "most of the time" is the problem. A rule that holds ninety-nine times out of a hundred will eventually be broken on the one day it matters: a long session, a context that has been summarised, a task phrased in a way that makes the forbidden command look like the obvious next step. Nobody was careless. The rule was simply never enforced — it was only asked.
Why instructions aren't enough
An instruction file is context. The model reads it, weighs it against everything else it has read, and decides what to do. That is exactly what you want for guidance — conventions, preferences, how the project is organised. It is not what you want for the handful of actions that must never happen, whatever the context.
For those, you need something that runs outside the model's judgement: a check that executes every single time, and whose answer the model cannot argue with.
Claude Code provides two:
- Permissions — rules in
.claude/settings.jsonthat make a command ask you first, or deny it outright. - Hooks — small programs that run at fixed points: before a tool is used, after it runs, when the agent is about to stop. A hook can block the action and tell the agent why.
What goes where
| Kind of rule | Where it belongs | Why |
|---|---|---|
| "We use Tailwind tokens, not raw colours" | Instructions | Judgement, style, context |
| "Pushing needs a human" | Permission: ask |
Legitimate, but not alone |
| "Never reset the database" | Hook (or permission: deny) |
Must never happen |
"Never pass --force anywhere in a push" |
Hook | A pattern permissions can't express |
| "Tests must pass before 'done'" | Hook on stop | Runs every time, not when remembered |
A good test: if breaking the rule once would cost you a day, it doesn't belong only in the instructions.
A minimal example
Permissions cover the simple cases. Commands matching a prefix will ask before running:
{
"permissions": {
"ask": ["Bash(git push:*)", "Bash(npm install:*)"]
}
}
A hook covers what a prefix can't — a flag that could appear anywhere in
the command. Register it for the Bash tool:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "node .claude/hooks/guard.mjs" }]
}
]
}
}
The hook receives the tool call as JSON on its standard input. Exit code
2 blocks the call, and whatever it writes to standard error is shown to
the agent as the reason:
// .claude/hooks/guard.mjs
let input = "";
for await (const chunk of process.stdin) input += chunk;
const command = JSON.parse(input).tool_input?.command ?? "";
const rules = [
[/\bgit\s+push\b.*\s(--force|-f)\b/, "Force-push is not allowed. Push normally or ask."],
[/\s--no-verify\b/, "Don't skip the checks: fix what they report."],
];
for (const [pattern, reason] of rules) {
if (pattern.test(command)) {
console.error(`Blocked: ${reason}`);
process.exit(2);
}
}
process.exit(0);
The agent doesn't just fail: it reads the reason and adapts — usually by doing the right thing instead.
The same idea works at the end of a task. A hook on the Stop event can
run your tests and refuse to let the agent finish while they fail. Give
it a way out: the hook input tells you when the agent is already retrying
after a block (stop_hook_active). Block once, and on the second attempt
let it stop but make it report that the work is not done — otherwise
an unfixable failure becomes an infinite loop.
Test the guard, not just the thing it guards
A hook is code, and code has bugs. The dangerous kind is a guard that fails open: it runs, it says nothing, and the forbidden action goes through. You won't notice, because a silent guard looks exactly like a guard with nothing to report.
Two traps are worth knowing:
- Paths come in more than one shape. On Windows, the same file can
reach your hook as a drive-letter path with backslashes, or in Unix form
as
/c/project/.env. A check that compares against one form silently misses the other. Match on what you actually care about — the file name, the folder pattern — rather than on a path relative to a root that may be written differently. - Tools summarise.
git status --porcelainshows a brand-new folder as a single line,?? folder/, not the files inside it. A hook that looks for changed code files will see none — unless you ask for them with--untracked-files=all.
The fix is the same for both: test the hook like any other program. Feed it the JSON it will receive, and check the exit code — for commands that must be blocked and for commands that must pass:
echo '{"tool_input":{"command":"git push --force origin main"}}' | node .claude/hooks/guard.mjs
echo $? # expect 2
echo '{"tool_input":{"command":"git push origin main"}}' | node .claude/hooks/guard.mjs
echo $? # expect 0
The second case matters as much as the first. A guard that blocks everything gets disabled by the first frustrated person who meets it.
The take-away
Instructions guide an agent; hooks and permissions bind it. Keep the instruction file for judgement and conventions. Move the few rules that must never be broken into places the agent cannot talk its way around — and test those guards with the same care as the code they protect.
Checklist
Moving a rule from instructions to enforcement
1. Would breaking it once cost a day or more? → enforce it
2. Is it a command prefix (push, install, deploy)? → permission: ask / deny
3. Is it a pattern anywhere in a command or a path? → PreToolUse hook
4. Is it "must be true before finishing"? → Stop hook, with a retry limit
5. Test each guard with inputs that must be blocked AND inputs that must pass
6. Keep the rule in the instructions too — explained — so the agent knows why