How to block git push, rm -rf and .env reads in Claude Code
You can stop Claude Code from pushing your code, deleting folders or reading your secrets. Permission rules do most of it, but they have a gap: Claude Code's own documentation says a rule like Bash(git push *) does not stop git -C . push origin main. A small hook closes that gap. Everything here was run against Claude Code 2.1.278 on Windows in September 2026.
The short version: add deny rules for the common forms, then add a PreToolUse hook that reads the whole command and exits with code 2 to block it. Exit code 1 does not block anything.
Step 1: deny rules
Put these in .claude/settings.json in your project. Commit the file and your whole team gets them. A deny rule wins over any allow rule.
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(!.env.example)",
"Read(./secrets/**)",
"Bash(git push *)",
"Bash(git reset --hard *)",
"Bash(sudo *)"
]
}
}
A Read deny rule also blocks the Write and Edit tools on the same path, so this protects .env from being overwritten too. We checked that in a real session: reading .env and writing to it were both refused, and the file was untouched. A rule ending in * with a space before it also matches the bare command, so Bash(git push *) covers plain git push as well.
The .env.example trap
Read(./.env.*) also matches .env.example, which you usually want the AI to read. A rule starting with ! carves a file back out. The spelling matters: in our test Read(!.env.example) worked, and Read(!./.env.example) (with ./) did not, so .env.example stayed blocked. Keep the ! pattern without ./, and keep it after the rule it carves out of.
The gap: what a Bash rule doesn't match
A Bash rule matches the command text Claude writes. The documentation lists forms it does not stop:
Rule Stops Doesn't stop
Bash(git push *) git push origin main git -C . push origin main
git -c push.default=current push origin main
git 'push' origin main
Bash(rm *) rm -rf build/ /bin/rm -rf build/
bash -c 'rm -rf build/'
Those are perfectly normal ways for an AI to write a command, so a deny rule alone is a false sense of safety. The documentation's own suggestion for inspecting the full command is a PreToolUse hook.
Step 2: a hook that reads the whole command
A hook is a small program Claude Code runs before a tool call. It receives the call as JSON on standard input. If the program exits with code 2, the call is blocked and whatever it wrote to standard error is shown to Claude. Save this as .claude/hooks/guard-push.mjs. It needs Node.js.
// .claude/hooks/guard-push.mjs
// Blocks every spelling of `git push`. Claude Code sends the tool call as JSON on stdin.
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
const command = JSON.parse(raw).tool_input?.command ?? '';
// git, then any options (-C path, -c key=value, --no-pager, ...), then the word push (quotes allowed).
const gitPush = /\bgit(?:\.exe)?(?:\s+(?:-[Cc]\s+\S+|--?[\w-]+(?:=\S+)?))*\s+['"]?push['"]?(?=\s|$|[;&|)'"])/;
if (gitPush.test(command)) {
console.error('git push is blocked in this project. Tell the user the work is ready and let them push it.');
process.exit(2); // 2 blocks the command. Exit code 1 would NOT block it.
}
Then register it in the same .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-push.mjs"]
}
]
}
]
}
}
Because args is set, Claude Code starts node directly with no shell in between, which avoids quoting problems with paths that contain spaces. ${CLAUDE_PROJECT_DIR} is the project root, so the hook is found whichever folder Claude is working in.
Does it work? A real test
We put only this hook in a scratch project and asked Claude Code to run git -C . push origin main. The hook stopped it, and the message reached Claude:
PreToolUse:Bash hook error: [node ${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-push.mjs]:
git push is blocked in this project. Tell the user the work is ready and let them push it.
As a control, we removed the hook and ran the same request. This time the command was actually attempted (it only failed because the scratch repository had no remote). So the hook is what made the difference.
The script also passed 33 automated checks: 18 spellings of git push that must be blocked, 14 harmless commands that must not be, and a check that garbled input never blocks. The harmless ones include git commit -m "add push button", git stash push and git checkout push-fix.
How to test yours
- Run the script by hand:
echo '{"tool_input":{"command":"git -C . push"}}' | node .claude/hooks/guard-push.mjs, then check the exit code. It should be 2. - Inside Claude Code, type
/hooksto confirm the hook is listed. - Ask Claude to run
git -C . push origin main. Claude sometimes declines by itself, especially if yourCLAUDE.mdalready says not to push, and then the hook is never tested. Tell it the repository is a throwaway one.
Gotchas we hit, or that the docs warn about
- Exit code 1 does not block. Only exit code 2 does. A hook that crashes or exits 1 lets the command run, and Claude Code just shows a "hook error" notice. If you enforce a policy, watch for that notice on the first run.
- A hook that never starts lets everything through. A mistyped script path or a missing
nodegives a non-blocking error. That is why we keep the deny rules in Step 1 too: Claude Code enforces them itself, without a hook. - Timeouts don't block either. A hook that hits its time limit is cancelled, and the command continues.
- Windows without Git Bash has no Bash tool. Claude Code uses PowerShell instead, and a hook that matches only
Bashnever fires there. The matcherBash|PowerShellcovers both. - Windows paths have backslashes. If you write a hook for file edits, the path arrives as
C:\project\src\index.ts. A check for/src/never matches. Replace backslashes with forward slashes before comparing. - Exec form needs a real program. On Windows,
npmandnpxare.cmdshims and can't be started this way.nodeplus a script path works everywhere.
What a hook can't do
A hook reads the text of a command. It is not a sandbox. A script that runs the blocked command for you, or an unusual way of writing it, can get past any text check. If you need a hard guarantee, turn on Claude Code's sandboxing as well. For most people, deny rules plus a hook stop the ways an AI normally writes these commands.
Want the whole thing, already tested? The Safe setup pack covers rm -rf, curl | sh, destructive database commands, secrets in .env, editing lock files and protecting the guards themselves, with 199 automated tests.