The annoyance
You write a PreToolUse hook to keep Claude off .env. The script exits 1 when the path matches. That is the Unix failure code, so it feels like a gate. The transcript looks fine. The file still gets edited.
It is not a flaky script. It is the documented contract.
What Anthropic actually says
From the official hooks reference, under exit-code output:
“For most hook events, exit code 2 is the only exit code that blocks through the code alone. Without valid JSON on stdout, Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code. If your hook is meant to enforce a policy, use exit 2.”
Two things follow. First, exit 1 is a notice, not a stop. The action continues. Second, a mistyped or non-executable script path exits 127, which is the same non-blocking bucket, so a broken gate fails open as well.
The one documented exception is WorktreeCreate, where any non-zero exit aborts worktree creation. Do not generalise that to PreToolUse.
The gate
Here is the official protect-files pattern. Reason on stderr. exit 2 to block. exit 0 to stay silent.
#!/bin/bash
# protect-files.sh
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Normalize Windows backslash separators so the patterns below match
FILE_PATH="${FILE_PATH//\\//}"
PROTECTED_PATTERNS=(".env" "package-lock.json" ".git/")
for pattern in "${PROTECTED_PATTERNS[@]}"; do
if [[ "$FILE_PATH" == *"$pattern"* ]]; then
echo "Blocked: $FILE_PATH matches protected pattern '$pattern'" >&2
exit 2
fi
done
exit 0Then: chmod +x .claude/hooks/protect-files.sh
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh"
}
]
}
]
}
}Do not put the reason on stdout if you are using the exit-code path. Do not register this as a standalone hooks file. It belongs under the hooks key in a settings file.
Prove it
Prove the script, then prove Claude Code loaded it.
echo '{"tool_input":{"file_path":".env"}}' | .claude/hooks/protect-files.sh; echo "exit=$?"
# must print 2, not 1
# then in-session: /hooksIf /hooks does not list the handler, the gate is not installed. If the transcript ever shows “Failed with non-blocking status code”, the hook ran and the tool still proceeded. That is exit 1 (or 127) doing exactly what the docs say.
Why exit 1 fails
“Return a failure code” is muscle memory. On Unix, 1 means the process failed. In Claude Code, 1 means the process failed and the action continues. The hook looks like it worked. The file still changes.
Testable rule, same as the other notes in this series: if the hook is a policy, the last line is exit 2. Anything else is a log line.
Sources
From idea to operation
Make the next AI decision concrete.
NavAIgate helps leadership teams identify high-value AI opportunities, prove them safely and turn the winners into working systems.
