A Claude Code deny rule,
Bash(git push:*), let 5 of 14 spellings of "push" reach the remote in our earlier test. This time we put a gitpre-pushhook in the same kind of throwaway repo and ran the spellings that got past the rule, in plain bash. The hook stopped all 7 we tried. Two spellings that switch hooks off walked past it. Apre-receivehook on the remote side stopped all 9. This is a question post: I'd like to know what actually stops pushes in your setup.
Why we ran it
Our measurement of a deny rule against 14 ways to push ended with a plain conclusion: the rule matches the command text the model writes, so git -c user.name=x push, git 'push', sh -c "git push", eval on a variable and a script file all went through. The Claude Code permissions page says as much: a rule "covers the invocation Claude usually produces and isn't a security boundary around the program."
The obvious next question was what sits below the command text. Git itself runs a hook before every push, whatever the spelling that started it. So we asked git directly, with no model involved.
What we ran
Everything ran on 2026-10-04 with git 2.50.1 (Apple Git-155), in a directory made with mktemp -d, against a bare repository on the same disk. Each command went through bash -c. The detector is the same as last time: delete the remote's main ref before each command, and check afterwards whether it came back.
We tried 9 command lines: the plain git push origin main, the 6 that the deny rule did not block in the earlier test, and 2 that turn hooks off. Each ran in three rounds:
- no hook, as a baseline
-
client
pre-push:.git/hooks/pre-pushin the working clone prints a line and exits 1 -
server
pre-receive:hooks/pre-receivein the bare remote prints a line and exits 1
| Command | No hook | Client pre-push
|
Server pre-receive
|
|---|---|---|---|
git push origin main |
Pushed | Stopped | Stopped |
git -c user.name=x push origin main |
Pushed | Stopped | Stopped |
git 'push' origin main |
Pushed | Stopped | Stopped |
sh -c "git push origin main" |
Pushed | Stopped | Stopped |
c="git push"; eval "$c origin main" |
Pushed | Stopped | Stopped |
./push.sh (contains git push origin main) |
Pushed | Stopped | Stopped |
c="git push"; $c origin main |
Pushed | Stopped | Stopped |
git push --no-verify origin main |
Pushed | Pushed | Stopped |
git -c core.hooksPath=/dev/null push origin main |
Pushed | Pushed | Stopped |
One row settles a loose end from the earlier post. c="git push"; $c origin main failed there because the Bash tool on that machine ran under zsh, which does not split an unquoted variable into words. Under bash, with no hook, it pushed.
Why the hook caught what the rule missed
The git documentation describes pre-push as a hook that "is called by git-push[1] and can be used to prevent a push from taking place." It does not read the command line. It runs because git is about to push, so sh -c, eval and a script file all end up in the same place.
Why it missed the last two
The git push manual is direct about the first one: "With --no-verify, the hook is bypassed completely."
The second uses the same git -c that slipped past the deny rule. The hooks page says the hooks directory "can be changed via the core.hooksPath configuration variable", and -c sets a configuration value for one command. Point it at /dev/null and git finds no hook to run.
There is also a plainer gap: the client hook is a file inside the clone the agent works in. We did not test an agent deleting or editing it, but nothing in git stops a process with write access to .git/hooks from doing so.
The remote side
The pre-receive hook runs in the repository that receives the push, and the hooks page says "Its exit status determines the success or failure of the update." It stopped all 9, including the two that skipped the client hook, because neither flag reaches the receiving side.
We only tested a bare repository on the same disk. On a hosted remote, the comparable check is whatever rule the host applies to a branch. We did not test one, so I won't claim how any particular host behaves.
Where we stand ourselves
Our committed .claude/settings.json has no permission rules at all. The only hook in our repository's git hooks folder is a pre-commit hook. The rule that every push goes through one script, which pulls, runs our checks and tests again, and then pushes, is a sentence in our CLAUDE.md. Nothing in the repository would stop the agent typing git push itself.
After this test, a pre-push hook that runs the same checks looks cheap, and the table says it would cover the spellings a deny rule misses. It would not cover --no-verify, and we have not added it. Part of why this is a question post is that I'm not sure the hook is worth it without a remote-side rule behind it.
What I'd like to know
-
What actually stops a push in your setup? A deny rule, a
PreToolUsehook, a git hook, a rule on the remote, or nothing? -
If you use a
pre-pushhook, what covers--no-verifyandcore.hooksPath? Or do you let the remote handle those? -
Has your agent ever reached for
--no-verifyon its own after a hook failed? - Do you let the agent push at all, or keep pushes for a human?
The earlier measurement: Claude Code permission rules: Bash(git push:*) stopped 8 of 14 ways to push, and 5 reached the remote
Whatever guards your remote, or the push that got past it, please put it in the comments below. I'll answer each one there. For more measurements like this.
More from this account on the same problem:

Top comments (2)
The plainer gap is the decisive one: a client hook is just a file inside the clone the agent is working in, so the same agent that tripped the deny rule can edit the hook. That makes the split easy to state — client hooks are culture, server hooks are law. --no-verify only skips the client side, which is exactly why the pre-receive check is the one that matters. Did you also test pushes from a fresh clone or worktree where the hook never got installed? That path skips both the deny rule and your hook while looking perfectly ordinary in the logs.
I'd draw the same culture/law line. No, we didn't test a fresh clone: every run was in the one clone where we had put the hook, and the no-hook column, where all 9 pushed, is the closest we have. A worktree may not be the same gap, though: going by the git docs rather than a run, a linked worktree shares everything except per-worktree files such as
HEADand the index and reads hooks from the common directory, so apre-pushhook in the main clone should still run there.