Part of the AI coding agent security checklist
Claude Code hooks for security: block risky tool calls
A PreToolUse hook sees every tool call before it runs and can refuse it. Here is how to block rm -rf, force pushes and reads of .env, ~/.ssh and ~/.aws, and where the hook stops protecting you.
Checked against Claude Code's hooks and permissions documentation under Sources, and ranwhat's source. Published .
Three layers, and what each one is for
Claude Code can stop a risky call in three different places. They fail in different ways, so most setups use more than one.
Hooks and rules combine in a fixed way. A hook's "allow" skips the permission prompt but does not override a matching deny or ask rule, including one in managed settings. A hook that exits with code 2 stops the call before permission rules are evaluated, even if an allow rule would have approved it. So a hook can only make Claude Code stricter than your rules, never looser.
Block rm -rf and git push --force
One script handles every case in this guide. Save it to ~/.claude/hooks/guard.py. It reads the hook input from stdin, checks the Bash command, and prints a deny decision when a pattern matches.
#!/usr/bin/env python3
import json, os, re, sys
event = json.load(sys.stdin)
tool = event.get("tool_name", "")
args = event.get("tool_input", {})
BASH_RULES = [
(r"\brm\s+(-\S*\s+)*-\S*[rR]", "recursive rm"),
(r"\bgit\b.*\bpush\b.*(\s--force|\s-f\b)", "force push"),
(r"\bgit\s+reset\s+--hard", "git reset --hard"),
]
SECRET = r"(^|[\s/'\"=])\.env(\.(?!example|sample)\w+)?($|[\s'\";|&])|\.ssh/|\.aws/"
def deny(reason):
json.dump({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason}}, sys.stdout)
sys.exit(0)
if tool == "Bash":
cmd = args.get("command", "")
for pattern, name in BASH_RULES:
if re.search(pattern, cmd):
deny("Blocked by guard.py: " + name)
if re.search(SECRET, cmd):
deny("Blocked by guard.py: command names a secrets path")
elif tool == "Read":
path = os.path.realpath(args.get("file_path", ""))
if re.search(SECRET, path):
deny("Blocked by guard.py: secrets path")
sys.exit(0)Register it in ~/.claude/settings.json. If the file already has a hooks key, merge into it.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.claude/hooks/guard.py\""
}
]
}
]
}
}Block reads of .env, ~/.ssh and ~/.aws
The script above already refuses a Read of those paths, and a Bash command that names them. The first thing to add is plain deny rules, which need no code.
{
"permissions": {
"deny": [
"Read(**/.env)",
"Read(**/.env.*)",
"Read(~/.ssh/**)",
"Read(~/.aws/**)",
"Bash(git push --force *)",
"Bash(git push -f *)"
]
}
}The Claude Code and .env guide covers what these rules do not catch, including values that are already in a transcript.
Exit code 2 or a JSON decision
A hook can refuse a call in two ways. Pick one per hook, because Claude Code's guide says to choose one approach and the reference describes what happens when you mix them.
Exit code 2
#!/bin/bash cmd=$(jq -r '.tool_input.command // empty') if [[ "$cmd" == *"git push --force"* ]]; then echo "Force pushes are blocked. Ask the user." >&2 exit 2 fi exit 0
Exit 2 is a blocking error. For PreToolUse it cancels the call, and the text on stderr is the reason Claude sees. It wins even over a JSON "allow".
Exit 0 with JSON
The script in section 02 prints this and exits 0, which gives you three outcomes instead of one:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Writes to src/generated/ are not allowed."
}
}A crash lets the call through. Any other exit code, 1 included, is a non-blocking error: the action proceeds, and Claude Code shows only a notice with the first line of stderr. An uncaught Python exception exits 1. A timed-out PreToolUse command hook does not block either. For a security hook, wrap the check so that the failure path exits 2 if you would rather fail closed.
Where hooks live and who wins
| Location | Scope | Shared |
|---|---|---|
| ~/.claude/settings.json | All your projects | No |
| .claude/settings.json | One project | Yes, committed to the repository |
| .claude/settings.local.json | One project | No, gitignored |
| Managed policy settings | Organisation | Yes, set by an administrator |
| Plugin hooks/hooks.json, skill and subagent frontmatter | While the plugin, skill or subagent is active | Yes |
What a hook cannot do
A hook is a script that reads text. It is useful, and it is not a sandbox.
When the restriction has to hold, add the sandbox, and keep the hook for the message and for the calls the sandbox does not cover.
Test a hook before you trust it
A hook is a program that reads JSON and writes JSON. Test it as one, outside Claude Code first.
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' \
| python3 ~/.claude/hooks/guard.py
# expect the deny JSON
echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
| python3 ~/.claude/hooks/guard.py; echo "exit $?"
# expect no output, exit 0
echo '{"tool_name":"Read","tool_input":{"file_path":"/Users/you/app/.env"}}' \
| python3 ~/.claude/hooks/guard.py
# expect the deny JSONIf you would rather not write the patterns
ranwhat hook install is an opt-in PreToolUse hook. From the next session, a tool call that ranwhat's watch rules rate high or critical waits for your yes. It judges calls on your machine and sends nothing anywhere. A call no rule rates that high gets no answer from it, so Claude Code decides as it would have without the hook. It fails open, as any hook does, and RANWHAT_HOOK=0 in Claude Code's environment lets every call through. --mode deny refuses critical calls outright. It needs a real install, not uvx. To see what it would have asked about, run the check below, which reads what the agent already did and changes nothing.
Next
A hook logs and blocks from now on. To see what already ran, start with the Claude Code audit log. If a command did delete something, read files Claude Code deleted. For secrets the agent has already read, see Claude Code and .env, and for the full list of controls, the AI coding agent security checklist. A hook is one gate among several: the secure-by-default setup shows where it fits, and checking AI-written code covers the gates that run in CI. The rest of the guides are listed here.
Sources
- Hooks referencePreToolUse input, exit codes, permissionDecision, matchers, workspace trust, disableAllHooks, security
- Hooks guideblocking example, hook locations, /hooks, debug log, matcher notes
- Configure permissionsdeny, ask, allow order, Read rules, what a Bash rule does not match, hooks versus rules, sandbox
- Settings files and precedencethe order of managed, command line, local, project and user
- Settings referencedisableAllHooks, allowManagedHooksOnly, permissions.deny
- ranwhat READMEwhat ranwhat hook install does and does not do