[KongchangAI]
· 3 min read· 1,634 words

22 Ways to Bypass Claude Code Push Rules, Only 7 Hard-Blocked

22 Ways to Bypass Claude Code Push Rules, Only 7 Hard-Blocked

Claude Code deny rules block only 7 of 22 force-push variants; a PreToolUse hook closes the gap.

The article tests Claude Code's deny rules with 22 real git commands and finds only 7 are hard-blocked, while 15 bypass them via refspec variations, git global options, wrappers, and script files. The author notes the official docs also admit Bash rules are not a security boundary, and proposes a PreToolUse hook that parses the full command in three steps to block all 22 without harming feature-branch pushes, plus 52 Vitest cases to prevent false positives. It also records pitfalls like headless mode and unborn branches, and notes the hook cannot cover your own terminal, CI, indirect scripts, or API pushes—GitHub main branch protection remains the ultimate backstop.

A team wrote "deny": ["Bash(git push --force *)", "Bash(git push -f *)", "Bash(git push * main)"] into .claude/settings.json, seemingly closing off both force pushes and direct pushes to main. But after one developer tested 22 real commands one by one, it turned out that only 7 were hard-blocked, while the other 15 bypassed the deny rules (most merely triggered a permission prompt). After adding a PreToolUse hook, all 22 were blocked, and normal feature-branch pushes were unaffected.

Test results overview

Test Method: Item-by-Item Verification in an Isolated Environment

The tests were run in a disposable clone, with origin pointing to a local bare repository in a temporary directory—no real GitHub remote involved. The rules layer used a headless session (claude -p --model haiku --setting-sources project) to execute each command and read the tool result to determine whether it was "denied" or "requires approval." The hook layer passed the command JSON directly to the PreToolUse script via stdin and checked the exit code.

The author stresses that a prompt is not a block: "A deny rule is a wall; a prompt is just one click away from running, and under bypassPermissions mode or broad allow rules, not even a click is needed." For operations a team decides should never be permitted, any non-hard block is a gap.

Claude Code's permission system and settings.json

Claude Code's permission system consists of three types of rules in the project-level .claude/settings.json: allow, ask, and deny. Rules in allow let matching calls execute directly without confirmation; rules in ask make calls pop up an approval prompt; rules in deny reject calls outright. Rules are written as Tool(matcher), so for example Bash(git push *) matches Bash command strings by prefix and wildcard. There is also bypassPermissions mode, in which all non-deny calls are no longer asked about—so for "never allow" operations, only deny is a reliable block. The PreToolUse hook is another mechanism that runs before permission rules are evaluated; it can read the full tool input and force a block via an exit code (such as 2), thereby compensating for the shortcomings of string-matching rules.

The 15 Bypasses Expose Four Categories of Gaps

The commands that were not blocked fall into four groups:

1. Refspec variations. git push origin +main, HEAD:main, main:main, and the like do not end with main, so the git push * main rule fails to match.

2. git's own options. git -C . push, git -c core.askPass=true push, and aliases (such as git -c alias.p=push p -f origin main) insert arguments between git and push, bypassing pattern matching.

3. Other execution methods. /usr/bin/git push, sh -c "git push -f", bash -lc 'git push --force', eval, xargs -I{}, and other wrappers keep the rules from seeing the real git call.

4. Script files. bash ship.sh only exposes the script name, so the rules cannot see the force push inside.

Valuable to note: Claude Code's matcher is not entirely useless—it correctly splits && and ; and checks each part, and it strips environment-variable prefixes, so GIT_TRACE=0 git push -f, cd . && git push --force, and git status; git push -f were successfully denied. The official permission docs also candidly admit that Bash rules "are not a security boundary for the program" and explicitly list git -C . push and sh -c as forms that the git push * rule cannot match.

Also, 6 of the 15 bypasses still surfaced a prompt only because the team had additionally configured a broad ask: git push * rule. Without that rule, commands 4 through 9 would be one step away from automatic approval.

Refspecs and git global options

A refspec is an expression in git push that specifies source and target references; common forms include src:dst, +refs/heads/main:refs/heads/main, and so on, where the + prefix means forced update. Because the rule git push * main depends on the command string ending with main or containing a specific fragment, forms like HEAD:main, main:main, and origin +main change the ending shape, causing the pattern to miss. On the other hand, git allows global options to be inserted before the subcommand, such as git -C <path> to specify the working directory and git -c key=value to temporarily override configuration (including defining an alias alias.p=push). These options sit between git and push, making rules that literally match git push ineffective. Understanding these two syntaxes is a prerequisite for designing a reliable guard: you must parse out the real subcommand, flags, and refspec rather than doing only string-prefix matching.

The Fix: A PreToolUse Hook That Reads the Full Command

The hook runs before permission rules; an exit code of 2 blocks the call even if an allow rule would have let it through. Thus the hook can inspect the full command text and make a judgment. It works in three steps:

  1. Split: split the command into simple commands on &&, ||, ;, |, newlines, $(...), and backticks, preserving strings inside quotes.
  2. Unwrap: discard FOO=bar prefixes and wrappers such as env, timeout, xargs, nohup, and exec; restore /usr/bin/git to git; and recursively enter sh -c/bash -c strings, eval arguments, and local script files.
  3. Check: for any git push, skip git global options (-C, -c, recognizing -c alias.x=push), then check flags and refspecs.

The core judgment logic (excerpted from the real file) detects flags such as --force, -f, --mirror, --all, and --delete, as well as refspecs starting with + and protected-branch targets. For text that cannot be parsed as a git call (such as python3 -c "subprocess.run(['git','push','-f',...])"), it falls back to a regex: git + push + a force flag or a protected branch name means block.

The hook is wired in via the PreToolUse configuration in settings.json and coexists with the deny rules. The author recommends keeping the deny rules as the first line of defense, and after testing added Bash(git push * +*), Bash(git push * *:main), Bash(git push * *:refs/heads/main), plus rules for --mirror forms and the master/production branches.

The hook ships with 52 Vitest cases (39 should block, 12 should allow, plus a current-branch check), run as part of the team's regular npm test. Tests are critical: guards like this most easily fail due to false positives—if it blocked git push origin feature/main-menu, people would just turn it off.

PreToolUse hook exit-code semantics

In Claude Code, PreToolUse hooks are registered via the hooks field of settings.json and can run before a tool such as Bash is called. The hook receives JSON containing the tool name and input on stdin, and its exit code determines what happens next: exit code 0 means allow and continue through the normal permission flow; exit code 2 means a hard block—the tool call will not execute, and this block takes precedence over allow rules; other nonzero exit codes are generally treated as errors. This is why the hook can read the full command text beyond string-matching rules, and even recursively parse wrappers and script contents, thereby detecting force pushes that deny patterns miss. But the hook is itself local configuration and can be modified by anyone with permission, so what it improves is the reliability of the default safe path, not an unbypassable security boundary.

Pitfalls Encountered in Practice

  • claude -p ignores allow rules in untrusted folders and prints a warning to stderr. deny and ask rules still apply, so the tests are valid, but a headless run should not be equated directly with the team's actual configured behavior.
  • In -p mode, prompts silently become denials, and the tool result shows "requires approval" without executing. So in the tests, the "prompt" column did not actually reach the remote—but in an interactive or auto-approving session, those 15 commands are one step away.
  • git rev-parse --abbrev-ref HEAD fails in a repository with no commits, causing the first version of the hook to mistakenly block an ordinary git push on a feature branch. Switching to git symbolic-ref --short HEAD handles unborn branches; when the branch truly cannot be determined (detached HEAD), the hook chooses to block.
  • Running the tests in bypass mode did not work—Claude Code's safety classifier refused to start bypassPermissions mode for an unattended session. But the hook does not need a live session; piping in JSON is the documented testing approach.

What It Cannot Protect Against

The author frankly lists the boundaries: everything outside the Claude Bash tool (your own terminal, git GUIs, CI); indirect calls it cannot read (npm run release, Makefile targets, compiled binaries, scripts that build commands at runtime); pushes that do not use the git CLI (gh api or curl calling the GitHub API); and anyone who edits the hook. The ultimate backstop remains main branch protection: require review and prohibit force pushes. The hook makes the safe path the default within Claude Code, while the server makes the safe path the only one.

The complete setup (hook, tests, deny rules, team playbook, and raw results) is public in a free demo repository.

One-line Summary

Claude Code deny rules block only 7 of 22 force-push variants; a PreToolUse hook closes the gap.

Paragraph Summary

The article tests Claude Code's deny rules with 22 real git commands and finds that only 7 are hard-blocked, while 15 bypass them via refspec variations, git global options, wrappers, and script files. The author notes that the official docs also admit Bash rules are not a security boundary. It proposes a PreToolUse hook that parses the full command in three steps—split, unwrap, check—blocking all 22 commands without harming feature-branch pushes, and includes 52 Vitest cases to prevent false positives. It also records practical pitfalls such as headless mode and unborn branches, and makes clear the hook cannot cover your own terminal, CI, indirect scripts, or API pushes; the ultimate backstop is still GitHub main branch protection.

#Claude Code#git push#PreToolUse hook#deny rules#branch protection
Share:

Related articles