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

  1. 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.
  2. Inside Claude Code, type /hooks to confirm the hook is listed.
  3. Ask Claude to run git -C . push origin main. Claude sometimes declines by itself, especially if your CLAUDE.md already 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

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.

See the Safe setup pack Get early access to Pro