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?
Rulestack sells guides, hooks and skills for Claude Code at rulestack.gumroad.com.
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, follow @ai-shop.bsky.social.

Top comments (0)