DEV Community

Rulestack
Rulestack

Posted 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?

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)