Late one night, I had handed a long refactoring session to Claude Code and moved on to other work. Scrolling back through the execution log, I stopped cold. An rm command meant to clear build artifacts had been written in a way that, depending on how a variable expanded, could have targeted the project root instead. Nothing was lost. But I suddenly felt the absence of something I should have had all along: a mechanical check that inspects commands before they run.
As an indie developer, I have no colleague reviewing the commands an agent queues up. The hours I delegate to Claude Code were only going to grow, so that night I built a screening layer with a PreToolUse hook. A few weeks in, it has stepped in front of real mistakes more than once, and the design has held up well. Here is the setup, the actual script, and what I learned tuning it.
Why the permission prompt alone stopped being enough
Claude Code already asks for permission before risky actions, so a second layer might seem redundant. Two things changed my mind.
First, auto-approval is a normal part of long sessions in practice. You allowlist Bash, or you run with confirmations relaxed, because stopping to click "approve" forty times defeats the point of delegation. The moment you do that, the assumption "a human reads every command first" quietly disappears.
Second, confirmation fatigue is real. After approving dozens of harmless commands in a row, I stopped reading the dialogs. Human attention is a poor safety mechanism precisely where it matters most. A hook is the opposite: it reads every command with the same level of attention, at 2 a.m., on the hundredth invocation.
Where PreToolUse hooks intervene
PreToolUse fires immediately before Claude Code executes a tool. The command you register receives JSON on standard input. For the Bash tool, that payload includes tool_name and a tool_input object carrying the exact command string about to run.
The exit code semantics are the part worth memorizing:
exit 0— allow the executionexit 2— block it, and feed whatever you printed to stderr back to Claude- any other non-zero — treated as a hook error, but does not block
That "only 2 blocks" rule is easy to miss. My first version returned exit 1, which meant my screening script ran for days while blocking precisely nothing. If a pattern matches, return 2. Settling that first saves a confusing debugging session later.
The minimal settings.json wiring
For a per-project setup, the configuration lives in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 .claude/hooks/check_bash.py"
}
]
}
]
}
}The matcher filters by tool name. Leaving it empty would run the hook before every tool call, including file edits, and screening those is a much harder false-positive problem. Scoping to Bash keeps the surface small and the rules easy to reason about.
Because the file can be committed to the repository, the same screening applies on any machine that clones it. I ran the hook in a single repository for about two weeks before promoting it to ~/.claude/settings.json, where it now covers every project.
The screening script itself
The screening logic is a deliberately modest denylist: only operations that cannot be undone.
#!/usr/bin/env python3
# .claude/hooks/check_bash.py
import json
import re
import sys
BLOCK_PATTERNS = [
(r"rm\s+-[a-zA-Z]*r[a-zA-Z]*f?\s+(/|~|\$HOME)(\s|$)",
"recursive deletion of root or home directories is not allowed"),
(r"git\s+push\s+(?!.*--force-with-lease).*--force",
"use --force-with-lease instead of a bare force push"),
(r"chmod\s+(-R\s+)?777",
"chmod 777 is not allowed; grant specific permissions instead"),
(r"curl\s+[^|;]*\|\s*(ba|z)?sh",
"piping downloaded scripts directly into a shell is not allowed"),
(r">\s*/dev/sd[a-z]",
"writing directly to block devices is not allowed"),
]
def main() -> int:
data = json.load(sys.stdin)
if data.get("tool_name") != "Bash":
return 0
command = data.get("tool_input", {}).get("command", "")
for pattern, reason in BLOCK_PATTERNS:
if re.search(pattern, command):
print(f"blocked by check_bash.py: {reason}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())I also prototyped the inverse — an allowlist that blocks everything not explicitly approved — and abandoned it within a week. The range of commands an agent legitimately needs is wider than I expected, and until the allowlist matures, the agent can barely function. Starting from a denylist that only prevents fatal damage, then adding a pattern each time something makes me flinch, has proven far more sustainable for a solo setup.
What Claude does after being blocked
The most interesting discovery came right after deployment. Whatever the script prints to stderr under exit 2 becomes feedback Claude actually reads.
When the hook blocked a git push --force, Claude picked up the phrase "use --force-with-lease" from the rejection message, rewrote the command on its own, and ran the safer version. A well-written rejection message works less like a wall and more like a signpost.
Once I understood that, I rewrote every message to say what to do instead, not just what was forbidden. A bare blocked response tends to send Claude guessing at workarounds, which makes its behavior harder to predict — the opposite of what a safety layer is for.
Adjustments after a few weeks of daily use
Three things needed tuning once the hook met real workloads:
- False positives on temp directories. The first pattern also caught recursive deletes under
$TMPDIRduring test runs. Narrowing the match to literal/,~, and$HOMEtargets fixed it without weakening the protection that matters. - A block log. Every rejection now appends a line to
~/.claude/hooks.log. Reviewing it weekly gives me evidence for both adding patterns and removing ones that never fire — the log, not intuition, drives the rule set. - Keeping the script fast. The hook runs before every single Bash call, so any startup weight is felt immediately. No external command calls, no network access, no clever caching. Plain regex checks are more than enough.
Splitting responsibilities with permission deny rules
Claude Code also supports declarative deny rules in its permission settings, and a recent update added glob patterns to them. I tried pushing everything into deny rules to retire the script, then settled on a division of labor instead:
- Statically decidable bans (no writes to a protected directory, a command that should never run) → permission deny rules
- Context-dependent judgments (argument combinations, redirect targets, anything where I want the rejection message to steer Claude toward an alternative) → the PreToolUse hook
Deny rules are fast and declarative; hooks are expressive. Routing the simple cases to deny rules keeps the screening script short, which in turn keeps it auditable — the configuration I trust most is the one I can re-read in thirty seconds.
Start with a single file
The whole mechanism begins working the moment one script exists and settings.json gains a few lines. Start with two or three deny patterns, run it for a week, and let the block log tell you what to add. That order matters: you get your first "it caught something" moment before false positives have a chance to sour you on the idea.