Key Takeaway
If an agent merges its own pull requests, the safety mechanism cannot be an instruction — it must be code the agent cannot talk past. A pre-tool hook that intercepts the merge command and blocks it when the test count fell or tests were skipped catches the exact failure that honesty rules do not.
The moment my coding agent started merging its own pull requests, one question got sharper: what stops it from making the tests pass the easy way? Not maliciously — a model under pressure to finish will skip a stubborn test the same way a tired developer might, and it will write a perfectly reasonable commit message explaining why.
Instructions are not an answer to that, because the same reasoning that decides to skip the test also decides whether the instruction applies. The answer has to be deterministic, and it has to sit between the agent and the merge.
Why an honesty rule is not enough
My first version of this was a paragraph in the agent's instruction file: never disable a test to make the suite pass. It is a good rule and it is not a control, for three reasons.
- It is evaluated by the thing it constrains. A model that has convinced itself a test is flaky has also convinced itself the rule does not apply here.
- It is invisible when violated. A skipped test does not fail the suite — that is the entire point of skipping it — so nothing downstream notices.
- It competes with other instructions. Under a landing deadline, finish and be honest are in tension, and the deadline is the more concrete of the two.
A hook has none of those properties. It runs before the command, it compares two facts, and it returns a decision the agent cannot reinterpret.

The gate itself
The hook fires on every tool call, ignores everything that is not a merge, and then checks two things that are both cheap to compute and hard to argue with.
#!/usr/bin/env bash
# merge-gate.sh — runs BEFORE the agent's merge command is executed.
# Deterministic, boring, and impossible to argue with.
set -euo pipefail
cmd=$(jq -r '.tool_input.command' <<< "$(cat)")
[[ "$cmd" == *"pr merge"* ]] || { echo '{}'; exit 0; }
deny() {
jq -nc --arg r "$1" '{action:"block", reason:$r}'
exit 0
}
# 1. The branch must not have fewer tests than main.
base=$(git show origin/main:package.json >/dev/null 2>&1 && \
git grep -c -E "\b(it|test)\(" origin/main -- 'src/**/*.test.ts' | \
awk -F: '{s+=$NF} END {print s+0}')
head=$(git grep -c -E "\b(it|test)\(" HEAD -- 'src/**/*.test.ts' | \
awk -F: '{s+=$NF} END {print s+0}')
(( head < base )) && deny "test count dropped from $base to $head"
# 2. No test may have been disabled to make the suite pass.
git diff origin/main...HEAD -- 'src/**/*.test.ts' \
| grep -E '^\+.*(\.skip|\.only|xit\()' \
&& deny "branch adds skipped or focused tests"
echo '{}' # allowNote what it does not attempt. It does not judge code quality, it does not assess whether the change is a good idea, and it does not read the diff for intent. Every check is a comparison between the branch and the base that a person could verify in ten seconds. That narrowness is what makes it trustworthy.
Choose the hook point that fires before the action, not after it. My first attempt used a post-verification hook, which was too late: by the time it ran, the merge had already happened and the gate was reduced to reporting a fact.
Testing the gate, not just the agent
A guard rail that has never been observed blocking anything is a guess. I verified this one both ways before trusting it: a branch that deliberately deletes a test is blocked, and a clean branch merges normally.
The negative case matters as much as the positive one. A gate that blocks everything is quickly disabled by whoever is on call, and a gate that fires on legitimate merges trains you to bypass it — which is worse than not having it, because it also costs you the illusion of protection.
Keep the gate's failure mode explicit. If the hook itself errors — a missing dependency, a git command that fails in a fresh clone — decide deliberately whether that blocks or allows, and write the decision down. A gate that silently allows on error is indistinguishable from no gate at all.
What to gate on
Four checks cover the realistic ways an unattended agent can produce a green branch that should not merge. All four are mechanical.
| Check | What it catches | Why it is safe to automate |
|---|---|---|
| Test count did not drop | Tests deleted to make the suite pass | A count comparison against the base branch, with no judgement involved |
| No new skip or focus markers | Tests disabled or narrowed instead of fixed | A grep over added lines in the diff; false positives are rare and obvious |
| Pipeline is green on the head commit | A merge attempted before checks finished | The platform already knows the answer; the gate just refuses to ignore it |
| Diff size within a ceiling | A run that quietly rewrote a whole file instead of making a small change | A line-count threshold you set once, matching the scope you asked for |
I would not add a fifth. Every additional check is another chance to block a legitimate merge at three in the morning, and the value of this gate comes from being narrow enough that nobody is ever tempted to switch it off.

The rest of the safety envelope
The gate is one layer. Four others make self-merging defensible rather than reckless.
- Scope the token. A classic repository-scoped token is enough for branches, pull requests and merges, and deliberately not enough to delete a repository.
- Require the pipeline as a platform-side check, so a red build blocks the merge even if the hook is somehow bypassed.
- Keep the blast radius small. Self-merging suits a library with a strong test suite; it does not suit a service that deploys to production on merge.
- Read the merged commits regularly. Not every one, but enough that you would notice a pattern of quietly weakened tests.
It is worth being honest that this is still a risk trade. A modest free model merging to a published library, gated only on local tests and honesty rules, is a decision I made with open eyes — and the gate is the reason I was comfortable making it.
Building your own
Four steps, in the order that gets you a working gate in an afternoon.
- Find the hook point in your runtime that fires before a tool call, and confirm it can return a block decision rather than just an observation.
- Write the narrowest check you can justify — the test count comparison is the best starting point — and nothing else.
- Prove it blocks by pushing a branch that deliberately drops a test, and prove it allows by merging a clean one.
- Add the second check only after the first has run in anger for a week without a false positive.
One deployment detail bit me: the hook script lives inside the image, so a change to it needs a rebuild rather than a copy into the running container. A gate that only exists in the container you were experimenting in is not a gate.
Letting an agent merge its own work is reasonable when the thing standing between it and the main branch is code rather than a promise. Keep the gate narrow, test both of its outcomes, make its own failure mode explicit, and it converts an uncomfortable amount of trust into a small, verifiable one.

