# Google Antigravity (agy) Permissions, Hooks & ACP — the Harness Control Model, Explained

How the Antigravity harness controls tool calls — the allow/ask/deny permissions engine in settings.json, the five-event hook system with a native force_ask, the terminal sandbox, headless soft-deny — and exactly how ACP sits on that hook: the registration in ~/.gemini/config/hooks.json, the config file, and what a blocked call looks like.

# The Google Antigravity harness control model, explained

Antigravity is Google's agent-first development ecosystem — the [IDE](https://antigravity.google), the `agy` CLI that [replaced Gemini CLI in May 2026](https://antigravity.google/blog), extensions for VS Code, JetBrains, Zed and Xcode, and an enterprise tier under Gemini Enterprise. As a harness it has the pieces the others have — a permissions engine, a sandbox, a hook system, an escape hatch — but several of them behave differently from every other harness on [the comparison page](/controls), and the differences decide how you control it. The binary is closed source, but the control surface is well documented and largely verifiable from the outside: what follows is read from [the official docs](https://antigravity.google/docs/hooks/) and verified where possible against the shipped `agy` 1.1.21 binary.

This page is the reference in two halves. The first is Antigravity's own controls — what ships natively for controlling tool execution, exactly how each mechanism behaves, and where the model ends. The second is [Antigravity with ACP](#antigravity-with-acp-the-hook-the-config-and-a-blocked-call): the hook registration, the config file, the decision mapping, and what a blocked call actually looks like on the wire. *Just want to install it? [The install guide](/integrations/antigravity) is one command.*

## The permissions engine: three lists, one shape

Rules live in `~/.gemini/antigravity-cli/settings.json` under a `permissions` object with `allow`, `ask`, and `deny` arrays. Every rule is an `action(target)` resource:

```json
{
    "permissions": {
        "allow": ["command(git)", "write_file(src/)", "mcp(linter/*)"],
        "deny":  ["command(rm -rf)", "command(sudo)", "write_file(.git/)"],
        "ask":   ["command(*)", "execute_url(aws.amazon.com)"]
    }
}
```

Seven actions cover the surface: `read_file`, `write_file`, `read_url`, `execute_url` (browser actuation), `command` (prefix or anchored regex — `command(npm run (build|lint|test))`), `mcp` (`mcp(server/tool)`, `mcp(server/*)`, `mcp(*)`), and `unsandboxed` (see the sandbox section). Precedence is strict — **deny > ask > allow** — so a `command(*)` in `ask` forces a prompt past any narrower allow. Two implicit rules to know: allowing `write_file` on a path grants `read_file` there, and denying `read_file` denies `write_file`.

The defaults are sensible: files inside the workspace are auto-allowed, and everything else — commands, MCP tools, URLs, out-of-workspace files — defaults to **ask**. During a prompt you can widen the target's scope for the rest of the turn (a file to its parent directory), with the notable exception of terminal commands, which can't be scope-edited at the prompt.

The rule language in full — all seven actions, how prefix and `regex:` command matching decide what `command(git)` covers, the three presets, and two worked rule sets — is on [the Antigravity permissions reference](/blog/antigravity-permissions-reference); the hook contract on its own, with a minimal hook you can write, is on [the hooks reference](/blog/antigravity-hooks-reference).

This is a genuinely good rule language. It's less expressive than the TOML policy engine Gemini CLI shipped (per-mode rules, five priority tiers, root-owned admin policy files — [the high-water mark of the July survey](/blog/what-survives-yolo-mode)), but it covers files, commands, URLs, browser actuation, and MCP tools in one uniform syntax, and MCP coverage at `server/tool` granularity is better than most harnesses manage.

## Headless: the empty chair, answered correctly

In print mode (`-p`), there are no prompts, and Antigravity resolves the [empty chair](/blog/interactive-vs-autonomous-the-empty-chair-test) the right way: an unobtainable approval is **soft-denied** — the run continues, exits 0, and a stderr notice names the tool and the allow rule you'd need to pre-grant it. A changelog entry mid-2026 fixed headless runs that previously hung or silently auto-approved such calls, which tells you the behavior is deliberate, tested, and recent.

That puts Antigravity in the small club (dsh, Grok Build's `dontAsk`, Gemini CLI's policy engine before it) that answers unanswerable asks with a deny rather than a shrug — and it matters below, because hook-issued asks inherit the same resolution.

## The hook system: five events, and the first native ask

Hooks register in `hooks.json` — workspace-local at `.agents/hooks.json` (behind folder trust) or user-global at `~/.gemini/config/hooks.json`, shared by the CLI, the IDE, and the app. Five events: `PreToolUse`, `PostToolUse`, `PreInvocation`, `PostInvocation`, `Stop`. Tool events take a regex `matcher` on the tool name (`run_command|view_file`, `browser_.*`, or `*` for everything); handlers are shell commands (`type: "command"` is the only kind) with a 30-second default timeout, camelCase JSON on stdin, JSON decision on stdout.

A minimal registration, in the shape the file actually takes:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "run_command",
        "hooks": [
          { "type": "command", "command": "node ~/hooks/check-shell.mjs", "timeout": 30 }
        ]
      }
    ],
    "Stop": [
      { "type": "command", "command": "node ~/hooks/receipt.mjs", "timeout": 10 }
    ]
  }
}
```

Note the asymmetry: tool events are a list of `{ matcher, hooks[] }` groups; `Stop` is a flat list of hook commands with no matcher.

**What arrives on stdin.** A `PreToolUse` payload carries the call as `toolCall: { name, args }`, plus `conversationId`, `workspacePaths[]`, `modelName`, and `stepIdx`. The native tool names are Antigravity's own — `run_command`, `view_file`, `write_to_file`, `replace_file_content`, `multi_replace_file_content`, `grep_search`, `find_by_name`, `list_dir`, `search_web`, `read_url_content`, `invoke_subagent` — and a shell call's command string rides in `args.CommandLine` (verified live; not `command`). `PostToolUse` adds an `error` string, and fires on non-tool steps too, with `toolCall` null.

**What goes back on stdout.** A `PreToolUse` hook answers:

```json
{ "decision": "deny", "reason": "…" }
```

where `decision` is one of **`allow`, `deny`, `ask`, `force_ask`, `deny_unless_prior_grant`** — plus an optional `permissionOverrides` array that can grant `action(target)` resources for the turn. `ask` prompts the human but respects cached "Always Allow" grants; **`force_ask` prompts unconditionally, ignoring them**. No other mainstream harness lets a hook hand a call to the human as a first-class outcome — Claude Code's hooks can `ask`, but Grok Build's gate is allow/deny only, and most others are deny-only. For an external policy layer, `force_ask` is the exact primitive you want: an approval requirement that a locally cached grant can't pre-empt. `PreInvocation`/`PostInvocation` can inject steps into the loop, and `PostInvocation` can force-continue or terminate; `Stop` hooks can re-enter the loop with `decision: "continue"` (with a built-in cap on consecutive continuations, so an always-blocking stop hook can't hold the agent hostage forever — a thoughtful touch).

Four sharp edges, all verified:

1. **A failing hook blocks the call.** Crash, timeout, non-zero exit — the harness reports `pre-tool hook failed` and the tool call does not run. This is **fail-closed**, the opposite of Claude Code's and [Grok Build's fail-open cores](/controls/grok-build), and it cuts both ways: no silent lapse of a broken policy hook (good), but a hook whose upstream dependency is down can brick every tool call in the session (bad, if the hook doesn't carry its own posture). Denials must travel as JSON with exit 0 — there is no exit-code deny channel.
2. **The payload doesn't name its event.** Unlike every Claude-lineage contract, the stdin JSON has no `hookEventName` — a handler serving multiple events must be registered once per event with the event passed as a command-line argument (that's why the ACP registration below runs the same file as `hook.mjs pre_tool_use`, `hook.mjs post_tool_use`, and `hook.mjs stop`).
3. **The hook environment is sanitized.** Only whitelisted variables reach the subshell, so env-var-based configuration of a hook mostly doesn't. Configuration has to live in a file the hook reads itself.
4. **`PostToolUse` carries no tool output.** The payload has the `toolCall` and an `error` string — not what the tool returned. Output-content scanning isn't possible from the hook payload alone (the full exchange is in the transcript file the payload points at).

## The sandbox

`enableTerminalSandbox` (default **false**) confines agent-launched commands with native OS mechanisms — `nsjail` (Linux), `sandbox-exec` (macOS), `AppContainer` (Windows). The approval prompt adapts to sandbox state: enabled, you can approve a single run *without* restrictions; disabled, you can opt a risky command *into* containment. The `unsandboxed(pattern)` permission action exempts matching commands from confinement while still running them through the permission lists. Inside the workspace, a Git repo's `.git` directory is mounted read-only even for otherwise-writable agents.

Same caveat as most of the field: it's off by default. Our [sandbox positioning](/blog/sandboxes-and-control-planes) applies unchanged — containment bounds the blast radius; it doesn't record or decide anything.

## Audit

Locally: per-conversation transcripts (`transcript.jsonl` under the app data directory) — a full exchange record, not a decision ledger. The enterprise tier is where audit becomes a product: central logging of prompts, agent responses, and metadata behind a single admin toggle, with IAM inheritance, VPC Service Controls, workspace/browser/MCP access restrictions, and pooled spend caps on a rolling seven-day meter, [via eligible Gemini Enterprise plans](https://cloud.google.com/blog/products/ai-machine-learning/expanding-google-antigravity-for-enterprise-customers). Google states enterprise session data isn't used to train foundation models.

Grade it honestly: this is the most complete admin plane any harness vendor ships today. Its boundary is its ecosystem — the controls attach at the Gemini Enterprise tier and cover Antigravity. A fleet that also runs Claude Code, Codex, or Cursor gets no shared record, no shared policy, and no shared approval queue from it.

## The escape hatch

`--dangerously-skip-permissions` auto-approves every tool permission request (stream output reports `always-proceed`) — and it is **total**. Verified live on 1.1.21 with an A/B pair of otherwise-identical headless turns: without the flag, a registered `PreToolUse` hook fires before the permission layer; with the flag, the hook layer is **not invoked at all** — no hook execution, no decision, no record. That's the opposite of Claude Code (deny rules, hooks, and the circuit breaker survive `--dangerously-skip-permissions`) and Grok Build (deny rules and `PreToolUse` hooks fire in every mode including always-approve). On Antigravity, the escape hatch doesn't just skip the prompts — it removes the interception surface. If a policy hook is part of your control story, the flag is the line it cannot cross, and unattended fleets should treat its use as the event to alert on.

## Antigravity with ACP: the hook, the config, and a blocked call

Everything above is the harness's own surface. ACP stands on one piece of it — the `PreToolUse` hook — and uses the harness's own vocabulary to answer. This is the complete picture of what's on your machine after install, what crosses the wire, and what each outcome looks like from inside a session.

### What the installer puts where

```sh
curl -sf https://agenticcontrolplane.com/install.sh | bash
```

The installer detects Antigravity by the `agy` binary or an existing `~/.gemini/antigravity-cli` directory, then:

| Path | What lands there |
|---|---|
| `~/.acp/hooks/antigravity/hook.mjs` | The hook — one Node file, zero dependencies, [MIT on GitHub](https://github.com/agentic-control-plane/antigravity-acp-plugin) |
| `~/.acp/hooks/antigravity/acp.json` | The registration fragment below |
| `~/.gemini/config/hooks.json` | The fragment **merged in under its own `acp` key** — existing hooks in the shared file are kept, never overwritten |
| `~/.acp/credentials` | The workspace key (the browser opens once to provision it; same file every ACP harness reads) |
| `~/.acp/config.json` | Optional operational overrides, snake_case keys — see below |
| `~/.acp/lapse.log` | Appended whenever a call proceeded without a policy check, and why |

The registration fragment, verbatim — the same file, invoked three times with the event as its argument, because the payload doesn't name its own event:

```json
{
  "acp": {
    "enabled": true,
    "PreToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.acp/hooks/antigravity/hook.mjs\" pre_tool_use", "timeout": 30 }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "*",
        "hooks": [
          { "type": "command", "command": "node \"$HOME/.acp/hooks/antigravity/hook.mjs\" post_tool_use", "timeout": 30 }
        ]
      }
    ],
    "Stop": [
      { "type": "command", "command": "node \"$HOME/.acp/hooks/antigravity/hook.mjs\" stop", "timeout": 10 }
    ]
  }
}
```

`matcher: "*"` means every tool call — shell, file edits, browser actuation, MCP tools, subagents — across the CLI, the IDE, and the app, since they read the same file. Check it registered from inside Antigravity with `/hooks`.

### What crosses the wire

| Antigravity event | ACP call | What the hook answers |
|---|---|---|
| `PreToolUse` | `POST /govern/tool-use` | `{"decision":"allow"}`, or `deny` / `force_ask` with a reason (below) |
| `PostToolUse` | `POST /govern/tool-output` | `{}` — an audit and completion record; Antigravity's payload carries the call and error status, not the tool's output, so this is not a content scan |
| `Stop` | — | `{"decision":"stop"}`, plus a session receipt on stderr. Never `continue`: a receipt must not be able to keep the agent running |

Before the policy check, the hook maps Antigravity's native tool names onto the canonical vocabulary the policy floors key on — `run_command` → `Bash`, `view_file` → `Read`, `write_to_file` → `Write`, `replace_file_content` and `multi_replace_file_content` → `Edit`, `grep_search` → `Grep`, `find_by_name` and `list_dir` → `Glob`, `search_web` → `WebSearch`, `read_url_content` → `WebFetch`, `invoke_subagent` → `Task` — and lifts `args.CommandLine` into the canonical `command` field. The native name travels alongside as `client_tool_name`, so the audit row shows what Antigravity actually called. Unknown and MCP tool names pass through unchanged.

### The three outcomes, as Antigravity sees them

| ACP decision | Hook stdout | In the session |
|---|---|---|
| **allow** | `{"decision":"allow"}` | The call runs. Nothing printed. |
| **deny** | `{"decision":"deny","reason":"[ACP] Denied by policy: <reason>"}` | The call does not run; the reason is what Antigravity has to show for it. |
| **ask** | `{"decision":"force_ask","reason":"[ACP] Approval required: <reason>"}` | A native Antigravity approval card, which a cached "Always Allow" cannot pre-empt. Headless, Antigravity soft-denies it itself — run continues, exit 0, stderr notice. |

### What a blocked call looks like

Take the case that was verified live on `agy` 1.1.21 (2026-08-26): the agent issues a recursive root delete. Antigravity hands the hook this on stdin (abridged):

```json
{
  "toolCall": { "name": "run_command", "args": { "CommandLine": "rm -rf /" } },
  "conversationId": "…",
  "workspacePaths": ["/Users/you/project"]
}
```

The hook posts it as `tool_name: "Bash"`, `client_tool_name: "run_command"`, `tool_input: { "CommandLine": "rm -rf /", "command": "rm -rf /" }`, and answers on stdout:

```json
{
  "decision": "deny",
  "reason": "[ACP] Denied by policy: hardline floor: recursive delete of the root filesystem — blocked unconditionally; this pattern cannot be allowed by policy or approval"
}
```

Exit code 0, the call never runs, and the audit row records the decision, the reason, both tool names, and the session. The reason text is exactly the gateway's; the `[ACP]` prefix is the hook's.

That particular reason comes from the **hardline floor**: a short list that denies in every workspace, above policy — no tenant setting, tier default, or standing approval can allow it. The list is deliberately narrow, on the bar of "no legitimate agent task ever needs it": recursive delete of the root filesystem, a system directory, or the home directory (quoting and `${HOME}` spellings included); `mkfs`; `dd` or a redirect onto a raw block device; a fork bomb; `kill -1`; `shutdown`/`reboot`/`halt`/`poweroff`, `init 0`/`6`, `systemctl poweroff`. Commands laundered through `sh -c '…'` or `eval '…'` are scanned as commands, not prose. Everything softer is ordinary policy, which your workspace sets.

At the end of the session, the `Stop` hook prints one line to the scrollback:

```
[ACP] Session receipt: 14 tool calls governed · 1 denied · 2 held for approval — review this session: https://cloud.agenticcontrolplane.com/sessions/<conversationId>
```

### Configuration: a file, not the environment

Because Antigravity sanitizes the hook's environment, the hook reads `~/.acp/config.json` (env vars win when they do reach it):

| Key | Env equivalent | Meaning |
|---|---|---|
| `agent_tier` | `ACP_AGENT_TIER` | `interactive` (default) or `background`; `CI=1` in the environment also resolves to `background` |
| `check_timeout_ms` | `ACP_CHECK_TIMEOUT_MS` | Decision budget, default `4000` — far under the registered 30-second hook timeout, so the hook always answers before Antigravity's error path |
| `govern_base` / `console_base` | `ACP_GOVERN_BASE` / `ACP_CONSOLE_BASE` | Gateway and console origins |
| `shadow` | `ACP_SHADOW` | Set to `off` to silence shadow-mode advisory notices |

Unattended fleets should pin `"agent_tier": "background"` here explicitly. The tier changes the failure posture, which is the next thing to know.

### Failure posture, on a fail-closed harness

Antigravity's core would block every call if this hook crashed, timed out, or exited non-zero. So it never does: every outcome — including its own crash handling — travels as JSON with exit 0, and the posture for gateway trouble is the hook's own:

- **Interactive tier:** gateway unreachable → the call proceeds, loudly. stderr gets `[ACP] ⚠ UNGOVERNED: gateway unreachable (<detail>) — <tool> proceeded WITHOUT policy check. Lapse logged to ~/.acp/lapse.log.` and the lapse log gets a durable line. An ACP outage must never brick an attended session.
- **Background tier:** gateway unreachable → the call is denied. Nobody is watching, so the block is the safety net.
- One retry on transport failure; an HTTP error status is the server answering and is never retried.
- No credential on the machine → every call proceeds with one `[ACP] ⚠ UNGOVERNED: no credential` warning per invocation and a lapse line; nothing is blocked before you've connected.

### The limits, stated plainly

- **`--dangerously-skip-permissions` removes this hook along with every other.** Verified by A/B: no invocation, no decision, no record. Use scoped `permissions.allow` rules to unblock headless runs instead, and alert on the flag.
- **`PostToolUse` can't see output**, so the post-call record is a completion record, not a content scan.
- **`--local` isn't wired for Antigravity.** ACP's on-device engine (`~/.acp/policy.json`, `~/.acp/audit.jsonl`, no account) ships for Claude Code, Cursor, and Codex; the installer skips Antigravity under `--local` and says so. This hook needs a workspace.
- **Hooks see tool calls, not tokens — and on Antigravity nothing else can either.** The Gemini endpoint is fixed, with no base-URL override, so Antigravity's model traffic can't be routed through the ACP proxy; [the cost X-ray page](/cost-tracking) lists it as tool-call control only.
- **`force_ask` rendering in the interactive TUI** is verified offline against the documented contract; the live pass exercised headless soft-deny, the floor deny, and the receipt.

## Where the model ends

- **No decision ledger below enterprise.** Transcripts record what happened; nothing records *what was decided and why* — which rule matched, what was asked, who approved. The enterprise audit toggle exists precisely because that record has buyers.
- **Config is workspace-writable.** Global settings and hooks live in the user profile, but workspace `.agents/hooks.json` loads behind folder trust — trust the folder and its hooks are live.
- **PostToolUse can't see output**, so no native post-hoc content control.
- **The escape hatch is total.** `--dangerously-skip-permissions` removes hooks from the call path entirely (verified) — no other mainstream harness we've tested unhooks its own interception layer with one flag.
- **One vendor's fleet.** The controls — including the enterprise plane — cover Antigravity. The moment your fleet is heterogeneous, so is your control surface.

*Fleet running more than Antigravity? [One hook puts ACP's policy, approvals, and audit record on every Antigravity tool call](/integrations/antigravity) — the same policy set that runs your Claude Code, Codex, Cursor, and Grok Build sessions.*

## Frequently asked questions


