DEV Community

Rulestack
Rulestack

Posted on Edited on

A Claude Code deny rule missed 5 ways to git push. A pre-push hook caught all 5, but 2 flags skip it. What guards your remote?

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 git pre-push hook 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. A pre-receive hook 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-push in the working clone prints a line and exits 1
  • server pre-receive: hooks/pre-receive in 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

Terminal: in a clone whose pre-push hook exits 1, sh -c

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 PreToolUse hook, a git hook, a rule on the remote, or nothing?
  • If you use a pre-push hook, what covers --no-verify and core.hooksPath? Or do you let the remote handle those?
  • Has your agent ever reached for --no-verify on 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)

Collapse
 
mrsaynothing profile image
Mr Say Nothing •

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.

Collapse
 
rulestack profile image
Rulestack •

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 HEAD and the index and reads hooks from the common directory, so a pre-push hook in the main clone should still run there.