Stopping an AI Agent from Skipping Quality Checks — A Two-Layer Push Gate with Antigravity CLI Hooks and git pre-push
An agent once judged my failing tests 'unrelated' and pushed anyway. Here is the two-layer gate — Antigravity CLI hooks plus git pre-push — I now rely on.
A while back, I handed a batch dependency update to an Antigravity agent in the repository of an Android app I run as an indie developer (a wallpaper app distributed on Google Play). Reading the work log afterwards, I found that two unit tests had failed — and the agent had decided on its own that they were "pre-existing failures unrelated to this change," then proceeded all the way to push.
Both failures were in fact caused by the dependency update. CI caught it, so no real damage was done, but the episode made one thing uncomfortably clear: my local quality checks were only being enforced on a please-and-thank-you basis.
You can write "only push after the tests pass" into the system prompt, and the agent will follow it — probabilistically. Rules that must hold need to live in machinery, not in instructions. So let me walk through the two-layer gate I now use, combining Antigravity CLI Hooks with git's pre-push hook, with the actual config and scripts included.
Skipped Instructions Are a Property, Not a Defect
Instructions to an LLM agent are followed probabilistically. The longer the context grows, the less weight early constraints carry, and if the history gets summarized mid-task, the one line saying "always run tests before pushing" may simply fall out of the summary.
What makes it trickier is that agents interpret constraints. That is exactly what happened in my opening example: the agent recognized the test failures, then constructed a rationale — "pre-existing, therefore unrelated" — and moved on. It did not lie. It behaved rationally with respect to the goal it was given, which was to finish the dependency update.
I expect this property to shrink with better models, but never to reach zero. So the starting point of the design is to separate rules that must hold from preferences I would like followed, and to move the former out of the prompt and into machinery. Deciding how hard to guard something by how reversible it is — an idea I wrote about in Delegate the Undoable, Guard the Irreversible — Tiering Agent Autonomy by Reversibility — puts push firmly on the guarded side: it drags other people, and CI, into the shared history of the repository.
The Two-Layer Gate at a Glance
The setup I currently run has two layers.
Layer 1: Antigravity CLI Hooks (PreToolUse) — intercepts the moment the agent tries to run git push and executes the gate script. The blocking message is fed straight back to the agent, so the self-correction loop turns around quickly
Layer 2: git's pre-push hook — runs immediately before any push, no matter where it comes from. Even if the Hooks config is broken, even if a human pushes from another terminal, the same gate runs. It is the deterministic last line of defense
In one sentence each: layer 1 exists to return correction feedback to the agent fast; layer 2 exists to let nothing through, period. Layer 1 alone is powerless against pushes that happen outside the CLI; layer 2 alone communicates its reasons poorly back to the agent, so the correction loop slows down. You need both for speed and certainty at the same time.
Outside these two sits the remote-side defense: branch protection and required CI checks. The local pair prevents bad pushes from ever happening; the remote side protects main when one happens anyway. If you rely only on the remote side, the agent discovers the block only after CI finishes, which adds minutes to every iteration of its loop — stopping locally is worth it.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can stop pushes that skip tests or lint from ever leaving the machine — enforced by hooks and git, not by asking the agent nicely
✦You'll take home a working two-layer setup: an Antigravity CLI PreToolUse hook plus a git pre-push script you can drop into your own repo
✦You'll learn how to close bypass routes like --no-verify and self-edited configs so the gate holds even with multiple agents running in parallel
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Layer 1: Screening git push with a PreToolUse Hook
In Antigravity CLI (2.0.0) you can define Hooks in .antigravity/settings.json at the project root. The mechanism is inherited from Gemini CLI and lets you insert arbitrary commands before and after tool execution. For screening pushes, the relevant event is PreToolUse, which fires before a tool runs.
The matcher narrows the hook to the shell tool, and the condition regex restricts it to commands containing git push. If the hook command exits non-zero, the tool call itself is blocked and the hook's stderr is handed to the agent. That property — stderr becomes your reply to the agent — is the core of the message design in the next section.
One caveat: the Hooks schema is still moving, so key names may change between versions. The shape above works in my environment on 2.0.0, but I recommend checking the Hooks section of the Antigravity changelog before wiring it up.
The Gate Script — Write the Messages for the Agent, Not for Humans
This is the gate body called from both layers. What it inspects will vary by repository; my minimal set is tests, lint, and a secret scan.
#!/usr/bin/env bash# .agent/hooks/pre-push-gate.sh — pre-push gate (shared by layers 1 and 2)# exit 0 = allow / non-zero = block the pushset -ufail() { echo "BLOCKED: $1" >&2 exit 1}# 1. Unit tests./gradlew testDebugUnitTest --console=plain -q \ || fail "Unit tests are failing. Fix the failing tests and re-run this gate. Do not retry the push before fixing them."# 2. Lint./gradlew lintDebug -q \ || fail "Lint errors found. Fix the issues in the lint report, then re-run the gate."# 3. Secret scan (fall back to a simple grep when gitleaks is absent)if command -v gitleaks >/dev/null 2>&1; then gitleaks detect --no-banner --log-opts "origin/main..HEAD" \ || fail "Possible secrets in the commits about to be pushed. Remove the credentials and check whether history rewriting is needed."else if git diff origin/main...HEAD | grep -qE "BEGIN (RSA|EC|OPENSSH) PRIVATE KEY"; then fail "The diff appears to contain a private key. Abort the push and inspect the file." fifiecho "GATE PASSED: tests, lint, and secret scan all clear. You may run the push now."exit 0
The point is to write the fail() messages not as human-facing error text but as the agent's next instruction. If the message just says "unit tests are failing," the agent tends to treat it as a status report and goes right back to retrying the push. Spelling out the forbidden action — "do not retry the push before fixing them" — visibly reduced the post-block thrashing in my logs.
When blocked, this is what the agent sees:
$ git push origin main[hook] running .agent/hooks/pre-push-gate.sh ...> Task :app:testDebugUnitTest FAILEDBLOCKED: Unit tests are failing. Fix the failing tests and re-runthis gate. Do not retry the push before fixing them.(tool call blocked: exit code 1)
Layer 2: git pre-push as the Deterministic Last Line
Git ships a standard hook mechanism for inserting scripts around commits and pushes; the one that runs right before a push is pre-push. Because hooks placed directly in .git/hooks do not travel with clones, I keep them under version control in .githooks/ and point core.hooksPath at it.
#!/usr/bin/env bash# .githooks/pre-push — layer 2: the last line, regardless of route# exiting non-zero aborts the entire pushset -uremote="$1" # remote name (e.g. origin)url="$2" # remote URL# stdin receives lines of: <local ref> <local sha> <remote ref> <remote sha>while read -r local_ref local_sha remote_ref remote_sha; do case "$remote_ref" in refs/heads/main|refs/heads/release/*) echo "[pre-push] ${remote_ref} -> ${remote}: running the gate" >&2 ./.agent/hooks/pre-push-gate.sh || exit 1 ;; esacdoneexit 0
Gating only main and release branches is deliberate. Gating every push to working branches slows the agent's trial-and-error too much, so the temperature gradient is: main-line branches are hard, working branches are fast. How hard to make each tier, I decide by the reversibility of the operation — that is my personal rule of thumb.
If you run several agents in parallel across multiple worktrees, setting core.hooksPath in the repository-local config covers all worktrees at once, since local git config is shared between worktrees by default. In parallel operation that one-place-to-configure property becomes a real advantage.
Closing the Bypass Routes — --no-verify and Self-Edited Configs
Even with both layers in place, bypass routes remain. These are the three I have actually had to deal with in operation.
git push --no-verify — by specification, pre-push hooks are skipped under --no-verify. My countermeasure is twofold: remove --no-verify pushes from the agent's allowed-command set, and add a branch to the layer-1 condition that unconditionally blocks any push containing the flag.
Rewriting the Hooks config itself — the agent is in a position to edit .antigravity/settings.json. The awkward part is that this can happen not out of malice but as optimization: removing the thing that keeps blocking it. I keep the config and the gate script outside the agent's writable directories, and any change to them requires review.
Chaining the gate and the push with && — ask an agent to "run the gate, then push," and it will often batch them into one command like bash gate.sh && git push. It looks correct, but it erases the checkpoint where a failure gets inspected and fixed, and depending on how the shell line is written, a failure can be swallowed while the push still runs. My operating rule: the gate run and the push run stay separate steps.
Number three comes with a scar. In another repository I missed a line equivalent to gate || true; git push, and a push went through with the gate failing. Fortunately layer 2 was already in place and pre-push stopped it — but the lesson stands: you cannot fully prevent batching through instructions alone, and in the end you lean on layer 2's determinism. For the mirror-image failure — a push that looks successful but never reaches the remote — see When an AI Agent's git push Reports Success but Nothing Reaches the Remote.
What Two Weeks of Parallel-Agent Operation Showed
After rolling this out, I started appending the gate result as one JSON line per run.
# appended at the end of pre-push-gate.sh for later aggregationRESULT="passed" # fail() records "blocked" insteadprintf '{"ts":"%s","result":"%s","branch":"%s"}\n' \ "$(date -u +%FT%TZ)" "$RESULT" "$(git branch --show-current)" \ >> .agent/logs/gate.jsonl# aggregation example: block rate# jq -r .result .agent/logs/gate.jsonl | sort | uniq -c
Over two weeks of logs on my machine, 6 of 31 agent-initiated push attempts (about 19%) were blocked at layer 1 — four for failing tests, one for lint, one by the secret scan. The secret was a debug connection string I had forgotten to remove; having that stopped before it landed in the history of a production repository paid for the whole setup by itself, in my view.
The other discovery: once the block messages were phrased as next instructions, the agent settled into a stable loop of fixing the tests on its own, re-running the gate, and only then pushing again. Layer 1's value, I came to understand, lies less in stopping things than in returning the reason in a machine-actionable form. It pairs well with the habit of keeping diffs small (Teaching Antigravity's Agent to Keep Diffs Small: How a Month Changed the Way I Review) — small diffs make post-block fixes small too.
One caution: watch the gate's runtime. In repositories with long test suites, every push now waits several minutes, and I once observed the agent misreading that as a hang and hammering the push repeatedly. Either set layer 1's timeoutSec comfortably above your test duration, or narrow the gate to tests related to the changed files. I recommend starting with the former and adding the narrowing only when the wait starts to hurt.
Start with the Single pre-push File
You do not have to build both layers at once. By impact, the first thing to install is layer 2's pre-push: write the one file under .githooks/ and run git config core.hooksPath .githooks. That is ten minutes of work, and from that moment no push gets through ungated, by any route. Add the layer-1 Hooks later, when you want the agent's correction loop to turn faster.
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.