A Renamed Tool Is a Disabled Guard: Contract-Test Hook Coverage Before Antigravity’s Oct 5 Cutover
Antigravity agent hooks match tool names and fail open, and 09-2026 renamed the file tools. Test guard coverage and replay wire fixtures before Oct 5.
Go deeper. Build your own.
The Java version of Google’s own privacy-gate sample for Antigravity agent hooks guards file reads by matching the tool name read_file. The Antigravity agent version Google released on Sep 17 has no tool by that name. It reads files with view_file, which takes an AbsolutePath where the old tool took a path, and the Java sample, still on the hooks page as of Sep 21, matches nothing. Nothing errors. A regex that finds no tool is a valid regex, and a hook that never fires looks exactly like a hook that approved every call.
That is the whole failure, and it isn’t Google’s alone. A guard is a string compared against a tool name, plus a script that reads a field from the call. Rename the tool or the field and the guard stays installed, loads cleanly and stops guarding. The May version of the Antigravity agent shuts down on Oct 5, so every lane pinned to it changes tools within two weeks, whether you plan the move or the API makes it for you.
By Tuesday you’ll have a guard contract suite that runs on every agent-version or harness bump and daily. It asserts coverage against the tools the agent actually has, replays recorded payloads and deliberate failures through every guard, and takes write evidence from the disk. You’ll also pin antigravity-preview-09-2026 today, with a drill for both things Oct 5 might do.
Sep 17: Antigravity 09-2026 renames the file tools, and Google’s docs disagree about Oct 5
On Sep 17, 2026, the Gemini API changelog released antigravity-preview-09-2026 as the replacement for antigravity-preview-05-2026, which it deprecates. Four built-in tools changed name and shape, and two search tools arrived where 05 had only the shell:
| Job | 05-2026 | 09-2026 |
|---|---|---|
| Write a file | write_file(path, content) |
write_to_file(TargetFile, CodeContent, Overwrite, Description) |
| Edit a file | full rewrite | replace_file_content(TargetFile, StartLine, EndLine, TargetContent, ReplacementContent) |
| Read a file | read_file(path, offset, limit) |
view_file(AbsolutePath, StartLine, EndLine, ContentOffset) |
| List a directory | list_files(path) |
list_dir(DirectoryPath) |
| Search | shell only | find_by_name(SearchDirectory, Pattern, MaxDepth), grep_search(SearchPath, Query, IsRegex) |
Two things there matter to a guard. The argument naming the file moved from path to TargetFile or AbsolutePath, so a guard reading the old field reads nothing. And edits moved to line ranges, so the arguments no longer describe the resulting file.
The deprecations page lists Oct 5, 2026 as the 05-2026 shutdown and defines shutdown generically: the model is turned off completely and “the endpoint is no longer available.” It also calls its dates the earliest possible ones. Google’s gemini-skills migration table, a reference file for coding agents merged in PR #83 the same day, says something else: “Requests redirect to antigravity-preview-09-2026 after October 5, 2026.” One Google source predicts a hard error. The other predicts a silent swap to an agent whose tools have different names. A stale pin that errors is loud; a stale pin that redirects keeps running with guards keyed to names that no longer exist.
Screenshot: GitHub, “gemini-skills/skills/gemini-api-dev/references/migration.md at main · google-gemini/gemini-skills” (PR #83 merged Sep 17, 2026), captured Sep 21, 2026.
The changelog’s migration advice is narrower than it reads. A remote-sandbox user who reads only output_text or model_output steps can, it says, “update the agent string and nothing else changes”; the next sentence says anyone running tools locally or parsing function_call steps faces changed built-in tools. Our reading, not Google’s sentence: hooks run inside the remote sandbox and match on the tool’s container name, so a hooks.json written for 05 names sits in that no-change path and has just stopped matching.
The agent hooks page, updated the same day, sets the rules this piece tests. Matchers are RE2 regular expressions evaluated against the tool’s container name. The documented decisions are allow and deny ({"decision": "deny", "reason": "..."}). A command that crashes with a non-zero exit, an HTTP hook that returns non-2xx, a timeout (default 30 seconds) or unrecognised JSON all count as allow. Hooks cover code_execution and the filesystem tools, and they don’t fire for custom function tools or mcp_server tools. The page warns that .*_file misses replace_file_content and list_dir. Its Python and JavaScript samples use the 09 names, but the Java tabs of two samples still key on 05 names: a privacy gate on read_file and an audit logger on read_file|write_file.
Screenshot: Google AI for Developers, “Hooks / Gemini API / Google AI for Developers” (last updated Sep 17, 2026), captured Sep 21, 2026.
One naming trap: “Antigravity” here is the Gemini API’s managed Antigravity Agent. The Antigravity CLI and IDE are a separate product with their own hook model, so keep write-ups about them out of these tables.
Why Antigravity agent hooks, Claude Code matchers and Codex hooks all fail quiet
A tool integration breaks loudly: the call fails and somebody sees an error. A guard sits beside the tool rather than in its path, so it breaks quietly. The harness checks whether any matcher applies, and when none does the call simply runs; nothing logs no guard matched. On Antigravity, a guard that matches and then crashes lets the call through too.
Claude Code has hit the same wall repeatedly this year, per its dated changelog. Version 2.1.191 (Jun 24) fixed comma-separated matchers such as Bash,PowerShell that had silently never fired. Version 2.1.195 (Jun 26) moved hyphenated matchers from substring to exact match, prescribing mcp__brave-search__.* to cover a hyphenated server’s tools. The hooks reference adds that a matcher on a bare server key never fires for plugin-bundled MCP tools, which are named mcp__plugin_<plugin-name>_<server-name>__<tool>. And 2.1.275 (Sep 17) fixed /update-config writing Write(path) permission rules that file-permission checks don’t match, two months after 2.1.210 (Jul 14) began warning about them. Each was a rule that parsed, loaded and matched something other than what its author meant.
Codex documents its coverage more candidly. Its hooks docs say shell, unified exec, apply_patch, MCP tools and other local function tools fire PreToolUse; hosted tools such as WebSearch don’t; write_stdin doesn’t re-run the hook. They call hooks “a useful guardrail, not a complete enforcement boundary,” and say decisions apply to tools called from code mode. Issue #23411, open since May 19 with user reproductions through September and no maintainer reply, reports that outer Code Mode exec doesn’t fire PreToolUse. That’s a user report against the docs, and a test case.
Coverage, in other words, is a measurement you repeat on every version, including the ones you didn’t choose.
The guard contract suite: four asserts, every bump and every day
The suite is a repo with one tool list, fixture set and guard inventory per vendor per agent version, plus a runner that fails the bump on any red line. It runs on every bump and once a day, because a hosted agent and its docs can change on a day you changed nothing.
Two inputs, four asserts, one gate. The red link is Google’s own sample meeting Google’s own rename.
Step 1: Assert coverage against the tools the agent actually has
Build a tool list per vendor per agent version from the vendor’s changelog and from the tool names in recorded function_call steps and hook payloads; when they disagree, the recording wins. Give every tool a class and a hookability flag. On Antigravity, function and mcp_server tools are unhookable by design, and the Antigravity agent page lists google_search and url_context as default tools whose hook coverage the hooks page doesn’t document. The hooks page names its targetable tools as code_execution plus view_file, write_to_file, replace_file_content, list_dir and delete_file, which leaves the two new search tools undocumented too. On Codex, hosted tools are unhooked.
# tool-lists/antigravity-agent/09-2026.yaml (illustrative shape; a human reviews it per version)
- {name: code_execution, class: exec}
- {name: write_to_file, class: write, key: TargetFile}
- {name: replace_file_content, class: write, key: TargetFile}
- {name: delete_file, class: delete}
- {name: view_file, class: secret-read, key: AbsolutePath}
- {name: grep_search, class: secret-read, key: SearchPath, hookable: undocumented}
- {name: list_dir, class: read, key: DirectoryPath}
- {name: find_by_name, class: read, key: SearchDirectory, hookable: undocumented}
- {name: google_search, class: network, hookable: undocumented}
- {name: url_context, class: network, hookable: undocumented}
- {name: each-function-tool, class: by-tool, hookable: false} # hooks skip function and mcp_server tools
Next, the guard inventory: every hook, its event, its matcher, the field it keys on, and the tools its author intends it to cover. Then the assert, with four rules:
- Every hookable write, exec, delete and secret-read tool hits at least one pre-tool guard.
- Every matcher matches at least one current tool name. A dead matcher fails the run even when coverage is otherwise complete, because it is a guard somebody believes in.
- Every matcher compiles in the vendor’s dialect. Python’s
rerejects a lone*, as RE2 does, yet Google’s hooks page lists"*"and""beside".*"as catch-alls, and its auto-format sample uses"*". Normalize those two documented spellings to.*, fail anything else that doesn’t compile, and replay one fixture to confirm the runtime agrees. - Every matcher’s real match set equals its declared intent. Before 2.1.195, Claude Code treated a hyphenated name as an unanchored regex, so a matcher for
code-revieweralso fired forsenior-code-reviewer.
# coverage_assert.py (illustrative): run per vendor x version; any printed line fails the bump
import re, sys, yaml
EXACT = re.compile(r"[A-Za-z0-9_\- ,|]+") # Claude Code: only these characters means an exact-name list
CATCH_ALL = {"*", ""} # Antigravity: listed beside ".*" as catch-alls
def pat(g):
return ".*" if g["vendor"] == "antigravity" and g["matcher"] in CATCH_ALL else g["matcher"]
def hits(g, name):
m = pat(g)
if g["vendor"] == "claude-code":
if EXACT.fullmatch(m):
return name in [s.strip() for s in re.split(r"[|,]", m)]
return re.search(m, name) is not None # any other character: unanchored regex
return re.fullmatch(m, name) is not None # Antigravity: RE2, whole-name match assumed
tools, guards = yaml.safe_load(open(sys.argv[1])), yaml.safe_load(open(sys.argv[2]))
for g in guards:
try:
re.compile(pat(g))
except re.error:
print(f"BAD REGEX {g['id']}: {g['matcher']!r}"); continue
got = {t["name"] for t in tools if hits(g, t["name"])}
if not got:
print(f"DEAD MATCHER {g['id']}: {g['matcher']!r}")
elif got != set(g["intent"]):
print(f"DRIFT {g['id']}: matches {sorted(got)}, intent {g['intent']}")
for t in tools:
if t["class"] in {"write", "exec", "delete", "secret-read"} and t.get("hookable", True) is True:
if not any(g["event"] == "pre" and hits(g, t["name"]) for g in guards):
print(f"UNGUARDED {t['name']} ({t['class']})")
The Antigravity branch assumes whole-name matching. That is an inference from the page’s .*_file advice, not a documented rule, and its "" catch-all points the other way, so the fixture replay in step 2 is where you confirm it. Tools flagged undocumented or false skip the unguarded check on purpose; they go to the wall table in step 6.
Run it with the hooks page’s Java samples plus its auto-format example as the inventory and the 09 list as the tools, and the Sep 17 story prints itself (the security gate on code_execution passes, and the catch-all auto-format hook runs only after the fact):
DEAD MATCHER privacy-gate: 'read_file'
DEAD MATCHER audit-log: 'read_file|write_file'
UNGUARDED write_to_file (write)
UNGUARDED replace_file_content (write)
UNGUARDED delete_file (delete)
UNGUARDED view_file (secret-read)
Against a 05 tool list the privacy gate comes back green, which is why nobody looked. The Python tab of the same gate already says view_file: one guard kept in two places drifted in one. A new UNGUARDED line can also mean a guard was removed. If nobody on your team removed it, that is the persistence problem in unauthored hooks, and it gets an incident.
Step 2: Replay wire fixtures, not doc samples, through every guard
Docs lag the wire, and Google’s samples prove it. Record real payloads per agent version, the stdin each hook receives and the function_call steps the agent emits, from a disposable lane working on canary files, never production data. Commit each one beside a sidecar saying what it must prove.
fixtures/antigravity-agent/09-2026/view_file.canary-secret.json # raw payload, as recorded
fixtures/antigravity-agent/09-2026/view_file.canary-secret.meta.yaml # tool: view_file, key: AbsolutePath, expect: deny
fixtures/claude-code/2.1.278/PreToolUse.Edit.canary.json
fixtures/codex/0.155.0/PreToolUse.apply_patch.canary.json
Replay every fixture through every guard that claims its tool, and assert three things: some guard matched, the field it keys on is present and non-empty, and the verdict is the one the sidecar expects.
| Guard keys on | 05-2026 field | 09-2026 field | Assert |
|---|---|---|---|
| Write target | path |
TargetFile |
present, non-empty, resolves inside the workspace |
| Read target | path |
AbsolutePath |
present, non-empty, checked against the secrets list |
| List or search root | path (list_files only) |
DirectoryPath, SearchDirectory, SearchPath |
present, non-empty |
| Shell | command (Claude Code, Codex) |
unchanged | present, non-empty |
The non-empty assert is the one people skip, and it catches the nastiest version of this bug. Fix the matcher to view_file but leave the guard reading path, and the guard gets an empty string, finds no secrets prefix in it, and allows. The matcher is green, the guard ran, the log says allow, and the canary secret was read.
# replay.py (illustrative): adapters are written against the recordings, never against doc samples
for fx in sorted(glob.glob(f"fixtures/{vendor}/{version}/*.json")):
meta = yaml.safe_load(open(fx.replace(".json", ".meta.yaml")))
raw = open(fx).read()
args = ADAPTERS[vendor](json.loads(raw))
assert args.get(meta["key"]), f"{fx}: {meta['key']} missing or empty"
guards = guards_for(vendor, meta["tool"])
assert guards, f"{fx}: no guard matched {meta['tool']}"
for g in guards:
verdict = run_guard(g, stdin=raw) # the real script, fed the real bytes
assert verdict == meta["expect"], f"{fx}: {g['id']} said {verdict}"
Step 3: Replay the failures and record the verdict each harness applies
A guard’s contract includes what happens when the guard breaks, and that part belongs to the harness, not to your script. For each vendor, point a disposable lane at a canary action (a read of a canary secret file works everywhere) and swap in guard variants that deny cleanly, ask, print malformed output, exit non-zero and sleep past the timeout. Record what the harness actually did: did the canary run? Documented behaviour as of Sep 21:
| Replay | Antigravity Agent (API) | Claude Code | Codex |
|---|---|---|---|
| Clean deny | blocks only on {"decision": "deny", ...} |
blocks on exit 2 or permissionDecision: "deny" |
blocks on permissionDecision: "deny", {"decision": "block"} or exit 2 |
| Ask | not a documented decision: record it | forces a permission prompt | parsed but not supported: hook marked failed, call continues |
| Malformed output | allow | not documented for exit 0: record it | not documented: record it |
| Non-zero exit, not 2 | allow | doesn’t block on its own | not documented: record it |
| Timeout | allow (default 30 s) | output discarded, no decision; call continues through the normal permission flow (default 600 s) | not documented (default 600 s): record it |
Read the Antigravity column first: only a clean deny blocks, and every documented failure lets the call run. Then read the non-zero-exit row. The Claude Code contract in the Claude Code + Jev hook playbook sends every failure path to exit 2, which is right for Claude Code. Port that script to Antigravity and exit 2 is just a non-zero exit, which means allow; the fail-closed guard turns fail-open without a line changing. The same holds for converting verifier errors to exit 2, as the approve-once piece teaches: correct on Claude Code, an approval on Antigravity.
On Antigravity the only blocking output is a deny printed by a process that exits cleanly, so wrap every guard to do exactly that on its own errors:
#!/usr/bin/env python3
# ag_guard.py (illustrative): on Antigravity a crash or non-zero exit is an allow, so print the deny yourself
import json, signal, sys
def _deadline(*_):
raise TimeoutError
signal.signal(signal.SIGALRM, _deadline)
signal.alarm(10) # your own deadline, well under the 30 s hook default
try:
reason = check(json.load(sys.stdin)) # None means allow; a string is the deny reason
out = {"decision": "allow"} if reason is None else {"decision": "deny", "reason": reason}
except BaseException as e:
out = {"decision": "deny", "reason": f"guard error: {type(e).__name__}"}
print(json.dumps(out))
sys.exit(0)
The wrapper narrows fail-open without closing it. An interpreter that never starts, a process killed from outside, or the runtime’s own 30-second timeout still means allow. That is why the timeout replay stays in the suite after the wrapper ships, and why step 6 exists.
Step 4: Take write evidence from the disk, not the arguments
replace_file_content edits a line range and write_to_file carries an Overwrite flag. The arguments describe an intent; what landed is on the disk. For every write-class call, record the path, a hash before, a hash after and the real diff, from a pre-tool and a post-tool hook (pre_tool_execution / post_tool_execution on Antigravity, PreToolUse / PostToolUse elsewhere).
{"ts":"2026-09-22T14:03:11Z","lane":"ag-docs-03","agent":"antigravity-preview-09-2026","tool":"replace_file_content","target":"/workspace/src/billing.py","declared_lines":[40,58],"pre_sha256":"9f2c...","post_sha256":"41ab...","changed_lines":[40,61],"evidence":"complete"}
Three asserts run over that log:
- Every write-class call has both hashes. Post hooks fail open too, so a missing pair is a failed check, never a pass.
- The changed lines sit inside the declared range. The illustrative record above changed line 61 on a call that declared 40 to 58. Flag it and read the diff.
- No hash changes without a recorded call. Code run through
code_executioncan write files without calling a file tool, and so can a custom function tool your hooks never see. A sweep of workspace hashes from the runner, outside the agent, is how you notice.
Step 5: Pin 09-2026 now, shadow the suite, then test both Oct 5 outcomes
Across two vendors, five dated changes in three months touched what a guard or permission rule matches, and a sixth is on the calendar.
Every marker is a documented release or shutdown date. What a 05-2026 request gets after Oct 5 is not documented consistently, so it gets a drill.
Work the calendar backwards from Oct 5:
| When | Do | Pass when |
|---|---|---|
| Today, Sep 21 | Search every lane config, runner script and SDK call for antigravity-preview-05-2026; pin antigravity-preview-09-2026 explicitly |
no 05 string anywhere a lane can read it |
| This week | Port matchers and key fields to 09 names; record 09 fixtures from a disposable lane | steps 1 to 4 green on 09 |
| Until Oct 2 (illustrative cut-over) | Run the suite daily in shadow: log every red line, block nothing, give each one an owner | red list empty or owned |
| Oct 2 | Make the suite blocking for Antigravity lanes | a red line stops the bump and pages the owner |
| Oct 5 onward | From one disposable lane with canary tools only, send one request pinned to 05; record error or redirect, with the date | outcome logged |
| Oct 5 onward | Runner refuses to start any lane pinned to 05, whatever the API does | refusal in the start log |
Both outcomes need a handler before you know which one you’ll get. An error is easy: the lane fails and the runner alerts. A redirect shows up in what comes back: function_call steps from a 05-pinned request that name view_file, write_to_file or list_dir are 09 tools you didn’t ask for, and that lane runs only while the 09 suite is green. The runner’s refusal is the control; the API’s behaviour on the day is evidence.
Oct 5 is also a dated deprecation, so it belongs on the ledger that forced model routing continuity keeps for model-ID swaps; point that ledger entry at this suite as its proof. After any bump, the served-behaviour replay in the CLI upgrade canary checks what the lane was actually served. This suite checks that its guards still see it.
Step 6: Put a wall behind every guard the suite can’t prove
Hooks stay guardrails. The suite proves coverage and verdicts at test time; it can’t make a fail-open runtime fail closed or hook a tool the vendor never routes through hooks. Each such path gets a named wall and its own test:
| Path the hook can’t hold | Wall behind it | Test |
|---|---|---|
Antigravity custom function tools |
your application executes them, so the check lives in that code | a canary function call your code refuses |
Antigravity mcp_server tools |
the MCP server’s or gateway’s own authorization | a canary tool call rejected upstream |
| Any Antigravity guard failure | keep secrets out of the sandbox; scope what it can reach | the canary secret isn’t in the sandbox at all |
| Claude Code guard failure or dead matcher | permission deny rules and the sandbox under destructive tools | post-bump canary repo; rules written as Edit(path), not Write(path) |
| Codex hosted tools such as WebSearch | egress policy outside the agent | a canary domain stays blocked with hooks off |
Codex code-mode exec (#23411) |
the same shell deny, enforced by the sandbox rather than the hook | the shell canary sent through code mode is refused |
For the Claude Code row, run the post-bump canary repo from restricted mode as fleet policy in the same gate as this suite; the cross-harness version is the managed-permissions override test. And remember what 2.1.275 fixed: the wall itself had been written as rules nothing matched. A permission rule is one more matcher, so it goes in the coverage assert too.
Where Antigravity agent hooks pass the suite and still guard nothing
The tool list came from the changelog. A tool in function_call steps but not in your list is invisible to every assert. Signal: a recorded tool name missing from the list.
The matcher moved; the field didn’t. The usual half-fix after a rename. Signal: the key-field assert fails, or the canary secret read logs allow from a guard that matched.
The guard matches too much. Claude Code 2.1.275 also fixed SubagentStop hooks with a specific matcher that fired for every stopping subagent whose agent type was empty. Signal: DRIFT lines from step 1. Over-broad guards get disabled by the people they annoy, which ends where a dead one does.
Green on Claude’s contract, open on Google’s runtime. Signal: the non-zero-exit replay reads allow on Antigravity for a guard someone ported from a Claude Code lane. The step 3 wrapper is the fix.
The suite ran once. Signal: the last green run is older than the last agent bump, or older than a day. Hosted agents and their docs change on their own calendar.
Guard coverage is fleet inventory, not a line in one hooks file
A fleet that runs Antigravity Agent, Claude Code and Codex lanes has three matcher dialects, three failure policies and three release trains. Reading one hooks.json tells you whether that file parses, not whether the fleet’s writes are guarded. Coverage is a join between what each lane runs today and what each guard matches, and only the layer that starts lanes, pins versions and holds the kill switch has both halves.
That’s where this suite belongs: in the multi-agent command center layer, beside the runner that refuses to start a lane whose pinned version has a red line. Chatbots suggest; agents act. A guard that stopped being asked is an agent acting with nobody watching.
FAQ
Do Antigravity agent hooks fail open?
Yes, on the Gemini API’s Antigravity Agent. Google’s hooks page says a crashed script, a non-2xx HTTP response, a timeout or unrecognised JSON is treated as allow. Only a deny decision from a working hook blocks the call. Hooks also skip custom function and mcp_server tools, so those need a gate elsewhere.
Will antigravity-preview-05-2026 error or redirect after October 5?
Google’s sources disagree. The deprecations page says a shut-down model’s endpoint is no longer available, which implies an error. Google’s gemini-skills migration table says 05-2026 requests redirect to 09-2026. Pin 09-2026 now, make your runner refuse 05 pins, and test both outcomes from a disposable lane.
Sources
- Gemini API changelog: 09-2026 release and tool table, Sep 17, 2026
- Gemini API deprecations: 05-2026 shutdown, Oct 5, 2026
- Gemini API agent hooks: matchers and failure handling, updated Sep 17, 2026
- Gemini API Antigravity agent: default tools
- gemini-skills migration reference: the redirect row
- gemini-skills PR #83: merged Sep 17, 2026
- Claude Code hooks reference: matcher rules, exit codes, timeouts
- Claude Code changelog: 2.1.191, 2.1.195, 2.1.210, 2.1.275
- Codex hooks docs: coverage table and deny shapes
- openai/codex issue #23411: Code Mode
execandPreToolUse, open
