What we are building
Claude Code will, given a plausible reason, run almost any command you let it. Most of the time that is the point. But "most of the time" is not "always," and the failure cases are the expensive ones: a git reset --hard that eats three hours of uncommitted work, a curl | sh that runs a script nobody read, an AKIA... key pasted straight into config.py because the agent was trying to be helpful and get your integration working.
Permission prompts catch some of this. They also train you to click "allow" on autopilot, which means by the time the dangerous command shows up you have already built the habit of approving it. We are going to build something that does not depend on your attention: a PreToolUse hook that inspects every Bash, Write, and Edit call before it runs, and returns a hard deny for a specific list of destructive patterns and secret leaks. The rest pass through untouched.
The finished thing is one Python script and one JSON config file, both committable to your repo so the whole team inherits the same floor. When Claude tries something on the deny list, the tool call never executes, and Claude sees a reason it can act on ("load it from an environment variable instead") rather than a silent failure it will retry three different ways. The non-obvious design choice is that we deny with structured JSON, not an exit code, because the reason string is the difference between the agent correcting itself and the agent flailing.
Prerequisites
You need Claude Code installed and working (claude --version should print something recent; the JSON output fields here assume a 2.x build). You need Python 3.8 or later on your PATH as python3, which every modern macOS and Linux box has. No third-party packages: the script uses only the standard library, so there is nothing to pip install and nothing to keep patched.
You should be comfortable reading Python and JSON, and you should have used Claude Code enough to know what a tool call is. You do not need an Anthropic API key beyond whatever already powers your Claude Code session, and nothing here touches a paid feature. Everything runs locally on your machine as a subprocess. If you work on Windows, the concepts are identical but you will point the hook at python instead of python3 and store settings under %USERPROFILE%\.claude.
Setup
Create the hook directory inside whatever repo you want to protect. Hooks configured in .claude/settings.json apply to that project and can be committed; hooks in ~/.claude/settings.json apply to every project on your machine. We will use the project file so your teammates get the same guardrail.
cd your-project
mkdir -p .claude/hooks
touch .claude/hooks/guard.py
chmod +x .claude/hooks/guard.py
Before writing any logic, prove the wiring works with a hook that just announces itself. Put this in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.py"
}
]
}
]
}
}
The matcher narrows this hook to three tool names using | alternation, so Read, Grep, and web tools never pay the cost. ${CLAUDE_PROJECT_DIR} is a placeholder Claude Code expands to your project root, so the command works regardless of which subdirectory the agent is standing in when it fires. Put one line in guard.py for now, import sys; sys.exit(0), start a fresh claude session, and run /hooks. You should see one PreToolUse hook registered against Bash|Edit|Write. That read-only menu is your proof the config parsed; if the hook is missing, the JSON is malformed and Claude Code silently ignored it.
Step 1: Read the event and set up a deny helper
Claude Code sends the hook a JSON object on stdin describing the tool call it is about to make. For a Bash call it looks like this:
{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": { "command": "rm -rf ./build" },
"cwd": "/home/you/your-project"
}
Our job is to parse that, decide, and either stay silent or print a deny decision. Start guard.py with the plumbing:
#!/usr/bin/env python3
"""PreToolUse guardrail for Claude Code: block destructive commands and secret leaks."""
import json
import os
import re
import sys
def deny(reason: str) -> None:
"""Emit a deny decision Claude can read, then exit cleanly."""
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
try:
event = json.load(sys.stdin)
except json.JSONDecodeError:
sys.exit(0)
tool = event.get("tool_name", "")
tool_input = event.get("tool_input") or {}
Two decisions here matter more than they look. First, a json.JSONDecodeError exits 0, not 2. If the hook ever receives something it cannot parse, the correct behavior is to get out of the way, not to wedge every tool call in the session behind a crash. A guardrail that breaks the agent when it malfunctions is worse than no guardrail. Second, deny() prints JSON and exits 0. This is the part people get backwards: Claude Code only reads your JSON on exit 0. Exit 2 also blocks the call, but it throws away your stdout and feeds stderr back instead. You pick one channel per hook. We want the permissionDecisionReason to reach Claude, so we go through JSON on a clean exit.
The permissionDecision field takes allow, deny, or ask. We only ever return deny; everything we do not explicitly block falls through to Claude Code's normal permission flow. A hook that stays silent is not an approval, it is an abstention, which is exactly what you want. You are adding a floor, not replacing the permission system.
Step 2: Block destructive shell commands
Now the first rule set. These are the Bash commands that can ruin an afternoon. Define them as compiled patterns with a human-readable label, then scan the command string:
DANGEROUS_BASH = [
(re.compile(r"\brm\s+(?:-\w*\s+)*-\w*[rf]\w*"), "recursive/forced rm"),
(re.compile(r"\bgit\s+push\b.*(?:--force\b|(?<!-)\s-f\b)"), "git push --force"),
(re.compile(r"\bgit\s+reset\s+--hard\b"), "git reset --hard"),
(re.compile(r"\bchmod\s+-R\s+777\b"), "chmod -R 777"),
(re.compile(r"\bcurl\b[^|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b"), "curl piped to a shell"),
(re.compile(r"\b(?:mkfs\b|dd\s+if=)"), "raw disk write"),
(re.compile(r"(?i)\b(?:drop\s+(?:table|database)|truncate\s+table)\b"), "destructive SQL"),
]
def check_bash(command: str) -> None:
scan = command.replace("--force-with-lease", "")
for pattern, label in DANGEROUS_BASH:
if pattern.search(scan):
deny(f"Blocked a {label} command: {command!r}. "
f"If this is intentional, run it yourself outside Claude Code.")
The list is opinionated, and it should be. A deny list is only useful if it is short enough that you understand every entry and can defend it in code review. These seven cover the destructive operations I have actually seen an agent reach for: recursive deletes, history-rewriting git, a permission blast on a whole tree, the "just pipe this installer into your shell" pattern, raw block-device writes, and the two SQL verbs that drop data with no undo.
The --force-with-lease line is the tell that this list was written by someone who force-pushes on purpose. Plain git push --force overwrites remote history and can clobber a teammate's commits; --force-with-lease refuses if the remote moved under you, which is the safe version. Blocking the safe variant would just teach you to route around the hook, so we strip that substring before scanning. Every entry on a deny list should survive the question "what legitimate command does this also block, and is that acceptable." If the answer is "a common safe one," fix the pattern.
The deny reason names the command and tells Claude what to do instead: run it yourself. That matters because a bare "blocked" sends the agent hunting for a workaround, and the workaround is usually worse than the original.
Step 3: Block secret leaks in Write and Edit
The second failure mode is quieter and more permanent than a bad rm: a live credential written into a tracked file, discovered three commits later by a scanner or, worse, by someone who should not have it. Two checks cover most of it. Block writes to files that should never hold agent-authored content, and block content that contains something shaped like a secret:
SECRET_PATTERNS = [
(re.compile(r"AKIA[0-9A-Z]{16}"), "an AWS access key ID"),
(re.compile(r"-----BEGIN(?:[ A-Z]*)PRIVATE KEY-----"), "a private key block"),
(re.compile(r"ghp_[A-Za-z0-9]{36}"), "a GitHub personal access token"),
(re.compile(r"xox[baprs]-[0-9A-Za-z-]{10,}"), "a Slack token"),
(re.compile(r"""(?i)(?:api[_-]?key|secret|token|passwd|password)\s*[:=]\s*['"][^'"]{12,}['"]"""),
"a hardcoded credential"),
]
SENSITIVE_BASENAMES = {".env", ".env.local", ".env.production",
"credentials", "id_rsa", "id_ed25519"}
SENSITIVE_EXTS = {".pem", ".key", ".p12", ".pfx"}
ALLOWED_ENV = {".env.example", ".env.sample", ".env.template"}
def check_write(file_path: str, content: str) -> None:
base = os.path.basename(file_path)
if base not in ALLOWED_ENV:
_, ext = os.path.splitext(base)
if base in SENSITIVE_BASENAMES or ext in SENSITIVE_EXTS:
deny(f"Refusing to write to {base}. Secrets belong in a gitignored file "
f"or a secret manager, not one Claude edits.")
for pattern, label in SECRET_PATTERNS:
if pattern.search(content):
deny(f"Refusing to write {label} into {base}. "
f"Load it from an environment variable instead (os.environ).")
The path check and the content check catch different mistakes. The path check stops the agent from editing .env or a .pem at all, on the theory that if a file's whole job is to hold secrets, Claude has no business generating it. The ALLOWED_ENV set carves out the placeholder files (.env.example and friends) that are supposed to be committed and are supposed to contain changeme, not real values. Without that exception the hook would block the single most common and most legitimate .env-adjacent write.
The content check is the higher-value half. The first four patterns match specific, unambiguous token shapes: an AWS access key, a PEM private key header, a GitHub ghp_ token, a Slack xox token. These almost never appear in real source except by accident, so a match is a near-certain leak. The fifth pattern is the fuzzy one: an assignment like password = "somethinglongish" with a twelve-plus-character quoted value. It will occasionally fire on a long non-secret string, and that false positive is a deliberate trade. A guardrail that misses real keys to avoid ever annoying you is not a guardrail. When it fires wrongly, the fix is one line, and I would rather explain a spurious block than a leaked key.
Step 4: Dispatch on the tool name
The two rule sets attach to different tools, so route each call to the right check based on tool_name. Write carries the full new file in content; Edit carries the replacement text in new_string. Both are where a secret would land, so both go through check_write:
if tool == "Bash":
check_bash(tool_input.get("command", ""))
elif tool == "Write":
check_write(tool_input.get("file_path", ""), tool_input.get("content", ""))
elif tool == "Edit":
check_write(tool_input.get("file_path", ""), tool_input.get("new_string", ""))
sys.exit(0)
That trailing sys.exit(0) is the whole philosophy in one line. If no rule fired, the script exits clean with no stdout, which Claude Code reads as "this hook has no opinion," and the tool call proceeds through normal permissions. Deny is the only thing this hook ever asserts. It never returns allow, because auto-approving is how you turn a safety tool into a new way to shoot yourself: the day your patterns have a gap, an allow would wave the bad call straight through, whereas silence leaves the existing permission prompts standing behind you.
That is the full script, about 90 lines of logic. It has no dependencies, no state, and no network calls, which means it adds single-digit milliseconds per tool call and cannot itself become a liability you have to maintain.
Verify it works
Test the script directly before trusting it in a live session, by piping it the same JSON Claude Code would. A blocked command:
echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf ./build"}}' \
| python3 .claude/hooks/guard.py
Expected output, a single line of JSON with exit status 0:
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Blocked a recursive/forced rm command: 'rm -rf ./build'. If this is intentional, run it yourself outside Claude Code."}}
Now confirm a safe command produces nothing at all:
echo '{"tool_name":"Bash","tool_input":{"command":"git push --force-with-lease"}}' \
| python3 .claude/hooks/guard.py; echo "exit=$?"
You should see only exit=0 with no JSON above it. Silence plus a zero exit is the "no opinion" signal. Run the same two-line test for a secret write ({"tool_name":"Write","tool_input":{"file_path":"config.py","content":"KEY=\"AKIAIOSFODNN7EXAMPLE\""}} should deny) and a normal write (main.py with print(42) should stay silent). Once the unit tests pass, go live: start claude, ask it to "delete the build folder with rm -rf," and watch the call get denied with your reason inline in the transcript. That end-to-end block is the contract. If you see it, the hook is protecting the session.
When it breaks
The most common failure is the hook not firing at all. Run /hooks in Claude Code; if your PreToolUse entry is absent, .claude/settings.json did not parse, almost always a trailing comma or a missing brace. Claude Code ignores malformed settings silently rather than erroring, so the menu is your only feedback.
If the hook is registered but every tool call still runs, check the exit code your script returns on a match. A deny must go out as JSON on exit 0. If you accidentally exit 1, Claude Code treats it as a non-blocking error, prints a warning, and proceeds with the call anyway. Only exit 0 with valid JSON, or a deliberate exit 2, actually stops a tool. This is the single sharpest edge in the whole hooks system.
If the hook throws a Python traceback, that traceback goes to stderr with a nonzero exit, which is a non-blocking error, so the dangerous call proceeds. That is why the top of the script swallows a JSON parse failure and exits 0. Any code you add should be equally defensive: a guardrail that crashes open is a guardrail that lied to you. When you extend the pattern lists, test the new script against malformed input too.
The last failure is a false positive, usually the fuzzy credential pattern firing on a long legitimate string, or a .env.local you genuinely need Claude to touch. Do not disable the hook. Tighten the specific pattern or add the path to an allow set, then commit the change so the reasoning is visible in history. The moment you start bypassing your own guardrail, it stops being one.
Where to take it next
Three extensions, easiest first. Split the deny lists into a guard-rules.json file the script loads at startup, so teammates can propose new patterns in a pull request without editing Python; the review discussion on "should we block this" is where the real value is. Next, add a companion PostToolUse hook on Write|Edit that shells out to git diff --cached and scans staged content, catching secrets that arrive through a path your PreToolUse rules did not model. Hardest, promote the hook from a personal script to a Claude Code plugin so a whole org installs the same guardrail with one command, versioned and updated centrally, using ${CLAUDE_PLUGIN_ROOT} in place of the project path.
But resist the urge to make the list long. The value of this hook is not coverage, it is that you can read all seven Bash rules and five secret patterns in under a minute and know exactly what your agent cannot do. A deny list you no longer understand is just a slower way of trusting the model completely. What is the one command you would be furious to discover Claude ran unattended, and is it on your list yet?

