DEV Community

teefayb
teefayb

Posted on Edited on

I tried 22 ways to force-push past my team's Claude Code rules. 7 were hard-blocked.

If your team uses Claude Code, someone has probably put this in .claude/settings.json:

"deny": ["Bash(git push --force *)", "Bash(git push -f *)", "Bash(git push * main)"]
Enter fullscreen mode Exit fullscreen mode

It looks like it closes the door. I wanted to know how far, so I listed 22 realistic ways
to force-push or push to main and ran each one against a real Claude Code session. The
repo was a team demo with a full shared setup: deny rules, ask rules, hooks, skills.

7 of 22 were hard-blocked. The other 15 got past the deny rules. Then I added one
PreToolUse hook, and all 22 were blocked while normal feature-branch pushes still went
through.

Most Claude Code safety posts are about one developer on one laptop. This one is about the
settings file a whole team shares, where one person's "yes, allow" can land on everyone's
main.

How I tested (without hurting anything)

  • Every variant ran in a throwaway clone whose origin was a local bare repo in a temp folder. The real GitHub remote was never involved.
  • Rules layer: for each command, a headless session (claude -p --model haiku --setting-sources project) was asked to run it verbatim. I read the tool result: "has been denied" means a deny rule matched. "Requires approval" means it fell through to a permission prompt.
  • Hook layer: a PreToolUse hook is just a script that reads JSON on stdin, so I piped each command straight into it and checked the exit code.

Why does a prompt count as "got past"? A deny rule is a wall. A prompt is one click from
running, and in bypassPermissions mode, or with a broad allow, there is no click at all.
For something your team has decided to never allow, anything short of a hard block is a
gap.

The results

# Command Deny rules only Rules + hook
1 git push --force origin main Denied Blocked
2 git push -f origin main Denied Blocked
3 git push --force-with-lease origin main Denied Blocked
4 git push origin +main Prompt Blocked
5 git push origin HEAD:main Prompt Blocked
6 git push origin HEAD:refs/heads/main Prompt Blocked
7 git push origin main:main Prompt Blocked
8 git push (while on main) Prompt Blocked
9 git push --mirror Prompt Blocked
10 git push --all --force Denied Blocked
11 git -C . push --force origin main Prompt Blocked
12 git -c core.askPass=true push -f origin main Prompt Blocked
13 GIT_TRACE=0 git push -f origin main Denied Blocked
14 /usr/bin/git push -f origin main Prompt Blocked
15 cd . && git push --force origin main Denied Blocked
16 git status; git push -f origin main Denied Blocked
17 sh -c "git push -f origin main" Prompt Blocked
18 bash -lc 'git push --force origin main' Prompt Blocked
19 eval "git push -f origin main" Prompt Blocked
20 git -c alias.p=push p -f origin main Prompt Blocked
21 `echo main \ xargs -I{} git push -f origin {}` Prompt
22 bash ship.sh (a script with a force push) Prompt Blocked

Credit where it's due: Claude Code's matcher got some things right. It splits && and ;
and checks each part, and it strips env-var prefixes, so #13, #15 and #16 were denied. The
permissions docs are upfront about the
rest. They say a Bash rule "isn't a security boundary around the program", and they list
git -C . push and sh -c as forms a git push * rule won't match.

What slipped through falls into four groups:

  1. Refspecs. +main, HEAD:main and main:main don't end in main, so git push * main never fires.
  2. git's own options. git -C, git -c, and aliases put words between git and push.
  3. Other ways to run a program. /usr/bin/git, sh -c, bash -lc, eval, xargs -I.
  4. Scripts. The rule sees bash ship.sh, not what's inside it.

Six of the 15 still prompted only because the team also had a broad ask: git push * rule.
Without that rule, #4–#9 would have been one auto-approve away from running.

The fix: a PreToolUse hook that reads the whole command

Hooks run before permission rules. If a PreToolUse hook exits with code 2, the call is
blocked, even when an allow rule would let it through. So the hook can look at the full
command text and decide.

It works in three steps:

  1. Split the command into simple commands on &&, ||, ;, |, newlines, $(...) and backticks, keeping quoted strings together.
  2. Unwrap each one: drop FOO=bar prefixes and wrappers (env, timeout, xargs, nohup, exec...), and reduce /usr/bin/git to git. Recurse into sh -c/bash -c strings, eval arguments and local script files.
  3. Check any git push: skip git's global options (-C, -c, and pick up -c alias.x=push), then look at the flags and refspecs.

Here's the part that makes the call (trimmed from the real file):

if (sub !== "push") return null;

const positional = [];
let force = false;
let everything = null;
for (let j = 0; j < args.length; j++) {
  const a = args[j];
  if (PUSH_OPTS_WITH_VALUE.has(a)) { j++; continue; }
  if (/^--force(-with-lease|-if-includes)?(=|$)/.test(a) || /^-[A-Za-z]*f[A-Za-z]*$/.test(a))
    force = true;
  else if (a === "--mirror") everything = "--mirror";
  else if (a === "--all" || a === "--branches") everything = a;
  else if (a === "--delete" || a === "-d") force = true;
  else if (!a.startsWith("-")) positional.push(a);
}
if (force) return "force pushes are blocked";
if (everything) return `\`git push ${everything}\` would push protected branches`;

const refspecs = positional.slice(1);
if (!refspecs.length) {
  const branch = currentBranch(gitCwd);
  if (!branch || isProtected(branch))
    return `\`git push\` with no branch pushes the current branch (${branch ?? "unknown"})`;
  return null;
}
for (const spec of refspecs) {
  if (spec.startsWith("+")) return `"${spec}" is a force push`;
  if (/[$`*]/.test(spec)) return `can't verify the target of "${spec}"`;
  const dst = spec.includes(":") ? spec.slice(spec.indexOf(":") + 1) : spec;
  const target = dst === "HEAD" || dst === "@" ? currentBranch(gitCwd) : dst;
  if (!target || isProtected(target)) return `pushing to ${target ?? "an unknown branch"}`;
}
return null;
Enter fullscreen mode Exit fullscreen mode

For text it can't parse as a git call, like 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.

Wire it up next to your deny rules:

"PreToolUse": [
  {
    "matcher": "Bash",
    "hooks": [
      {
        "type": "command",
        "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-git-push.mjs\"",
        "timeout": 10
      }
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

Keep the deny rules. They're the first layer, and they still work if someone disables hooks.
I added a few after this test: Bash(git push * +*), Bash(git push * *:main),
Bash(git push * *:refs/heads/main), the --mirror forms, and master/production.

The hook ships with 52 Vitest cases (39 should-block, 12 should-allow, plus a current-branch
check), and they run with the team's normal npm test. Tests matter here: the easy way to
break a guard like this is a false positive, and if it blocks
git push origin feature/main-menu, people will just turn it off. These all pass:

git push origin feature-x
git push -u origin feature-x
git push origin feature/main-menu
git status && git push origin feature-x
git push                (while on feature-x)
Enter fullscreen mode Exit fullscreen mode

Gotchas I hit

  • claude -p ignores your allow rules in a folder it hasn't trusted. It prints a warning on stderr. Deny and ask rules still apply, so the test was still valid, but don't read a headless run as "how my team's setup behaves" without checking that warning.
  • In -p mode, a prompt quietly turns into a no. The tool result says "requires approval" and nothing runs. That's why the before column says "Prompt", not "Ran": in my tests nothing reached the remote. In an interactive or auto-approve session, those 15 are one step away.
  • git rev-parse --abbrev-ref HEAD fails in a repo with no commits. My first version couldn't find the branch and blocked a plain git push on a feature branch. git symbolic-ref --short HEAD works on an unborn branch. When the branch really can't be known (detached HEAD), the hook blocks.
  • Running this test in bypass mode didn't work. Claude Code's safety classifier wouldn't start an unattended session in bypassPermissions mode for it. Fair enough. The hook doesn't need a live session to test; piping JSON in is the documented way to check it.

What it doesn't protect against

Be honest with your team about this part:

  • Anything outside Claude's Bash tool: your own terminal, a git GUI, CI.
  • Indirection it can't read: npm run release, a Makefile target, a compiled binary, a script that builds the command at runtime, or a script edited after the check.
  • Pushes without the git CLI: gh api or curl against the GitHub API.
  • Anyone who edits the hook. It's code in the repo, and it changes through a PR like everything else.

The real backstop is still branch protection on main: required review, no force
pushes. The hook makes the safe path the default inside Claude Code. The server makes it the
only path.


The full setup is in the free demo repo, with the hook, tests, deny rules, team playbook and
raw results: https://github.com/teefayb/claude-code-setup-demo. If you'd rather have this
fitted to your team's repo, I do that here:
https://contra.com/s/vXvWUYYB-claude-code-setup-for-dev-teams


Update (2026-10-19): variant 23, and three rounds of review

A reader pointed out a case I missed: a plain git push takes its refspecs from git config. With
remote.origin.push = +refs/heads/*:refs/heads/* in .git/config, a harmless-looking git push from a feature
branch force-pushes every branch, including main. The hook allowed it.

My first fix tried to read the right config keys and copy git's rules. Before publishing it I ran it through an
independent code review, and it found 10 ways around that fix. The second attempt had 6 more holes, and the third
review turned up a handful of alias edge cases. Each review found fewer and smaller problems, but trying to imitate
git was never going to close the gap.

So the hook now stops imitating git. Every push has to name its branch (git push origin my-branch). With an
explicit refspec, git ignores remote.*.push and push.default, and refuses to combine refspecs with mirror mode.
A bare git push is blocked with a message telling the agent the exact command to run. Pushes that carry -c,
--config-env, --git-dir or GIT_CONFIG*/GIT_DIR overrides are blocked, and aliases (including shell aliases and
alias chains) are expanded and checked. Every review finding is now a test case: 105 tests in the repo.

The lesson for anyone writing hooks like this: when the tool you're guarding has its own configuration language,
don't re-implement it. Narrow what you allow until the configuration no longer matters. And keep server-side branch
protection on, because a client-side hook is a guardrail, not a security boundary.

Thanks to the reader who reported it. Full details are in
docs/bypass-test-results.md.

Top comments (2)

Collapse
 
vishisht_choudhary_a12d08 profile image
Vishisht Choudhary •

Love the methodical table, and the throwaway bare-repo setup is a great way to test this safely. One case that might slip past the hook is a plain git push when .git/config has a remote.origin.push refspec like +refs/heads/*:refs/heads/*, since git takes the refspecs from config instead of the command line. Is that worth covering, or do you lean on server-side branch protection as the backstop?

Collapse
 
teefayb profile image
teefayb •

You're right, and thanks. That one got straight through. A bare git push takes its refspecs from config, so the hook's "feature branch is fine" logic was looking at the wrong thing.

My first fix tried to read the config and copy git's rules. I ran it through an independent review before shipping and it found 10 ways around it (-c adding to config instead of replacing it, last-value-wins, remote.*.mirror, push.default=upstream, GIT_CONFIG_* env, include.path, aliases for git config...). So I stopped imitating git. Every push now has to name its branch (git push origin my-branch). With an explicit refspec git ignores push refspecs and push.default, and refuses mirror mode. Overrides like -c/GIT_CONFIG*/--git-dir on a push are blocked.

Your case and every review finding are now tests (105 in the repo). I added an update section to the post crediting you, and the details are in docs/bypass-test-results.md. If you find another one, I'd love to hear it.