Skip to content

WEB-4656: add Antigravity 2.0 hooks setup (Subscription mode)#104

Open
thatcatfromspace wants to merge 6 commits into
stagingfrom
dinesh/web-4656-antigravity-setup-scripts
Open

WEB-4656: add Antigravity 2.0 hooks setup (Subscription mode)#104
thatcatfromspace wants to merge 6 commits into
stagingfrom
dinesh/web-4656-antigravity-setup-scripts

Conversation

@thatcatfromspace

@thatcatfromspace thatcatfromspace commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Google Antigravity CLI (agy 1.0.5+) as a supported tool in this repo. Subscription (hooks) mode only. Pairs with the ai-gateway-data PR (#2109) for the control-plane enum.

Scope change during review: Originally targeted both the Antigravity 2.0 desktop IDE and the CLI via ~/.antigravity/settings.json. Empirical testing against agy 1.0.5 proved that path is never read and that several event types we wired (UserPromptSubmit, SessionStart) are silently dropped. The integration has been rewritten end-to-end against the verified agy CLI contract. Full empirical findings: see AGY-EMPIRICAL-FINDINGS.md at the workspace root.

Verified contract (from three empirical passes)

Aspect Verified value
Install path ~/.gemini/config/hooks.json (single user-level location)
Schema wrap {"hooks": {EVENT: [{matcher, hooks:[{type, command}]}]}}
Catch-all matcher "" (empty string)
Supported events PreToolUse, PostToolUse only. PreInvocation/PostInvocation/Stop parse but never exec. UserPromptSubmit/SessionStart silently dropped.
Stdin payload camelCase: {conversationId, stepIdx, toolCall: {name, args}, transcriptPath, workspacePaths, artifactDirectoryPath}. PostToolUse adds error, may have toolCall: null
Stdout (override-allow) Bare `{"decision": "allow"
Tool names agy-native lowercase: run_command, view_file, edit_file, write_to_file, codebase_search, ask_permission (plus browser/notebook/subagent families). Args use PascalCase keys
Verified deny enforcement Yes — {"decision":"deny","reason":"..."} blocks the tool call; reason reaches the model verbatim

What's installed

Per user:

  • ~/.gemini/config/hooks.json — registers PreToolUse + PostToolUse hooks pointing at our scripts. Non-destructive merge (third-party hooks preserved).
  • ~/.unbound/antigravity-hooks/unbound_pre_tool_use.py — policy decision script. POSTs to ${gateway}/hooks/antigravity with unbound_app_label: 'antigravity', maps response to bare native-proto decision.
  • ~/.unbound/antigravity-hooks/unbound_post_tool_use.py — telemetry. Defensively skips POST when toolCall: null (agy fires PostToolUse on every step including non-tool turns).
  • ~/.unbound/antigravity-hooks/_common.py — shared helper for stdin parsing, per-tool command/metadata extraction, urllib-based gateway POST (no curl argv leak), fail-open contract.
  • ~/.unbound/config.json — api_key + gateway_url. Atomic write, O_NOFOLLOW, 0o600.

MDM variant enumerates user homes and runs the per-user installer under privilege drop.

Per-tool field extraction (in _common.py:_extract_command_and_metadata)

agy tool command metadata extras
run_command args.CommandLine cwd: args.Cwd
view_file args.AbsolutePath file_path: args.AbsolutePath
edit_file args.Instruction file_path: args.TargetFile, code_markdown_language
write_to_file "" file_path: args.TargetFile
codebase_search args.Query target_directories: args.TargetDirectories
ask_permission Action: Target target, action, reason
(unknown future tool) JSON-stringified args args: <opaque>

Test plan

  • antigravity/hooks/test_setup.py — 14/14 (install/clear, atomic write, idempotency, third-party preservation, catch-all matcher, unsupported-event regression)
  • antigravity/hooks/scripts/test_hooks.py — 26/26 (agy-shape stdin fixtures × 6 tool names, null-toolCall skip, env-var conversationId fallback, allow/deny/ask, fail-open on every infra error, no-curl regression, gateway URL bake-in)
  • antigravity/hooks/mdm/test_setup.py — 11/11 (MDM creds write, no argv leak, catch-all matcher, unsupported events absent, per-user path migration)
  • claude-code/hooks/test_setup.py regression — 7/7 (no collateral damage)
  • CI green pre-merge
  • End-to-end on a real agy install once merged

Linear

WEB-4656 (sub-issue of WEB-4655).

Follow-up tickets, not blocking this PR

  • WEB-4657 (gateway handler + dashboard card) — must drop the L442 UserPromptSubmit OR-clause from its plan; agy never fires it. APP_NATIVE_FILE_TOOLS['antigravity'] should use agy's real tool names from the table above.
  • Antigravity 2.0 desktop IDE support — deferred. Would require its own path/contract investigation (different surface, different config root: ~/Library/Application Support/Antigravity/User/).

Greptile Summary

This PR adds Antigravity CLI (agy 1.0.5+) hook support in subscription mode, wiring PreToolUse (policy gate, synchronous) and PostToolUse (telemetry, async) into ~/.gemini/config/hooks.json. The implementation is grounded in three empirical passes against the real agy binary and replaces an earlier, incorrect scope that targeted unsupported events and a config path agy never reads.

Confidence Score: 5/5

The change is safe to merge: both hook scripts and installers correctly implement fail-open behavior, O_NOFOLLOW credential writes, and urllib-only authenticated calls.

All previous security concerns are addressed in the current code. The two remaining findings are narrow defensive hardening points that do not affect normal operation or policy enforcement.

antigravity/hooks/scripts/_common.py (_NoRedirectHandler) and antigravity/hooks/setup.py (rewrite_gateway_url_in_file) warrant a second look, but neither blocks merge.

Important Files Changed

Filename Overview
antigravity/hooks/scripts/_common.py Shared runtime helper: stdin parsing, credential loading, per-tool arg extraction, gateway POST via urllib. Clean fail-open contract overall; the _NoRedirectHandler returns None instead of raising, causing an AttributeError that escapes the post_to_gateway except clause (caught only by outer callers).
antigravity/hooks/setup.py User-level installer: OAuth callback, config write (O_NOFOLLOW present), hook-script copy+URL bake, non-destructive hooks.json merge. The rewrite_gateway_url_in_file step does a non-atomic read+write_text that can leave _common.py empty on crash/disk-full.
antigravity/hooks/scripts/pre_tool_use.py PreToolUse hook: reads agy stdin, POSTs to gateway, emits bare native-proto deny/ask on non-allow response. Fail-open on every error path. Correct agy stdout shape (no hookSpecificOutput wrapper).
antigravity/hooks/scripts/post_tool_use.py PostToolUse telemetry hook: skips POST when toolCall is null (non-tool agy steps), delegates to fire_and_forget_telemetry. Minimal, correct, fail-open.
antigravity/hooks/mdm/setup.py MDM installer: privilege-drop via fork, O_NOFOLLOW on all writes, in-memory URL substitution before write, urllib (not curl) for all authenticated requests. Solid security posture. Only PreToolUse and PostToolUse installed.
antigravity/hooks/scripts/test_hooks.py 26 hook-script integration tests using a real local HTTP server; covers all 6 agy tool shapes, null-toolCall skip, env-var conversationId fallback, allow/deny/ask decisions, fail-open on infra errors, and no-curl regression.
antigravity/hooks/test_setup.py Setup integration tests: isolated HOME via reload, covers install/clear/idempotency, third-party hook preservation, catch-all matcher verification, unsupported-event regression lock.
antigravity/hooks/mdm/test_setup.py MDM tests: _write_unbound_config_payload mode/permissions, config-before-hooks ordering, catch-all matcher, no-curl assertion for authenticated calls.

Sequence Diagram

sequenceDiagram
    participant agy as agy CLI
    participant pre as unbound_pre_tool_use.py
    participant post as unbound_post_tool_use.py
    participant common as _common.py
    participant gw as Unbound Gateway

    agy->>pre: stdin JSON (PreToolUse, camelCase)
    pre->>common: read_stdin_event()
    pre->>common: load_credentials()
    pre->>common: build_request_body(event, "PreToolUse")
    pre->>common: post_to_gateway(body, api_key, gateway_url)
    common->>gw: POST /hooks/antigravity (urllib, Authorization header)
    gw-->>common: "{"decision":"deny","reason":"..."}"
    common-->>pre: parsed dict
    pre->>agy: "stdout {"decision":"deny","reason":"..."} + exit 0"
    Note over pre,agy: Empty stdout + exit 0 = silent allow

    agy->>post: stdin JSON (PostToolUse, toolCall may be null)
    post->>common: read_stdin_event()
    alt toolCall is dict
        post->>common: fire_and_forget_telemetry(event, "PostToolUse")
        common->>gw: POST /hooks/antigravity (async, best-effort)
    else toolCall is null
        post->>agy: exit 0 (skip — non-tool step)
    end
Loading

Reviews (5): Last reviewed commit: "WEB-4656: rewrite Antigravity hook contr..." | Re-trigger Greptile

@thatcatfromspace thatcatfromspace requested a review from a team June 3, 2026 17:54
@thatcatfromspace thatcatfromspace changed the base branch from main to staging June 3, 2026 17:58
thatcatfromspace and others added 3 commits June 3, 2026 23:29
Mirror the claude-code/hooks/ layout for Google Antigravity 2.0 (subscription
mode). Adds a user-level installer, an MDM device-wide installer, four hook
scripts (PreToolUse + 3 telemetry events), and integration tests.

Wire format verified against AgusRdz/chop:
- Settings file: ~/.antigravity/settings.json (canonical across OSes via
  os.path.expanduser("~"))
- Stdin payload: snake_case {session_id, cwd, hook_event_name, tool_name,
  tool_input}
- Stdout payload: camelCase {hookSpecificOutput: {hookEventName,
  permissionDecision, ...}} — only emitted to override the default allow
- Tool-name matchers cover both "bash" and "Bash" casings
- PreToolUse POSTs to ${gateway}/hooks/antigravity with the existing
  PretoolRequestBody shape (unbound_app_label: "antigravity")
- Fail-open on every infra error path — never block the agent on our infra

Tested with unittest (matching the existing claude-code/hooks test convention):
24 tests pass — 10 install/merge/clear integration tests + 14 hook-script
subprocess tests using a fake-curl shim and the chop-verified golden payloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flatten the three telemetry hook scripts (post_tool_use, user_prompt_submit,
session_start) from a main()/__main__ wrapper to inline body. Each was
~28 lines of identical boilerplate doing import + call helper + exit;
trimmed to the load-bearing 14 lines while preserving fail-open behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…llowlist

Three CRITICAL fixes from elite-pr-reviewer plus two WARNINGs.

CRITICAL #1: MDM install now writes ~/.unbound/config.json per user.
The hook scripts read this file at runtime via _common.py::load_credentials.
Without it, every PreToolUse silently fail-opens and org policy is never
enforced on managed devices. New _write_unbound_config_payload mirrors
claude-code/hooks/mdm/setup.py's pattern: privilege-dropped fork, tmp +
rename, O_NOFOLLOW + 0o600, dir 0o700. Called BEFORE the settings.json
merge so a settings failure never strands a device with an entry-point
hook pointing at missing creds.

CRITICAL #2: API keys no longer leak via curl argv. Both
notify_setup_complete (user + MDM) and fetch_api_key_from_mdm now use
stdlib urllib.request so the Authorization / X-API-KEY header stays inside
our process. Previously ps auxe / /proc/<pid>/cmdline exposed the secret
to every other user on the device. The hook-script POST in
scripts/_common.py is left as-is per review notes — it runs under the
user's own UID so the leak is bounded by user permissions, not cross-user.

CRITICAL #3: PreToolUse / PostToolUse matchers switched from the
"Bash|bash|Write|Edit|Read|Glob|Grep|Task" allowlist to the catch-all "*".
Antigravity 2.0 ships at minimum with WebFetch, WebSearch, MultiEdit,
NotebookEdit, TodoWrite (none of which were in the allowlist) — every
unfamiliar tool was silently bypassing the policy gate. Server-side
filtering via the gateway's APP_NATIVE_FILE_TOOLS / tools_to_check is the
correct defang point, not the matcher.

WARNING fixes:
- mdm/setup.py settings.json read now uses O_NOFOLLOW, consistent with
  the script-write loop above it.
- scripts/_common.py drops the -L flag from the hook-POST curl. Gateway is
  one hop; following redirects masks misconfig instead of surfacing it.

Tests:
- antigravity/hooks/test_setup.py: matcher tests rewritten to assert
  catch-all "*" (per-event), regression assert no "|" allowlist, plus a
  new TestNotifySetupCompleteNoCurl class that puts a fake curl shim on
  PATH and asserts it's never invoked.
- antigravity/hooks/mdm/test_setup.py: new file. Covers the credentials
  write (api_key present, 0o600 mode, 0o700 dir mode, preserves unrelated
  fields), the full install payload (config + scripts + settings all
  land), the ordering invariant (config written before settings), the
  catch-all matcher shape, and curl-shim assertions for both
  notify_setup_complete and fetch_api_key_from_mdm.

Skipped per brief:
- WARNING #4 (gateway URL string replace): speculative, "don't design for
  hypothetical futures."
- WARNING #5 (--foo=bar form): matches claude-code convention.
- WARNING #8 (operator log on hook failures): out of scope for v1.
- INFO #9-12: cosmetic / matches claude-code / out of scope.

Decisions made:
- Catch-all matcher uses "*", not "" or omitted, to match claude-code's
  established settings.json convention (verified in tree). Both forms work
  per Antigravity docs; "*" is what every other tool's setup ships.
- fetch_api_key_from_mdm was also migrated to urllib though the brief only
  explicitly named notify_setup_complete — same exact leak (bearer token
  on curl argv), same fix, same blast-radius file.
- MDM --clear path does NOT remove ~/.unbound/config.json (matches the
  claude-code MDM clear behaviour; config is shared across tools so we
  don't own removal).

Test commands run (all green):
  cd antigravity/hooks && python3 -m unittest test_setup.py -v        # 13 ok
  cd antigravity/hooks/scripts && python3 -m unittest test_hooks.py -v # 14 ok
  cd claude-code/hooks && python3 -m unittest test_setup.py -v        #  7 ok
  cd antigravity/hooks/mdm && python3 -m unittest test_setup.py -v    # 10 ok

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thatcatfromspace thatcatfromspace force-pushed the dinesh/web-4656-antigravity-setup-scripts branch from e47ec0f to 0310cc2 Compare June 3, 2026 18:00
Comment thread antigravity/hooks/setup.py Outdated
Comment thread antigravity/hooks/mdm/setup.py Outdated
Comment thread antigravity/hooks/setup.py Outdated
Comment thread antigravity/hooks/mdm/setup.py Outdated
Comment thread antigravity/hooks/scripts/_common.py Outdated
Comment thread antigravity/hooks/setup.py Outdated
Comment thread antigravity/hooks/mdm/setup.py Outdated
thatcatfromspace and others added 2 commits June 3, 2026 23:40
- UserPromptSubmit hook gets async: True so prompt submission doesn't
  block on the telemetry curl call (matches PostToolUse and
  SessionStart). Same fix in user-level and MDM setup.
- write_unbound_config (user-level) now uses O_NOFOLLOW on the config
  file open, matching the MDM variant. Defangs symlink redirection of
  ~/.unbound/config.json.
- install_for_user_payload docstring corrected: os.fork uses
  copy-on-write for arguments, not pickle. Pickle is only used for the
  return value over the pipe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
post_to_gateway in antigravity/hooks/scripts/_common.py was shelling out
to `curl -H "Authorization: Bearer <api_key>"`, exposing the bearer
token on curl's argv. argv is world-readable on Linux via
/proc/<pid>/cmdline (default hidepid=0) and on macOS via `ps auxe`,
so any local user could harvest the token while a hook was firing.
This is the same leak class we already fixed for the setup-time HTTP
calls (notify_setup_complete, fetch_api_key_from_mdm); hooks fire on
every PreToolUse/PostToolUse/UserPromptSubmit/SessionStart event, so
the leak is high-frequency.

Fix mirrors the existing setup-time urllib migration: build a
urllib.request.Request with headers in the dict (never on argv), POST
via a custom opener whose HTTPRedirectHandler.redirect_request returns
None so 3xx surfaces as an HTTPError instead of silently following a
Location (the no-`-L` rationale is preserved). Fail-open semantics are
unchanged: any URLError / HTTPException / OSError / socket.timeout /
ValueError / UnicodeDecodeError, any non-2xx status, or any non-JSON
body returns None silently.

Test fixture replacement: the previous _make_fake_curl PATH shim is
obsolete now that the hook never invokes curl. Replaced with a
_FakeGateway BaseHTTPRequestHandler bound on 127.0.0.1:<random> that
records each request's path/method/headers/body for assertions; tests
that previously read `entries[0]["argv"]` now read
`gw.requests[0]["headers"]`. New TestNoCurlAtRuntime puts a fake-curl
shim on PATH and runs all four hook scripts end-to-end against the
local server, asserting the shim was never invoked — locks the
migration in so any regression that re-introduces curl will fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thatcatfromspace

Copy link
Copy Markdown
Contributor Author

Greptile findings addressed:

Finding Severity Commit
UserPromptSubmit hook blocks prompt submission (missing async: True) — user-level P1 cde71b5
Same UserPromptSubmit async issue — MDM variant P1 cde71b5
write_unbound_config user-level open missing O_NOFOLLOW (MDM had it) P2 cde71b5
install_for_user_payload docstring incorrectly claimed args are pickled across fork (they're inherited via copy-on-write; only the return value pickles) P2 cde71b5
API key exposed in curl argv at runtime — hook-script POST shells out to curl -H "Authorization: Bearer <key>", leaking the bearer token to local users via ps auxe / /proc/<pid>/cmdline on every PreToolUse and telemetry call P1 Security 766fbeb

The P1 security fix migrates _common.py::post_to_gateway from curl to stdlib urllib.request (same fix pattern as the earlier setup-time migration). Auth header now lives in-process. Side effects:

  • Subprocess import removed from _common.py.
  • test_hooks.py fake-curl PATH shim replaced with a local _FakeGateway HTTP server fixture (BaseHTTPRequestHandler on 127.0.0.1:0); all 14 prior tests ported.
  • Added TestNoCurlAtRuntime (4 regression tests, one per hook script) that puts a fake-curl shim on PATH and asserts it is never invoked — locks the migration in place.
  • urllib redirect-following blocked via a _NoRedirectHandler subclass so we preserve the no--L intent (gateway is one hop; redirects should surface as failures, not be silently followed).

Test counts: test_setup.py 13/13, scripts/test_hooks.py 19/19 (was 14), mdm/test_setup.py 10/10, claude-code/hooks/test_setup.py regression 7/7.

The previous commits on this branch were built against chop's published
schema, which proved partly wrong against the actual agy CLI. Three
empirical passes (see AGY-EMPIRICAL-FINDINGS.md) locked in the real
contract; this rewrite aligns the integration with it.

What changed and why:

- Install path: ~/.antigravity/settings.json -> ~/.gemini/config/hooks.json.
  Verified empirically — that's the single location agy auto-loads.
  The old chop-derived path is not read at all.

- Events installed: PreToolUse + PostToolUse only. UserPromptSubmit and
  SessionStart are silently dropped by agy 1.0.5's hook parser;
  PreInvocation/PostInvocation/Stop register but never spawn the user
  process (log "executing command" but no fork+exec). Don't install
  hooks we know won't fire — deleted user_prompt_submit.py and
  session_start.py outright.

- Hook script install dir: ~/.unbound/antigravity-hooks/ (our own
  namespace, sibling to ~/.unbound/config.json) so --clear has one
  place to look and agy upgrades can't surprise-delete our scripts.

- Stdin parsing: agy uses camelCase {conversationId, stepIdx, toolCall:
  {name, args}, transcriptPath, workspacePaths} with PascalCase arg keys.
  Rewrote read_stdin_event + build_request_body to match. The script's
  identity (pre_tool_use.py vs post_tool_use.py) determines the hook
  event name — agy's stdin payload omits hook_event_name. Added
  ANTIGRAVITY_CONVERSATION_ID env fallback for conversationId.

- Tool name mapping: agy is lowercase-only (run_command, view_file,
  edit_file, write_to_file, codebase_search, ask_permission). No
  bash/Bash duality. Per-tool extractor in _extract_command_and_metadata
  maps PascalCase args -> gateway's command + metadata. Unknown tools
  fall through to a JSON-stringified args blob so we don't crash on
  browser/notebook/subagent tools we haven't taught yet.

- Stdout: bare native-proto {"decision","reason"} — drop the
  hookSpecificOutput / permissionDecision wrapper. Verified empirically
  with a deny test: agy honors the bare shape and surfaces the reason
  verbatim to the model. Empty stdout + exit 0 stays the canonical allow.

- PostToolUse with toolCall: null: agy fires PostToolUse on every step
  including non-tool turns. We skip the gateway POST entirely in that
  case — no tool identity means no useful telemetry to record.

- Catch-all matcher: "" (empty string), verified to fire on every tool.
  Server-side filtering, not the matcher, defangs.

- Gateway event_name: 'tool_use' (matches claude-code/hooks/unbound.py).
  The pre/post phase goes in metadata.hook_event_name.

Preserved: --clear sentinel pattern, urllib (no curl shell-out) for both
gateway POST and notify_setup_complete, MDM privilege-drop fork,
non-destructive merge, per-user ~/.unbound/config.json write, --backfill
no-op, --debug plumbing, fail-open contract on every infra error path.

Tests rewritten to match: stdin fixtures use the new camelCase shape;
output assertions are bare native-proto; one test per major agy tool
verifies build_request_body extracts the right command + metadata;
PostToolUse-with-null-toolCall must NOT POST; UserPromptSubmit/
SessionStart test classes deleted; path assertions migrated to
~/.gemini/config/hooks.json. claude-code regression untouched.

Test results: 14/14 antigravity/hooks/test_setup.py,
26/26 antigravity/hooks/scripts/test_hooks.py,
11/11 antigravity/hooks/mdm/test_setup.py,
7/7 claude-code/hooks/test_setup.py (regression, untouched).

See AGY-EMPIRICAL-FINDINGS.md for the verification methodology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thatcatfromspace

Copy link
Copy Markdown
Contributor Author

@greptileai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants