My coding agent has one rule it must never break: don't push straight to main, master or staging. The instructions file says so. A hook on the push command still had to stop it six times.
The hook was the easy part. Trusting it took longer.
My first version was a shell script on Claude Code's PreToolUse hook. It looked for the word "staging" near a push and blocked the command.
When I finally counted, it had blocked 33 commands. 27 of them should have gone through. Almost all were the same thing: gh pr create --base staging, which is exactly what I wanted the agent to do instead of pushing.
It had a second bug. It printed its reason to stdout. The agent got the block and the message "No stderr output", so it had nothing to act on. It retried, or guessed, or asked me.
So the guard was stopping the right move and explaining nothing. I only found out because I went looking.
When I rewrote it as a proper plugin (rules-guard), I didn't want to find the next false positive in the middle of real work. Unit tests helped, but I write tests for the cases I already thought about.
The better test set was already on disk: every shell command the agent had run on my machine. Claude Code keeps each session's history locally, and every Bash call in it has the exact command and the folder it ran in.
So I pulled out the Bash calls and ran each one through the new rules, without executing anything:
That gave me two numbers I care about. Missed blocks: 0. Wrong blocks: 0. And I knew which 15 commands the decision rested on.
A guard drifts. Every new rule, every refactor and every message tweak can change a decision you didn't mean to touch.
So the replay became part of every change. When I rewrote all the block messages later, the replay ran over 11,628 calls. The wording changed on every block. Not one allow or block decision did. That's the result I wanted, and I didn't have to argue it from reading the diff.
The setup is small:
decision + rule per call.
The other lesson from "No stderr output": the one reading the block message is the agent. If the message doesn't tell it what to do, it guesses.
Every rules-guard block now has the same shape:
rules-guard · protected-push · use-instead
Problem: pushing to main directly is not allowed (user rule: branch + PR only).
Where: git push origin main (in ~/code/app)
Fix: push a feature branch (git push -u origin <feature-branch>) and open a PR into main.
Ask the user: only when a direct push is truly needed; they push it themselves. Do not work around this block.
The first line names the tool, the rule and a category, and the category does most of the work:
fix-and-retry: change the command and run it again use-instead: there's another route, like a branch and a PR env-setup: something on the machine needs fixing first (VPN down, expired SSO session)held: stop, and ask the user
The Ask the user: line is optional. It's there when the next step isn't the agent's call, and it tells the agent not to look for a way around the block.
Since the switch, the agent reads the Fix line and follows it. A blocked push turns into a branch and a PR without me stepping in.
I still keep the rules in my instructions file. That's where the reason lives, and it shapes what the agent tries in the first place. But in a long session, with a failing check and a fix ready, pushing looks helpful, and one line of markdown is competing with everything else in the context.
A hook isn't competing with anything. It sees the actual call. So anything that must never happen gets a hook, and the hook gets tested against real history before I trust it.
rules-guard is MIT licensed and part of a small set of Claude Code mods:
/plugin marketplace add AbdAlrahmanAlQaroot/claude-code-mods
/plugin install rules-guard@claude-code-mods
Protected-branch pushes, plain force pushes and production kubectl/AWS calls are blocked by default. The stricter personal rules (AI attribution lines, PR description format, new code comments, test files in commits) are options, off by default. It makes no network calls.
Source: https://github.com/AbdAlrahmanAlQaroot/claude-code-mods
If you run a guard between an agent and your shell, what do you test it against?