DEV Community

teefayb
teefayb

Posted 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

Top comments (0)