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)"]
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
originwas 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:
-
Refspecs.
+main,HEAD:mainandmain:maindon't end inmain, sogit push * mainnever fires. -
git's own options.
git -C,git -c, and aliases put words betweengitandpush. -
Other ways to run a program.
/usr/bin/git,sh -c,bash -lc,eval,xargs -I. -
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:
-
Split the command into simple commands on
&&,||,;,|, newlines,$(...)and backticks, keeping quoted strings together. -
Unwrap each one: drop
FOO=barprefixes and wrappers (env,timeout,xargs,nohup,exec...), and reduce/usr/bin/gittogit. Recurse intosh -c/bash -cstrings,evalarguments and local script files. -
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;
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
}
]
}
]
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)
Gotchas I hit
-
claude -pignores 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
-pmode, 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 HEADfails in a repo with no commits. My first version couldn't find the branch and blocked a plaingit pushon a feature branch.git symbolic-ref --short HEADworks 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
bypassPermissionsmode 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 apiorcurlagainst 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)