Recipe · Intermediate · 10 min
Claude Code hooks: block dangerous commands and secrets
A Python PreToolUse hook that stops Claude Code from reading your .env, running dangerous rm -rf or doing git push --force to main. Tested.
Tested with Claude Code 2.1.316 · reviewed
The problem
Claude Code runs commands and opens files for you. One wrong step is enough for it to read your .env and drop the key into the conversation, run rm -rf on the wrong path, or rewrite main with git push --force. Asking for this in CLAUDE.md helps, but it is an instruction to the model, not a guarantee. If you use modes that approve on their own, such as acceptEdits or bypassPermissions, you need a rule that always holds.
The solution
A PreToolUse hook runs before every tool call. If the script exits with code 2, Claude Code cancels the action and passes the error message to Claude. We write it in Python with no dependencies, so you don't need jq.
1. Create the hook script
Save it as .claude/hooks/protect.py inside your project:
#!/usr/bin/env python3
"""PreToolUse hook: blocks dangerous rm -rf, access to secrets and git push --force to main."""
import json
import re
import subprocess
import sys
# Secret file names (matched against the file name, not the full path)
SECRETS = re.compile(r"^(\.env(\..+)?|.*\.pem|.*\.key|id_rsa.*|id_ed25519.*|credentials\.json|\.npmrc|\.pypirc)$")
TEMPLATES = re.compile(r"^\.env\.(example|sample|template)$") # these can be read
# Same idea, searched inside a Bash command
SECRET_IN_COMMAND = re.compile(
r"(?<![\w.-])(\.env(\.[\w-]+)?|[\w.-]+\.pem|[\w.-]+\.key|id_rsa\w*|id_ed25519\w*|credentials\.json)(?![\w.-])"
)
def block(reason):
print(f"Blocked by the security hook: {reason}", file=sys.stderr)
sys.exit(2) # exit 2 = Claude Code cancels the tool call and passes this message to Claude
def is_secret(path):
name = path.replace("\\", "/").rstrip("/").split("/")[-1]
return bool(SECRETS.match(name)) and not TEMPLATES.match(name)
def check_rm(command):
for part in re.split(r"&&|\|\||;|\||\n", command):
words = part.split()
if not words or words[0] not in ("rm", "sudo"):
continue
if words[0] == "sudo":
words = words[1:]
if not words or words[0] != "rm":
continue
flags = "".join(w.lstrip("-") for w in words[1:] if w.startswith("-") and not w.startswith("--"))
long_flags = [w for w in words[1:] if w.startswith("--")]
recursive = "r" in flags.lower() or "--recursive" in long_flags
forced = "f" in flags or "--force" in long_flags
if not (recursive and forced):
continue
for target in (w.strip("'\"") for w in words[1:] if not w.startswith("-")):
# Root, home, the whole current directory or any absolute path
if target in ("/", "/*", "~", "~/", "~/*", ".", "./", "..", "*", "./*") \
or target.startswith(("/", "~", "$HOME", "${HOME}", "..")):
block(f"'rm -rf {target}' is too dangerous. Delete specific paths inside the project.")
def check_push(command, cwd):
for part in re.split(r"&&|\|\||;|\n", command):
if not re.search(r"\bgit\s+(-C\s+\S+\s+)?push\b", part):
continue
if not re.search(r"(\s--force(-with-lease)?\b|\s-[a-zA-Z]*f\b|\s\+\S+)", part):
continue
if re.search(r"\b(main|master)\b", part):
block("'git push --force' to main/master is not allowed. Use a branch and a pull request.")
# No explicit branch: check which branch the repository is on
try:
branch = subprocess.run(["git", "-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5).stdout.strip()
except (OSError, subprocess.SubprocessError):
branch = ""
if branch in ("main", "master"):
block(f"you are on '{branch}' and 'git push --force' would rewrite it. Use a branch.")
def main():
data = json.load(sys.stdin)
tool = data.get("tool_name", "")
tool_input = data.get("tool_input", {})
if tool in ("Read", "Edit", "Write"):
path = tool_input.get("file_path", "")
if is_secret(path):
block(f"'{path}' contains secrets. Ask the user for the value you need.")
elif tool == "Grep":
path = tool_input.get("path", "") or ""
if is_secret(path) or is_secret(tool_input.get("glob", "") or ""):
block(f"searching inside secret files is not allowed ('{path or tool_input.get('glob')}').")
elif tool == "Bash":
command = tool_input.get("command", "")
for m in SECRET_IN_COMMAND.finditer(command):
if not TEMPLATES.match(m.group(1)):
block(f"the command touches '{m.group(1)}', which contains secrets.")
check_rm(command)
check_push(command, data.get("cwd", "."))
sys.exit(0) # no objection: the normal permission flow continues
if __name__ == "__main__":
main()
What it blocks:
| Tool | Blocked | Allowed |
|---|---|---|
Read, Edit, Write | .env, .env.*, *.pem, *.key, id_rsa*, id_ed25519*, credentials.json, .npmrc, .pypirc | .env.example, .env.sample, .env.template |
Grep | searching inside those files | the rest of the project |
Bash | commands that name those files (cat .env, cp x .env…) | process.env, .envrc |
Bash | rm -rf (or -fr, -Rf, with sudo) on /, ~, $HOME, ., .., * or any absolute path | rm -rf node_modules dist |
Bash | git push --force, -f, --force-with-lease or +branch to main/master, or with no branch while you are on main | git push --force origin feature/x |
2. Register it in .claude/settings.json
Create or extend .claude/settings.json at the project root:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Edit|Write|Grep",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect.py"
}
]
}
]
}
}
The matcher is a list of exact tool names separated by |, and it is case-sensitive. $CLAUDE_PROJECT_DIR is the project root, so the hook still works when Claude changes directory. Calling the script through python3 means you don't have to make it executable.
3. Share the protection with your team
Commit .claude/settings.json and .claude/hooks/protect.py to the repository, and add .env to .gitignore if it isn't there yet. Everyone who clones the project then gets the same rules.
Check it
First test the script on its own by simulating the JSON input Claude Code sends it:
echo '{"tool_name":"Read","tool_input":{"file_path":"/tmp/demo/.env"}}' | python3 .claude/hooks/protect.py; echo "exit $?"
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf node_modules"}}' | python3 .claude/hooks/protect.py; echo "exit $?"
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' | python3 .claude/hooks/protect.py; echo "exit $?"
Expected output:
Blocked by the security hook: '/tmp/demo/.env' contains secrets. Ask the user for the value you need.
exit 2
exit 0
Blocked by the security hook: 'git push --force' to main/master is not allowed. Use a branch and a pull request.
exit 2
Then test it with real Claude Code. We did it in bypassPermissions, the most permissive mode:
claude -p "Hook test: try to read .env with Read and then with 'cat .env'. Don't show any values; copy the exact error." --permission-mode bypassPermissions < /dev/null
This is what Claude received, copied from a real run with version 2.1.316 (with the Spanish version of the script, which prints the same messages in Spanish):
PreToolUse:Read hook error: [python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/proteger.py]: Bloqueado por el hook de seguridad: '/tmp/claude-1000/receta-hooks/.env' contiene secretos. Pídele al usuario el valor que necesites.
PreToolUse:Bash hook error: [python3 "$CLAUDE_PROJECT_DIR"/.claude/hooks/proteger.py]: Bloqueado por el hook de seguridad: el comando toca '.env', que contiene secretos.
With protect.py the message reads Blocked by the security hook: .... For rm -rf /tmp/claude-1000/receta-hooks-basura and git push --force origin main Claude also got the block error, and the folder stayed where it was. In an interactive session, type /hooks to confirm the hook is listed under PreToolUse.
Why it works
Claude Code runs PreToolUse hooks before it checks permissions, and it does so in every permission mode, including bypassPermissions and dontAsk. The hook gets a JSON object on stdin with tool_name and tool_input, which holds command for Bash, file_path for Read, Edit and Write, and path for Grep. Claude Code makes the file tools' paths absolute before it calls the hook. With exit 2 the action is blocked and the stderr text reaches Claude as the reason it was denied. Exit 0 approves nothing: the action goes through the normal permission flow. Any other exit code is a non-blocking error, and the action runs anyway. More in the hooks reference and the hooks guide.
Common mistakes
- Every tool is blocked with
can't open file→ the script path is wrong, andpython3exits with code 2 when it can't find the file, so it blocks everything → fix the path insettings.json. It must be"$CLAUDE_PROJECT_DIR"/.claude/hooks/protect.py, quotes included. - The hook never runs → the JSON in
settings.jsonis invalid (trailing commas, comments), the file isn't at.claude/settings.json, or thematcheris lowercase (bash) → validate the JSON withpython3 -m json.tool .claude/settings.jsonand type/hooks. If you edited the file during a session and the hook doesn't show up, restart the session. python3: command not found(on Windows it is often calledpython) → incommand, replacepython3with the name of your interpreter.- Your script catches the problem but the action runs anyway → it exits with
exit 1or raises an exception, and Claude Code treats that as a non-blocking error → you have to usesys.exit(2)to block. Note: if the input JSON is broken, this script fails with code 1 and the action goes through. - It blocks
cp .env.example .env→ this is on purpose, because the command writes to.env→ run it yourself in your terminal.
FAQ
Does it work with --dangerously-skip-permissions or in bypassPermissions mode?
Yes. The documentation says PreToolUse hooks run before the permission mode is checked and that a block holds in every mode. We tested it with --permission-mode bypassPermissions.
Is it bulletproof?
No. A command that reads files without naming them, such as python -c "open('.e'+'nv')" or grep -r key ., gets past it. Combine it with permission rules ("permissions": {"deny": ["Read(./.env)", "Read(./.env.*)"]}) and, if you need blocking at the operating-system level, with the sandbox.
Why Python and not jq?
The official examples use jq, but many machines don't have it installed, and then the hook fails with jq: command not found. python3 usually comes installed on Linux and macOS, and the script only uses the standard library.
Can I use it in all my projects?
Yes. Copy the script to ~/.claude/hooks/protect.py and put the same hooks block in ~/.claude/settings.json, with "command": "python3 \"$HOME\"/.claude/hooks/protect.py".
What if I really need Claude to use a value from .env?
Give it the value it needs yourself, or let the code read it at runtime without Claude seeing it. If it only needs to know which variables exist, keep a .env.example with no values: the hook lets Claude read it.