Claude Code Hooks: Deterministic Guardrails for AI Coding Agents
Prompts are suggestions; hooks are rules. Learn how Claude Code hooks block dangerous commands, enforce formatting and tests after every edit, and give you a deterministic control layer around your coding agent.
If you have used an AI coding agent for more than a week, you have probably written some version of this in your CLAUDE.md: "Never run rm -rf. Always run the formatter before finishing. Don't commit secrets." The agent follows these rules most of the time. "Most of the time" is the problem. A rule in a prompt is a request the model weighs against everything else in its context. Under a long session, a tight context budget, or an unusual task, that weighing sometimes comes out the wrong way.
Hooks solve this by moving the rule out of the model entirely. A hook is a shell command that Claude Code runs at a fixed point in the agent's lifecycle, for example right before a tool call or right after a file edit. The harness runs it, reads its exit code, and acts on the result. The model cannot talk its way past a hook, because the model never decides whether the hook runs.
This post covers the mental model, how to configure hooks, a working Python guardrail you can copy, a comparison of where each kind of rule belongs, and the mistakes that bite people in their first week.
The problem: instructions are probabilistic, controls should not be
Think about the two kinds of rules you want on a coding agent:
- Preferences: "Use named exports." "Prefer
pnpmovernpm." Losing one of these now and then is annoying but harmless. - Invariants: "Never write to
.env." "Never rungit push --forceonmain." "Never delete a migration file." Losing one of these is an incident.
Put preferences in CLAUDE.md and let the model handle them. Put invariants in code that cannot be ignored. The distinction matters because a prompt-level rule has a failure rate, and that rate is not zero for any model you can buy today. If the cost of one failure is a leaked credential, a failure rate of 1 in 500 is too high.
Hooks give you a second layer. The prompt says what you want. The hook enforces the part you cannot afford to lose.
How hooks work
Hooks are configured in JSON settings files. The main locations are:
| File | Scope | Committed to git? |
|---|---|---|
~/.claude/settings.json |
Every project on your machine | No |
.claude/settings.json |
Everyone on the repo | Yes |
.claude/settings.local.json |
You, on this repo only | No (gitignore it) |
The common lifecycle events are:
| Event | Fires when | Typical use |
|---|---|---|
PreToolUse |
Before a tool runs | Block dangerous commands or protected file writes |
PostToolUse |
After a tool succeeds | Format, lint, or type-check the file just edited |
UserPromptSubmit |
When you send a prompt | Inject context, or reject prompts that match a policy |
Stop |
When the agent finishes responding | Run the test suite before accepting "done" |
A hook is registered under an event, optionally filtered by a matcher that names the tool (for example Bash or Edit|Write). The hook command receives a JSON payload on stdin describing the call, including the tool name and its input. What happens next depends on the exit code:
- Exit
0: proceed. Anything the hook printed to stdout is usually shown in transcript mode, not fed back as a decision. - Exit
2on aPreToolUsehook: block the call. The hook's stderr is passed back to Claude, so the agent learns why it was blocked and can change course. - Any other non-zero exit: this is the footgun. Most Unix tools exit
1on failure, and in many hook events1does not block anything. Your guardrail silently does nothing. Always useexit 2to block, and test that it actually blocks.
Here is a minimal configuration that runs a guard script before every shell command:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/guard_bash.py"
}
]
}
]
}
}
Check the official reference before you rely on field names. The event names, matcher syntax and JSON payload shape above reflect how hooks are commonly documented, but Claude Code evolves quickly. Confirm the current schema at the official hooks docs (code.claude.com/docs/en/hooks) and run a deliberately bad command once to prove your guard blocks it.
A working guardrail: block risky shell commands
Here is a guard script that inspects every Bash call and blocks a short list of patterns that should never run unattended. It reads the payload from stdin, checks the command, and exits 2 with an explanation on stderr if it matches.
#!/usr/bin/env python3
# .claude/hooks/guard_bash.py
import json
import re
import sys
# Each rule: (regex, human-readable reason). Keep these specific.
BLOCKED = [
(r"\brm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+(/|~|\$HOME|\.\s*$)",
"Recursive delete of a root, home, or project directory"),
(r"\bgit\s+push\b.*\s--force(-with-lease)?\b.*\b(main|master)\b",
"Force-push to the default branch"),
(r"\bgit\s+push\b.*\s(--force|-f)\b",
"Force-push (use --force-with-lease on a feature branch if you must)"),
(r"\b(cat|less|head|tail|cp|mv)\b.*\.env(\.\w+)?\b",
"Reading or copying a .env file, which may contain secrets"),
(r"\bcurl\b.*\|\s*(sh|bash)\b",
"Piping a remote script straight into a shell"),
(r"\bDROP\s+(TABLE|DATABASE)\b",
"Destructive SQL statement"),
]
def main() -> int:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError:
# Fail closed: if we cannot read the request, do not guess.
print("guard_bash: could not parse hook payload; blocking.", file=sys.stderr)
return 2
if payload.get("tool_name") != "Bash":
return 0
command = payload.get("tool_input", {}).get("command", "")
for pattern, reason in BLOCKED:
if re.search(pattern, command, flags=re.IGNORECASE):
print(
f"Blocked by project policy: {reason}.\n"
f"Command: {command}\n"
"If this is intentional, ask the user to run it themselves.",
file=sys.stderr,
)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
Three design choices in this script are worth copying:
- It fails closed. If the payload cannot be parsed, the guard blocks instead of allowing. A guard that quietly passes when it breaks is worse than no guard, because you stop looking at it.
- The stderr message is written for the model. When the call is blocked, the agent reads that text and adapts. "Ask the user to run it themselves" is a useful instruction; a bare "blocked" is not.
- The rule list is short and specific. Regex guards are a backstop for obvious accidents, not a sandbox. A determined command can always be rewritten to slip past a pattern. Treat this as a seatbelt, not a lock.
Test it by hand before you trust it:
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
| python3 .claude/hooks/guard_bash.py
echo "exit code: $?"
You should see the blocked message and exit code: 2. If you see exit code: 0, the guard is not doing its job.
A second hook: enforce formatting and types after every edit
Hooks are not only for blocking. A PostToolUse hook on Edit|Write is a good way to make sure every change the agent makes is formatted and type-checked, without relying on the agent to remember.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/after_edit.py"
}
]
}
]
}
}
#!/usr/bin/env python3
# .claude/hooks/after_edit.py
import json
import subprocess
import sys
payload = json.load(sys.stdin)
path = payload.get("tool_input", {}).get("file_path", "")
if path.endswith((".ts", ".tsx")):
# Format, then type-check. Surface type errors back to the agent via stderr.
subprocess.run(["npx", "prettier", "--write", path], check=False)
result = subprocess.run(
["npx", "tsc", "--noEmit"], capture_output=True, text=True
)
if result.returncode != 0:
print(result.stdout + result.stderr, file=sys.stderr)
sys.exit(2)
sys.exit(0)
The tsc call is the interesting part. Running the type checker on every edit is slow on a large project, so in practice you may scope it to the changed file's package, or move the full check to a Stop hook. The principle is the same: the check runs whether or not the agent remembered to ask for it.
Where each kind of rule belongs
Not every rule should be a hook. Use this table to decide.
| Rule type | Example | Put it in | Why |
|---|---|---|---|
| Style preference | "Prefer early returns" | CLAUDE.md |
Cheap to violate, judgment-based |
| Mechanical formatting | "Code must pass Prettier" | PostToolUse hook |
Deterministic, automatable |
| Blocked action | "Never force-push to main" | PreToolUse hook + branch protection |
Must not depend on the model |
| Definition of done | "Tests pass before finishing" | Stop hook |
Gates the agent's final answer |
| Secret handling | "Never read .env" |
PreToolUse hook + secret scanning |
Failure is a leak |
| Project knowledge | "Our billing module uses the outbox pattern" | CLAUDE.md or docs |
Context, not enforcement |
The rule of thumb: if you would be upset to find a violation in a code review, it belongs in a hook. If you would merely be mildly annoyed, it belongs in the prompt.
Common pitfalls
Exit code 1 silently allows everything. This is the most common bug. Write sys.exit(2) explicitly and test the block path every time you change a hook.
Slow hooks make the agent feel broken. A PostToolUse hook that runs a full test suite on every keystroke-sized edit will make the agent crawl. Keep per-edit hooks fast (format, lint one file), and move expensive checks to Stop.
Regex guards give false confidence. rm -rf has a dozen spellings, and bash -c "$(echo cm0gLXJm | base64 -d)" is not on your list. Hooks reduce accidents. They do not sandbox an adversarial command. For real isolation, run the agent in a container or a restricted environment, and keep credentials out of its reach.
Hooks run with your permissions. A hook is a shell script. Review hooks in a shared .claude/settings.json the same way you review CI config, because they execute on every collaborator's machine.
Logging the payload leaks data. If you debug a hook by writing the full tool_input to a log file, you may be writing secrets from file contents or command arguments to disk. Log the tool name and a decision, not the full payload.
Blocking without explanation wastes turns. A block the model does not understand causes retries. Always write a stderr message that says what was blocked and what to do instead.
Next steps
- Start with one
PreToolUseguard onBashcovering the three or four commands your team has actually been burned by. Do not try to write a complete policy on day one. - Add a
PostToolUseformatter for the languages you edit most. It is the cheapest win in the whole setup. - Test every block path by piping a fake payload into the script, as shown above, and confirm the exit code.
- Move your
CLAUDE.md"NEVER" lines into hooks where a violation would hurt, and leave the preferences in the prompt. - Keep hooks in version control in
.claude/hooks/, with a short README on what each one enforces and why.
Hooks are a small feature with an outsized effect. They turn "the agent usually does the right thing" into "the agent cannot do this particular wrong thing," which is the property you actually need before you let an AI agent touch a real repository.
If you want to go deeper on building and operating AI agents, including tool design, evals, and production guardrails, take a look at the Joinloop engineering cohort. We work through these patterns hands-on, on real codebases.
Want to build this yourself?
Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.