Generated from
compatibility/antigravity.json,
the same record leastgrant doctor reads. Last verified 2026-09-03.
Google Antigravity
the real agent ran with LeastGrant installed and enforcement was observed happening.
How it attaches
A named hook in ~/.gemini/config/hooks.json with PreToolUse and PostToolUse groups, matcher *. There is no event name on the wire — the event is a protobuf oneof and both events carry toolCall — so LeastGrant labels its own handlers with --event pre and --event post rather than guessing from the payload.
Install it with leastgrant install antigravity. Configuration is written to
~/.gemini/config/hooks.json (global) or <repo>/{.agents,_agents,.agent,_agent}/hooks.json (workspace), alongside anything
already there — LeastGrant never removes a hook it did not add.
What a verdict does here
The three verdicts do not travel equally well. This is the table that decides whether LeastGrant is a prompt, a veto, or a suggestion on this agent.
| verdict | lands? | what actually happens |
|---|---|---|
| allow | yesprobe | A decision of allow is applied without a prompt. Observed indirectly: every gated call whose decision was not a block proceeded, and the host reported the tool result back. |
| ask | partialprobe | The verdict lands, but whether it reaches a person depends on the host. With the user’s autoExecutionPolicy set to CASCADE_COMMANDS_AUTO_EXECUTION_EAGER, an ask on run_command and on write_to_file executed with no prompt shown. That is the host honouring its own auto-execution setting, and it is exactly why a floored action must not use this verdict. |
| deny | yesprobe | Observed: with a deny rule in place, the command was blocked and the model was told "The command was denied by the pre-tool hook rule: LeastGrant: LeastGrant blocked this: you set a rule to always block this." The reason string reaches the model verbatim. |
Interactive and unattended
This agent does not tell the hook which mode it is in, so LeastGrant cannot distinguish an attended session from an unattended one and treats every session as unattended.
When the hook breaks
The question nobody asks until it matters: if LeastGrant crashes, times out, or cannot start, does the tool call still run?
- On crash — the call is refused. (probe) REFUTES the earlier binary read. A hook exiting 3 does not fail open: the call was blocked and the model was told "The command could not be executed because the pre-tool hook failed: JSON hook \"jsonhook__leastgrant_PreToolUse_0_0\" failed: command failed: exit status 3". The static read of applyPreToolHooks logging and continuing describes iteration over the remaining hooks, not the caller, which aborts.
- On timeout — the call is refused, after 30s by default. Also fails closed. A handler sleeping past its 10s timeout blocked the call, and the UI reported "All tool calls are currently being blocked by this hook."
- Can it be made to fail closed? — yes. Not a setting — it is the default. Crash, timeout, unparseable stdout, JSON without a decision, and an unrecognised decision string all block the call. The one way through is an empty stdout with exit 0, which the runtime treats as "no hook result" and lets the call proceed.
What it can see
A verdict is only worth as much as the set of actions it is asked about. gated means LeastGrant is consulted before the thing happens; seen after means it is told afterwards and can at best withhold the result; not covered means the action happens with LeastGrant never hearing about it.
| tool class | coverage | detail |
|---|---|---|
| shell commands | gatedprobe | run_command reaches PreToolUse with CommandLine, Cwd, IsDaemon, WaitMsBeforeAsync and the tool’s own toolAction/toolSummary. Both gated and floored live. |
| file reads | gatedprobe | view_file and find_by_name both reach PreToolUse before execution. A force_ask on view_file of a credential file blocked it with nothing reaching the model. |
| file writes | gatedprobe | write_to_file reaches PreToolUse with TargetFile, CodeContent, Description and Overwrite. Gated live. |
| deletions | gatedsource | |
| MCP calls | gatedprobe | MCP arrives as a single call_mcp_tool carrying {ServerName, ToolName, Arguments}. The adapter rebuilds the engine’s own mcp__server__tool identity from it, so MCP calls get the same tiering, signature and secret guards as on any other agent. Observed live: before that mapping every MCP call was force_ask because the call was unaccountable; after it, an ordinary call asks and can become familiar, and a credential-bearing argument still floors. |
| subagent spawn | unknownsource | invoke_subagent, define_subagent and manage_subagents exist as tool names in the runtime, but no subagent tool was invoked during the live run — the model answered the request directly instead — so nothing has been observed. Not claimed as gated. |
| network / web | gatedsource | |
| note | undefined? |
What decides this later
The highest-value write on your machine is not any single tool call. It is the file that
settles what the agent may do next time — its hook configuration, its standing
permission grants, the servers it is wired to, the memory a later session is handed as
context. LeastGrant floors an agent write to every path below, so it always reaches a
person, and test/control-files.test.ts reads this same list and fails the
build if any of them stops being floored.
The list is longer than the install path on purpose. Walking it across every agent, rather
than reading it, turned up three paths that were not floored at all — and the one that
mattered most was Antigravity's config.json, which holds that host's own
“Always allow” grants and outlives uninstalling LeastGrant entirely.
| path | kind | what it decides |
|---|---|---|
~/.gemini/config/hooks.json | hook config | the global hook definitions — where LeastGrant installs itself |
<repo>/.agents/hooks.json | hook config | workspace hook definitions. The runtime reads FOUR roots — .agents, _agents, .agent and _agent — and any of them can install a handler that runs on every later tool call |
<repo>/_agents/hooks.json | hook config | the second of the four workspace customization roots the runtime walks |
<repo>/.agent/hooks.json | hook config | the third of the four workspace customization roots the runtime walks |
<repo>/_agent/hooks.json | hook config | the fourth of the four workspace customization roots the runtime walks |
<repo>/plugins/hooks.json | hook config | plugins are a third hook source: GetJSONHooks calls GetAllPlugins and parses plugins/<name>/hooks.json, so installing a plugin installs its hooks |
~/.gemini/config/config.json | grant store | userSettings.globalPermissionGrants.allow — the host’s own standing-approval list. An agent that appends one entry grants itself permanent approval for that tool, invisibly to LeastGrant and surviving our uninstall |
~/.gemini/config/mcp_config.json | MCP wiring | which MCP servers the runtime starts and wires in |
~/.gemini/config/projects | project | per-project resources and settings, including which folders are in scope |
~/.gemini/projects.json | project | the folder-to-project registry that decides which of those applies here |
~/.gemini/settings.json | settings | IDE-level settings shared across the Gemini tools, read at startup before any project is chosen |
~/.gemini/antigravity/builtin | instructions | built-in skills — instructions a later session loads and follows |
~/.gemini/antigravity/brain | instructions | persisted memory handed to later sessions as context |
~/.gemini/antigravity/implicit | instructions | implicit context the runtime accumulates and replays into later sessions |
~/.gemini/antigravity/mcp | MCP wiring | cached MCP tool schemas, one per tool, holding the description of that tool the model is given |
What has actually been run
Four different things, deliberately not collapsed into one badge. Reproducing an agent's invocation is not running the agent; a passing conformance suite says our side is right and nothing about whether the host ever calls us.
- Live agent test — Driven through the real signed-in Antigravity Desktop window over CDP, in a disposable project with a synthetic .env, with a transparent wrapper capturing the wire bytes. Observed: the host invoking jsonhook__leastgrant_PreToolUse_0_0 by name; real PreToolUse and PostToolUse payloads for run_command, view_file, list_dir, find_by_name and write_to_file; deny blocking a command with LeastGrant’s reason shown to the model; force_ask stopping a credential read and raising a human prompt carrying LeastGrant’s reason, in the same session where an ordinary ask had been auto-executed; and the full failure matrix — non-zero exit, timeout, unparseable stdout, decision-less JSON and unknown decision all block, empty stdout allows. 2.11.0 · win32 · 2026-09-03
- Real transport probed — The real invocation was observed rather than reproduced: cmd /c, working directory set to the directory containing hooks.json, payload delivered on stdin as flat protojson camelCase with no BOM, ANTIGRAVITY_CONVERSATION_ID injected into the child environment, handler named jsonhook__leastgrant_<name>_<event>_<group>_<index>. Round trip measured at 92–121 ms. 2.11.0 · win32 · 2026-09-03
- Contract read from the shipped build — Contract re-derived from the shipped 153 MB Go runtime by parsing its pclntab into 166,036 symbols, disassembling the decision sites, and parsing the embedded hooks.proto FileDescriptorProto. Established at instruction level: force_ask skips the permission-cache check() call and zeroes its result before forcing ASK, where ask honours a cached grant; a missing decision becomes deny via the legacy allow_tool bool; a non-zero exit zeroes the stdout slice and the caller fails open; timeout 0 or negative becomes 30. Corrected two facts LeastGrant had wrong: PostToolHookArgs carries tool_call and result as well as step_idx, and the runtime reads hooks from four workspace roots plus plugins. 2.11.0 · win32 · 2026-09-03
- Conformance suite — Driven by test/conformance.test.ts. 2.11.0 · win32 · 2026-09-02
Not exercised on darwin, linux.
What it cannot do
Not a disclaimer. The point of everything above is that this list exists and is specific.
- The hook engine installs only when the server-delivered experiment flag json-hooks-enabled is true. It is per-session, not persisted, not readable and not overridable from the client, so LeastGrant cannot tell an enforcing session from an unenforced one.
- An ordinary ask does not necessarily reach a person. Observed live: with autoExecutionPolicy = CASCADE_COMMANDS_AUTO_EXECUTION_EAGER, an ask on a shell command and on a file write both executed with no prompt. Only force_ask is reliable, which is why LeastGrant floors to it.
- force_ask is silently downgraded to an allow when the host sets auto_interaction_behavior=ALLOW_ALL. ResolveAutoInteraction runs before any prompt is registered and the hook gets no signal. Not exercised live; read from the runtime.
- An empty stdout with exit 0 lets the call through. The runtime trims the output and treats an empty result as "no hook result", so a handler that stands aside silently is an allow. Every other failure mode blocks.
- The runtime reads hooks from four workspace roots — .agents, _agents, .agent and _agent — and from plugins/<name>/hooks.json. Any of them can add a handler; the published docs mention only .agents.
- Two of the six accepted verdicts are undocumented. auto_approve is accepted by the runtime and returns a silent allow with no prompt, appearing in neither the published docs nor the hooks guide embedded in the binary; deny_unless_prior_grant appears on the web page but not in that embedded guide.
- deny and ask do not have the same reach. deny is enforced in the tool-call converter, before the permission manager; ask and force_ask are read only by the permissions package, which a step reaches only if it declares permission targets.
- The runtime supports six hook events — PreToolUse, PostToolUse, PreInvocation, PostInvocation, Stop and SessionStart. Stop carries its own decision field. LeastGrant registers only the two tool events, so the others are unmediated.
- Prompt-mode handlers exist (HookHandlerConfig.prompt, executePromptModeHook) despite the embedded guide saying only command handlers are supported. A prompt handler is a model deciding a permission question.
- An unanswered force_ask blocks its conversation indefinitely. That is the intended behaviour of the verdict and it is also a way to wedge a session.
- Subagent tools are not verified. invoke_subagent, define_subagent and manage_subagents exist in the runtime, but no subagent call was made during the live run, so LeastGrant has not been observed gating one.
- LeastGrant does not model the PreInvocation, PostInvocation, Stop or SessionStart events, and does not use the overwrite field that lets a hook rewrite a tool call’s arguments.
- Verified on Windows only, through the Desktop Hub. The Unix launch form (sh -c) is not present in this build — GOOS is a compile-time constant — and the IDE surface was not exercised.
- Network and web tools are unverified. The model answered a search request from its own knowledge rather than calling a tool, so search_web and read_url_content have never been observed reaching the hook.
- A workspace on a disconnected drive stalls every tool call. A single blocking stat against an absent Windows device costs about 21 seconds and Node cannot bound a synchronous one, so the hook exceeds its timeout and the host blocks the call. Safe direction, unusable agent — and Antigravity itself would be no better off on that drive.
- The shell parser reads a command line with POSIX rules, and Antigravity runs a Windows shell. An unquoted native path — a Windows credential path written with backslashes — has its backslashes eaten as escapes, so no path target is produced and every path-keyed floor goes quiet together. Quoting it, or spelling it with forward slashes, floors correctly. This affects every agent LeastGrant supports on Windows, not just this one.