@estebanforge/pi-antigravity-bridge 1.7.9 → 1.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.10] - 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - **`readOnly` config key (fail-closed provider kill switch).** `AGY_READONLY` env and `/agy readonly on|off` set it. On: every provider turn is refused with a visible error instead of running, because the agy CLI has no review-only enforcement on any path (probed 2026-10-07, [google-antigravity/antigravity-cli#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181): plan mode stages a plan and executes the write in the same turn on both the `-p` and stream-json paths, with `permission_mode: "request-review"` reported and no permission event for the write; with `--disable-slash-commands` plan is a warned no-op). Built for tool-less callers (`pi -p --no-tools --model antigravity/...`) that must never mutate a workspace. While the knob is on, the `AskAntigravity` tool is refused as well; nothing in this package runs read-only, because the CLI cannot enforce it.
10
+ - **`AskAntigravity` mode default now follows the config.** Omitting `mode` used to hardcode `accept-edits`, so a `mode: "plan"` config still left the delegation tool write-capable by default (issue #3 request 3). The default is now plan when the configured mode is plan; an explicit `mode` param still wins.
11
+
12
+ ### Fixed
13
+
14
+ - **Plan-shaped provider turns refuse visibly instead of running write-capable.** `/agy mode plan` on the stream-json engine was silently a no-op (the engine warns plan has no effect with slash expansion disabled), and the `-p` path is no better: probed 2026-10-07, plan stages a plan, asks for approval, and executes the write in the same turn on both paths ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)). Provider turns with `mode: "plan"` or `readOnly` on now fail with an error pointing at host-side enforcement and the knob to lift. The README no longer claims provider plan mode "genuinely executes nothing" or that the one-shot path enforces plan.
15
+ - **`readOnly` refusals cover the `AskAntigravity` tool too, and plan runs never carry the skip flag.** `AskAntigravity` is refused while `readOnly` is on (a `mode` param cannot bypass the kill switch), and the tool's plan runs no longer pass `--dangerously-skip-permissions` even when the reviewer agent stages: the CLI does not gate writes under plan mode ([#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)), so an auto-approved plan run is a write-capable run. Analysis commands in plan runs now fail visibly (headless auto-deny) instead of running approved; `accept-edits` runs keep the flag.
16
+ - **Delegation tool refuses `background: true` (fail-closed).** The param is advertised and always refused with an explanation: a wake from a nested delegation would land in the pi session, and long delegations already have the bridge's own early-ack plus poll flow. No behavior change for existing calls.
17
+
18
+
5
19
  ## [1.7.9] - 2026-10-06
6
20
 
7
21
  ### Changed
package/README.md CHANGED
@@ -135,7 +135,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
135
135
  /agy status summary, or open the full settings picker (TUI)
136
136
  /agy doctor single status surface: bridge state, driver counters, versions, acp state, lifecycle, settings, log dir
137
137
  /agy auth run the antigravity-acp sign-in now (engine acp): opens the Google login in your browser, shows the URL when no browser opens
138
- /agy mode plan review-only: agy plans but writes nothing
138
+ /agy mode plan provider turns are REFUSED (the CLI cannot enforce review-only, #1181)
139
139
  /agy mode accept-edits agy applies edits directly (default)
140
140
  /agy permissions on|off auto-approve / prompt for tool calls (see warning)
141
141
  /agy ask on|off register the AskAntigravity delegation tool (default on; off keeps only the provider and models, even with pi-ask-antigravity installed)
@@ -158,7 +158,13 @@ pi itself has no built-in approval gate. Unlike codex, claude, or agy running in
158
158
 
159
159
  Because agy runs non-interactively under this provider (nothing can answer a `y/n` prompt), this extension passes `--dangerously-skip-permissions` by default on `accept-edits` runs. It is technically necessary there: `accept-edits` auto-approves file edits but not shell commands, so a `run_command` would otherwise hang forever waiting for a prompt nothing can answer (upstream [google-antigravity/antigravity-cli#318](https://github.com/google-antigravity/antigravity-cli/issues/318)). The net effect is that agy executes commands the same way pi already executes your other tools: without per-action review.
160
160
 
161
- Plan mode never receives the flag: the flag auto-approves every permission request, including plan mode's own approval gate, which would silently turn review-only into full write access inside the `--add-dir` grant (probed 2026-09-25: with the flag a plan run wrote files; without it, file and command attempts end the run quickly with a "confirm plan" message). So `/agy mode plan` (and `AskAntigravity` with `mode: "plan"`) genuinely executes nothing. Do not combine `--sandbox` with skip-permissions ([#36](https://github.com/google-antigravity/antigravity-cli/issues/36)).
161
+ Plan mode never receives the flag: the flag auto-approves every permission request, so an auto-approved plan run is a write-capable run (the CLI does not gate writes under plan mode, see next paragraph). Do not combine `--sandbox` with skip-permissions ([#36](https://github.com/google-antigravity/antigravity-cli/issues/36)).
162
+
163
+ **Provider turns cannot run review-only.** Probed 2026-10-07 ([google-antigravity/antigravity-cli#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)): on every tested path (one-shot `-p` and the stream-json I/O format, with and without `--add-dir`), `--mode plan` stages a plan, asks for review and approval, and executes the requested file write in the same turn (`permission_mode: "request-review"` is reported; no permission event appears for the write). With `--disable-slash-commands` the CLI additionally warns that `--mode plan` has no effect. The `readOnly` knob below exists because of this: plan-shaped provider turns refuse visibly instead of pretending. `AskAntigravity` with `mode: "plan"` adds a staged restricted agent whose toolset omits file-editing tools (probed 2026-09-28 on agy 1.2.12; re-verify on 1.3.x), but plan mode itself does not gate writes anywhere. Treat host-side pi tool restrictions as the only hard read-only wall.
164
+
165
+ ### readOnly
166
+
167
+ `"readOnly": true` in the config (or `AGY_READONLY=1`, or `/agy readonly on`) is a fail-closed kill switch: every provider turn AND the `AskAntigravity` tool are refused with a visible error instead of running write-capable under a read-only label. Built for callers like `pi -p --no-tools --model antigravity/...` health checks that must never mutate a workspace: with no review-only enforcement available in the CLI (see Permissions), a visible refusal is the only honest behavior. The knob covers provider turns and the ask tool; the web tools still spawn agy for searches. Default false.
162
168
 
163
169
  For per-action review of agy's mutating native tools (`run_command`, `create_file`, `edit_file`, ...), see the [Approval gate](docs/APPROVAL-GATE.md): with it on, the call must pass a pi-side approval (your permission extension, or the built-in ask/allow/deny fallback) before agy executes it.
164
170
 
@@ -175,7 +181,8 @@ For isolation when running any agent that executes commands without a confirmati
175
181
  | `AGY_ACP_BIN` | Path to the ACP server binary (`agy_acp_server.par`). Defaults to `agy_acp_server.par` on PATH. Wins over `config.acp.bin`. When neither points at a binary, auto-setup installs one. |
176
182
  | `AGY_EXTRA_ARGS` | Extra args appended to every invocation. Whitespace-split. |
177
183
  | `AGY_CONVERSATIONS_DIR` | Override the conversations DB directory. |
178
- | `AGY_MODE` | Override execution mode: `plan` (review-only) or `accept-edits` (default). Wins over the config file. |
184
+ | `AGY_MODE` | Override execution mode: `plan` (provider turns are refused, see Permissions) or `accept-edits` (default). Wins over the config file. |
185
+ | `AGY_READONLY` | `1`/`true`/`on` (or any set value except `0`/`false`/`off`/empty) turns every provider turn and the `AskAntigravity` tool into a visible refusal (fail-closed: the CLI has no review-only enforcement on any path, [#1181](https://github.com/google-antigravity/antigravity-cli/issues/1181)). Wins over the config file. |
179
186
  | `AGY_SKIP_PERMISSIONS` | `1`/`true` (default) to pass `--dangerously-skip-permissions` so commands don't hang on an unanswerable prompt in `-p` mode. `0`/`false` to prompt (hangs any `run_command` non-interactively). Wins over the config file. |
180
187
  | `AGY_USAGE_ESTIMATE` | ACP token display: `estimate` (default; live client-side per-delta estimates), `direct` (pass through the server's own per-delta counts when it sends them), `off` (no usage display). Anything else falls back to `estimate`. Wins over the config file. |
181
188
  | `AGY_DEFAULT_MODEL` | Default model alias for the `AskAntigravity` tool (`flash`/`pro`/`gemini`, or a tier/version qualifier). Wins over the config file. |
@@ -219,6 +226,10 @@ pi-antigravity-bridge practical risk is low, near zero. But not zero: the "in co
219
226
 
220
227
  This is engineering analysis, not legal advice. Use against your own Antigravity account at your own risk; I am not responsible for any consequence to your account.
221
228
 
229
+ ## Delegation tool: background is refused
230
+
231
+ The `AskAntigravity` delegation tool rejects `background: true` by design: a background wake would land in the pi session even when the caller is a nested delegation through the bridge catalog, and long delegations already have the bridge's own async flow (early-ack plus `bridge_poll_result`). Call without `background`; for unattended peer runs launch the CLI directly in a terminal.
232
+
222
233
  ## License
223
234
 
224
235
  MIT.
@@ -1053,6 +1053,7 @@ interface AgyCommandCtx {
1053
1053
 
1054
1054
  interface PendingConfig {
1055
1055
  mode?: AgyMode;
1056
+ readOnly?: boolean;
1056
1057
  skipPermissions?: boolean;
1057
1058
  defaultModel?: string;
1058
1059
  defaultThinking?: ThinkingTier;
@@ -1074,6 +1075,10 @@ function settingsRows(ctx: AgyCommandCtx): string[] {
1074
1075
  const row = (label: string, value: string) => ` ${label.padEnd(24)} ${value}`;
1075
1076
  return [
1076
1077
  row("mode:", config.mode),
1078
+ row(
1079
+ "readOnly:",
1080
+ config.readOnly ? "on (provider turns are refused)" : "off",
1081
+ ),
1077
1082
  row("permissions:", perm),
1078
1083
  row("AskAntigravity tool:", config.askTool ? "on" : "off"),
1079
1084
  row("Web tools:", config.webTools ? "on" : "off"),
@@ -1096,7 +1101,7 @@ function statusText(ctx: AgyCommandCtx): string {
1096
1101
  ` sessions: ${ctx.store.size} bound`,
1097
1102
  ` config: ${CONFIG_PATH}`,
1098
1103
  "",
1099
- "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy agent <name|off>, /agy subagents, /agy quota, /agy artifacts [open <n|name>], /agy tasks [tail <id>], /agy bridge all|mcp|none, /agy tools [hide|show <name>|reset], /agy web on|off, /agy digest on|off, /agy system-prompt on|off, /agy timeout <1-1440|off>, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear, /agy doctor",
1104
+ "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy readonly on|off, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy agent <name|off>, /agy subagents, /agy quota, /agy artifacts [open <n|name>], /agy tasks [tail <id>], /agy bridge all|mcp|none, /agy tools [hide|show <name>|reset], /agy web on|off, /agy digest on|off, /agy system-prompt on|off, /agy timeout <1-1440|off>, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear, /agy doctor",
1100
1105
  ].join("\n");
1101
1106
  }
1102
1107
 
@@ -1104,7 +1109,7 @@ function statusText(ctx: AgyCommandCtx): string {
1104
1109
  function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1105
1110
  pi.registerCommand("agy", {
1106
1111
  description:
1107
- "Antigravity provider: doctor (health + settings), settings picker, clear sessions. Usage: /agy [doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|agent <name|off>|subagents|quota|artifacts [open <n|name>]|tasks [tail <id>]|bridge all|mcp|none|tools [hide|show <name>|reset]|web on|off|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
1112
+ "Antigravity provider: doctor (health + settings), settings picker, clear sessions. Usage: /agy [doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|readonly on|off|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|agent <name|off>|subagents|quota|artifacts [open <n|name>]|tasks [tail <id>]|bridge all|mcp|none|tools [hide|show <name>|reset]|web on|off|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
1108
1113
  handler: async (args, cmdCtx: ExtensionCommandContext) => {
1109
1114
  const ui = cmdCtx.ui;
1110
1115
  if (ui) activeUi = ui;
@@ -1387,13 +1392,52 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1387
1392
  if (sub === "mode") {
1388
1393
  if (val === "plan" || val === "accept-edits") {
1389
1394
  if (val === "plan" && ctx.engine === "acp") {
1390
- ui?.notify("the ACP engine has no plan mode (RC01). /agy engine stream-json first, or /agy mode accept-edits.", "warning");
1395
+ ui?.notify("plan cannot be enforced on provider turns on either engine: the ACP engine has no plan mode (RC01), and the CLI does not gate writes under plan (google-antigravity/antigravity-cli#1181). /agy mode accept-edits.", "warning");
1391
1396
  return;
1392
1397
  }
1393
1398
  const next = saveConfig({ mode: val as AgyMode });
1394
- ui?.notify(`mode set to ${next.mode}`, "info");
1399
+ // readOnly fail-closes provider turns regardless of this knob;
1400
+ // never let the confirmation imply turns will run. loadConfig, not
1401
+ // next: saveConfig merges the raw file, so an env-only readOnly
1402
+ // would otherwise read as off here.
1403
+ const effectiveReadonly = loadConfig().readOnly;
1404
+ if (effectiveReadonly) {
1405
+ ui?.notify(`mode set to ${next.mode}, but readOnly is on: provider turns are refused. /agy readonly off to lift.`, "info");
1406
+ } else if (val === "plan") {
1407
+ ui?.notify(`mode set to plan: provider turns are REFUSED (the CLI does not gate writes under plan, google-antigravity/antigravity-cli#1181). Only the AskAntigravity tool uses this mode.`, "info");
1408
+ } else {
1409
+ ui?.notify(`mode set to ${next.mode}`, "info");
1410
+ }
1411
+ } else {
1412
+ const cfg = loadConfig();
1413
+ ui?.notify(
1414
+ `current mode: ${cfg.mode}${cfg.readOnly ? " (readOnly on: provider turns are refused)" : ""}\nusage: /agy mode plan|accept-edits`,
1415
+ "info",
1416
+ );
1417
+ }
1418
+ return;
1419
+ }
1420
+ if (sub === "readonly") {
1421
+ if (val === "on" || val === "off") {
1422
+ const next = saveConfig({ readOnly: val === "on" });
1423
+ ui?.notify(
1424
+ next.readOnly
1425
+ ? "readOnly is on: provider turns and AskAntigravity are REFUSED (the agy CLI has no review-only enforcement on any path; probed 2026-10-07). Enforce read-only in the host, then /agy readonly off."
1426
+ : "readOnly off: provider turns follow /agy mode again.",
1427
+ "info",
1428
+ );
1429
+ // Compare effective vs saved: the env can force either direction
1430
+ // (AGY_READONLY=1 keeps refusals on after /agy readonly off;
1431
+ // AGY_READONLY=0 silently keeps readOnly off after /agy readonly on).
1432
+ if (loadConfig().readOnly !== next.readOnly) {
1433
+ ui?.notify(`AGY_READONLY is set in this environment and overrides the config: readOnly stays ${loadConfig().readOnly ? "on (refusing)" : "off"} until you unset it.`, "warning");
1434
+ }
1395
1435
  } else {
1396
- ui?.notify(`current mode: ${loadConfig().mode}\nusage: /agy mode plan|accept-edits`, "info");
1436
+ const cfg = loadConfig();
1437
+ ui?.notify(
1438
+ `readOnly is ${cfg.readOnly ? "on (provider turns and AskAntigravity are refused)" : "off"}\nusage: /agy readonly on|off. Fail-closed: the CLI has no review-only enforcement on any path (probed 2026-10-07), so on = refuse.`,
1439
+ "info",
1440
+ );
1397
1441
  }
1398
1442
  return;
1399
1443
  }
@@ -1755,10 +1799,18 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1755
1799
  id: "mode",
1756
1800
  label: "Execution mode",
1757
1801
  description:
1758
- "accept-edits: agy applies edits directly. plan: review-only, no writes. Takes effect next turn.",
1802
+ "accept-edits: agy applies edits directly. plan: provider turns are REFUSED (the CLI cannot enforce review-only, upstream #1181); AskAntigravity plan runs add a restricted agent as a damper. Takes effect next turn.",
1759
1803
  currentValue: config.mode,
1760
1804
  values: ["accept-edits", "plan"],
1761
1805
  },
1806
+ {
1807
+ id: "readonly",
1808
+ label: "Read-only provider turns",
1809
+ description:
1810
+ "on: provider turns are REFUSED (fail-closed; the CLI has no review-only enforcement on any path). Enforce read-only in the host instead.",
1811
+ currentValue: config.readOnly ? "on" : "off",
1812
+ values: ["off", "on"],
1813
+ },
1762
1814
  {
1763
1815
  id: "permissions",
1764
1816
  label: "Permissions",
@@ -1845,6 +1897,8 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1845
1897
  (id, newValue) => {
1846
1898
  if (id === "mode") {
1847
1899
  pending.mode = newValue as AgyMode;
1900
+ } else if (id === "readonly") {
1901
+ pending.readOnly = newValue === "on";
1848
1902
  } else if (id === "permissions") {
1849
1903
  pending.skipPermissions = newValue === "auto-approved";
1850
1904
  } else if (id === "model") {
@@ -1882,12 +1936,13 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1882
1936
  if (Object.keys(pending).length === 0) return;
1883
1937
 
1884
1938
  // The picker cannot switch engines (command-only: /agy engine), but the
1885
- // mode row can still produce plan while the latched engine is acp. ACP
1886
- // has no review-only mode (RC01); refuse the combination.
1939
+ // mode row can still produce plan while the latched engine is acp. Plan
1940
+ // cannot be enforced on provider turns on either engine (#1181 + RC01);
1941
+ // refuse the combination.
1887
1942
  const nextMode = pending.mode ?? config.mode;
1888
1943
  if (nextMode === "plan" && ctx.engine === "acp") {
1889
1944
  ui.notify(
1890
- "plan + acp is not supported (RC01): the ACP engine has no review-only mode. /agy engine stream-json first, or /agy mode accept-edits.",
1945
+ "plan cannot be enforced on provider turns on either engine (google-antigravity/antigravity-cli#1181; ACP additionally has no plan mode, RC01). /agy mode accept-edits.",
1891
1946
  "warning",
1892
1947
  );
1893
1948
  return;
@@ -1897,6 +1952,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
1897
1952
  const next = saveConfig(pending);
1898
1953
  const changed = [
1899
1954
  pending.mode ? `mode=${next.mode}` : null,
1955
+ pending.readOnly !== undefined ? `readOnly=${next.readOnly ? "on" : "off"}` : null,
1900
1956
  pending.skipPermissions !== undefined
1901
1957
  ? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
1902
1958
  : null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.9",
3
+ "version": "1.7.10",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/ask-tool.ts CHANGED
@@ -83,8 +83,8 @@ TWO MODES (you choose):
83
83
  - **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
84
84
 
85
85
  EXECUTION MODES (param: mode):
86
- - **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs execute in a temporary restricted agent whose toolset has NO file-editing tools (hard block); the reviewer may still execute arbitrary shell commands, kept read-only by the prompt guard alone. Inline the material to review - a plan run cannot fetch it.
87
- - **accept-edits** (default): agy applies edits directly inside the workspace.
86
+ - **plan**: agy proposes a plan for review-shaped tasks. NOT a security boundary: the CLI does not gate writes under plan mode (upstream google-antigravity/antigravity-cli#1181, probed 2026-10-07: the write can execute in the same turn). The run additionally stages a temporary restricted agent whose toolset has no file-editing tools, as a damper, and the skip-permissions flag is never passed. Inline the material to review - a plan run cannot fetch it.
87
+ - **accept-edits**: agy applies edits directly inside the workspace. Default unless the configured mode is plan.
88
88
 
89
89
  COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
90
90
 
@@ -328,15 +328,17 @@ function extraArgs(): string[] {
328
328
  // --- Prompt assembly -------------------------------------------------------
329
329
 
330
330
  /** Guard for plan runs backed by the restricted reviewer agent. Difference
331
- * to PLAN_HEADLESS_GUARD: commands are the analysis capability here (the
332
- * agent's commandExecutionPolicy auto-approves them headless, probed
333
- * 2026-09-28), so the guard forbids FILE MUTATION - by tool or by shell -
334
- * and keeps the answer-from-material discipline. */
331
+ * to PLAN_HEADLESS_GUARD: read commands are the intended analysis path
332
+ * here, so the guard forbids FILE MUTATION - by tool or by shell - and
333
+ * keeps the answer-from-material discipline. The agent is a damper, not
334
+ * enforcement (upstream #1181); a headless command attempt can still be
335
+ * auto-denied, which ends the run, so the guard steers toward view_file
336
+ * and staged material first. */
335
337
  export const AGENT_REVIEW_GUARD = [
336
338
  "",
337
339
  "--- Review constraints ---",
338
340
  "- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
339
- "- Read-only commands (git log, git diff, git show, rg, ls, cat, test runners) are allowed for analysis.",
341
+ "- Prefer the staged material and view_file for reading. If you run a read-only command (git log, git diff, rg, ls) and it comes back denied, the run ends without an answer: state what you could not check instead of retrying commands.",
340
342
  "- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
341
343
  ].join("\n");
342
344
 
@@ -365,11 +367,12 @@ export function buildFinalPrompt(
365
367
  mode: AgyMode,
366
368
  digest: boolean,
367
369
  /** True when the restricted reviewer agent is staged: its empty edit
368
- * toolset is the hard block, so the prompt may ALLOW read-only commands. */
369
- agentEnforced = false,
370
+ * toolset is a damper (the CLI does not enforce review-only, upstream
371
+ * #1181), so the prompt may ALLOW read-only commands. */
372
+ agentDamper = false,
370
373
  ): string {
371
374
  let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
372
- if (mode === "plan") out += agentEnforced ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
375
+ if (mode === "plan") out += agentDamper ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
373
376
  return out;
374
377
  }
375
378
 
@@ -384,12 +387,14 @@ export function askAgentsRoot(): string {
384
387
  const ASK_AGENT_PREFIX = "pi-bridge-ask-";
385
388
  const ASK_AGENT_TOOLS = ["view_file", "run_command"];
386
389
 
387
- /** agent.md for the plan-mode reviewer. The tools list is the ENFORCEMENT:
388
- * with no file-editing tool present the model cannot emit an edit call at
389
- * all (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
390
- * edits, with and without the skip flag). commandExecutionPolicy auto is
391
- * what lets read commands run headless with no user allow rules. Never add
392
- * a write-capable tool here. */
390
+ /** agent.md for the plan-mode reviewer. The tools list is a damper, not a
391
+ * guarantee: with no file-editing tool present the model has no edit tool in
392
+ * its menu (probed 2026-09-28 on agy 1.2.12: the toolset reports "none" for
393
+ * edits; re-verify on 1.3.x), but the CLI itself does not enforce
394
+ * review-only under plan mode (upstream google-antigravity/antigravity-cli
395
+ * #1181, probed 2026-10-07: the write can execute in the same turn).
396
+ * commandExecutionPolicy auto is what lets read commands run headless with
397
+ * no user allow rules. Never add a write-capable tool here. */
393
398
  export function reviewerAgentMd(name: string): string {
394
399
  return [
395
400
  "---",
@@ -484,8 +489,7 @@ export async function registerAskAntigravityTool(
484
489
  mode: Type.Optional(
485
490
  Type.Union([Type.Literal("plan"), Type.Literal("accept-edits")], {
486
491
  description:
487
- "agy execution mode. 'plan' = review-only. 'accept-edits' = agy applies edits (default).",
488
- default: "accept-edits",
492
+ "agy execution mode. 'plan' = plan-shaped run: the CLI does not enforce review-only (see the tool description); the staged reviewer agent's toolset omits file-editing tools as a damper. 'accept-edits' = agy applies edits. Omit to follow the configured default (plan when config mode is plan, else accept-edits).",
489
493
  }),
490
494
  ),
491
495
  digest: Type.Optional(
@@ -509,6 +513,12 @@ export async function registerAskAntigravityTool(
509
513
  "When true, export the current pi conversation (resolved, as markdown) to a temp file inside the workspace and tell agy to read it first. Default false (isolated one-shot). Opt in only when the user explicitly wants agy to see the full conversation; it costs agy tokens to read.",
510
514
  }),
511
515
  ),
516
+ background: Type.Optional(
517
+ Type.Boolean({
518
+ description:
519
+ "NOT supported by this tool. The bridge runs agy through its own delegated flow and the answer must return synchronously to the caller. For background runs use the standalone @estebanforge/pi-ask-antigravity extension. Calls with background=true are refused.",
520
+ }),
521
+ ),
512
522
  }),
513
523
  renderCall(args, theme, _context) {
514
524
  // Show RESOLVED model/thinking/mode (config defaults applied) so the
@@ -524,7 +534,10 @@ export async function registerAskAntigravityTool(
524
534
  thinkingArg ? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER) : undefined,
525
535
  ) ?? { model: requestedModel };
526
536
  const thinking: ThinkingTier = resolved.effort ?? cfg.defaultThinking;
527
- const mode: AgyMode = (args.mode as AgyMode | undefined) ?? "accept-edits";
537
+ // Same default rule as execute: plan when the configured mode is
538
+ // plan, so the row always names what will actually run.
539
+ const mode: AgyMode =
540
+ (args.mode as AgyMode | undefined) ?? (cfg.mode === "plan" ? "plan" : "accept-edits");
528
541
  const useDigest = typeof args.digest === "boolean" ? args.digest : mode === "plan";
529
542
  const isContinue =
530
543
  typeof args.conversationId === "string" && CONV_ID_RE.test(args.conversationId);
@@ -598,6 +611,37 @@ export async function registerAskAntigravityTool(
598
611
  }
599
612
 
600
613
  const config = loadConfig();
614
+ // readOnly refusal (fail-closed): the kill switch covers this tool too.
615
+ // It must not be bypassable with an explicit mode param: the CLI has no
616
+ // review-only enforcement (upstream google-antigravity/antigravity-cli
617
+ // #1181), so a plan-labeled run here is not read-only.
618
+ if (config.readOnly) {
619
+ return {
620
+ content: [
621
+ {
622
+ type: "text",
623
+ text: 'readOnly is on: AskAntigravity is refused (fail-closed; the agy CLI has no review-only enforcement, google-antigravity/antigravity-cli#1181). Run /agy readonly off to use this tool.',
624
+ },
625
+ ],
626
+ details: emptyDetails(),
627
+ };
628
+ }
629
+ // Background refusal (fail-closed): a wake from this tool would land in
630
+ // the pi session even when the CALLER is agy (nested delegation via the
631
+ // bridge catalog), and the bridge already owns an async delegation flow
632
+ // (early-ack + bridge_poll_result). The standalone pi-ask-antigravity
633
+ // extension is the background-capable tool.
634
+ if (params.background) {
635
+ return {
636
+ content: [
637
+ {
638
+ type: "text",
639
+ text: 'background is not supported by the bridge\'s AskAntigravity: the answer must return synchronously to the caller. Long delegations are already covered by the bridge\'s own async flow (early-ack + bridge_poll_result); for an unattended peer run, launch agy directly in a terminal instead. Call again without background.',
640
+ },
641
+ ],
642
+ details: emptyDetails(),
643
+ };
644
+ }
601
645
  const requestedModel = (params.model as string | undefined) ?? config.defaultModel;
602
646
  if (typeof params.model === "string" && params.model.trim().startsWith("-")) {
603
647
  return {
@@ -658,10 +702,13 @@ export async function registerAskAntigravityTool(
658
702
  typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
659
703
  const snapshot = isContinuation ? null : snapshotConversations();
660
704
 
661
- const mode: AgyMode = (params.mode as AgyMode | undefined) ?? "accept-edits";
705
+ const mode: AgyMode =
706
+ (params.mode as AgyMode | undefined) ?? (config.mode === "plan" ? "plan" : "accept-edits");
662
707
  // Plan runs stage a restricted reviewer agent: its tools list has NO
663
- // file-editing tool, which is the hard edit block (the prompt guard
664
- // alone was observed failing once - a sub-agent still edited files).
708
+ // file-editing tool, a toolset-level damper on edits (the prompt
709
+ // guard alone was observed failing once - a sub-agent still edited
710
+ // files; the CLI itself does not enforce review-only, upstream
711
+ // #1181).
665
712
  // Staging failure degrades to the legacy fallback: no agent, no skip
666
713
  // flag, the stricter command-forbidding guard.
667
714
  let reviewerAgent: { name: string; dir: string } | null = null;
@@ -711,12 +758,14 @@ export async function registerAskAntigravityTool(
711
758
  "info",
712
759
  );
713
760
  const extraRaw = extraArgs();
714
- // Fail-closed on the fallback path: AGY_EXTRA_ARGS lands before the
715
- // mode flags, so an env-injected skip flag would re-arm what the
716
- // plan-mode rule strips. Only the enforced agent makes the flag safe.
761
+ // Fail-closed on every plan run: AGY_EXTRA_ARGS lands before the mode
762
+ // flags, so an env-injected skip flag would re-arm exactly what the
763
+ // never-flag-on-plan rule withholds (upstream #1181: an auto-approved
764
+ // plan run is write-capable). The reviewer agent is a damper, not a
765
+ // license for the flag.
717
766
  const extra =
718
- mode === "plan" && !reviewerAgent
719
- ? extraRaw.filter((a) => a !== "--dangerously-skip-permissions")
767
+ mode === "plan"
768
+ ? extraRaw.filter((a) => !a.startsWith("--dangerously-skip-permissions"))
720
769
  : extraRaw;
721
770
  if (extra.length) args.push(...extra);
722
771
  if (resolved.model) args.push("--model", resolved.model);
@@ -725,16 +774,16 @@ export async function registerAskAntigravityTool(
725
774
  if (reviewerAgent) args.push("--agent", reviewerAgent.name);
726
775
  // Honor the shared permissions setting (same knob as the provider).
727
776
  // accept-edits: the flag keeps commands from hanging on an
728
- // unanswerable prompt in -p mode. Plan runs WITH the reviewer agent:
729
- // the flag is safe again because the agent's toolset has no
730
- // file-editing tool - the 2026-09-25 write incident rode edit tools
731
- // that existed then; probed 2026-09-28 the restricted toolset reports
732
- // "none" for edits even with the flag, and plan discipline turned a
733
- // create-a-file request into a proposed plan instead of a write. The
734
- // flag is what lets analysis commands (builds, test runners) run for
735
- // users without allow rules. Plan fallback (staging failed): strict
736
- // legacy behavior, no flag - the prompt guard is all that is left.
737
- if (config.skipPermissions !== false && (mode !== "plan" || reviewerAgent !== null)) {
777
+ // unanswerable prompt in -p mode. Plan runs NEVER get the flag: it
778
+ // auto-approves every permission request, and the CLI does not gate
779
+ // writes under plan mode (upstream
780
+ // google-antigravity/antigravity-cli#1181, probed 2026-10-07), so an
781
+ // auto-approved plan run is a write-capable run. The reviewer agent's
782
+ // toolset is a damper, not a guarantee; the 2026-09-28 "safe with the
783
+ // restricted toolset" probe predates the #1181 evidence. Without the
784
+ // flag, command attempts fail visibly (headless auto-deny) instead of
785
+ // running approved.
786
+ if (config.skipPermissions !== false && mode !== "plan") {
738
787
  args.push("--dangerously-skip-permissions");
739
788
  }
740
789
  if (isContinuation) args.push("--conversation", rawConvId as string);
package/src/config.ts CHANGED
@@ -89,11 +89,24 @@ export interface AgyConfig {
89
89
  /** Official-server ACP engine options (used when engine = "acp"). */
90
90
  acp: AcpConfig;
91
91
  mode: AgyMode;
92
+ /** Fail-closed kill switch for provider turns. ON = every provider turn
93
+ * is refused visibly: the agy CLI has no review-only enforcement on any
94
+ * path (probed 2026-10-07, upstream #1181: plan mode stages a plan and
95
+ * executes the write in the same turn on -p and stream-json alike; the
96
+ * ACP engine has no plan mode at all). Built for callers like
97
+ * `pi -p --no-tools` health checks that must never mutate a workspace
98
+ * and prefer a visible refusal over a write-capable turn. Wins over
99
+ * `mode`; the AskAntigravity delegation tool is refused while this is on
100
+ * (the CLI has no review-only enforcement, so nothing here can run
101
+ * read-only). Env AGY_READONLY wins over the file.
102
+ * Default false. */
103
+ readOnly: boolean;
92
104
  /** Auto-approve all agy tool permission requests (--dangerously-skip-permissions).
93
105
  * Required for non-interactive use: without it, any `run_command` triggers an
94
106
  * interactive y/n prompt that hangs forever in `-p` mode. Defaults true.
95
107
  * DANGEROUS: lets agy run arbitrary commands (including destructive ones)
96
- * without review. Turn off only if you also set mode=plan (no execution). */
108
+ * without review. The ask tool withholds the flag on plan runs;
109
+ * accept-edits runs need it to avoid hanging on the unanswerable prompt. */
97
110
  skipPermissions: boolean;
98
111
  /** AskAntigravity tool: default model alias (flash/pro/gemini or exact). */
99
112
  defaultModel: string;
@@ -176,6 +189,7 @@ export interface AgyConfig {
176
189
  const DEFAULTS: AgyConfig = {
177
190
  engine: "stream-json",
178
191
  mode: "accept-edits",
192
+ readOnly: false,
179
193
  skipPermissions: true,
180
194
  defaultModel: "flash",
181
195
  defaultThinking: "medium",
@@ -212,18 +226,24 @@ export function parseCapMinutes(raw: string | number | undefined, fallback: numb
212
226
  return n >= 1 && n <= MAX_TURN_CAP_MIN ? n : fallback;
213
227
  }
214
228
 
215
- /** Load config merged over defaults. Env vars override the file when set. */
216
- export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
217
- let file: Partial<AgyConfig> = {};
229
+ /** Read the raw config file (no defaults, no env). Shared by loadConfig and
230
+ * saveConfig so persisted writes can never bake env overrides into the file. */
231
+ function readConfigFile(configPath: string): Partial<AgyConfig> {
218
232
  try {
219
233
  const raw = fs.readFileSync(configPath, "utf8");
220
234
  const parsed = JSON.parse(raw);
221
235
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
222
- file = parsed as Partial<AgyConfig>;
236
+ return parsed as Partial<AgyConfig>;
223
237
  }
224
238
  } catch {
225
- /* missing or corrupt - fall back to defaults */
239
+ /* missing or corrupt - fall back to empty */
226
240
  }
241
+ return {};
242
+ }
243
+
244
+ /** Load config merged over defaults. Env vars override the file when set. */
245
+ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
246
+ let file: Partial<AgyConfig> = readConfigFile(configPath);
227
247
 
228
248
  // Env overrides file (matches the skipPermissions pattern).
229
249
  // The naive OR `env === "plan" || file.mode === "plan"` would ignore an
@@ -249,6 +269,17 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
249
269
  ? envPerm === "1" || envPerm.toLowerCase() === "true"
250
270
  : file.skipPermissions ?? DEFAULTS.skipPermissions;
251
271
 
272
+ const envReadOnly = process.env.AGY_READONLY;
273
+ // Fail-closed parse for a safety knob: empty string means the user
274
+ // explicitly blanked it (off), but ANY other set value that is not a
275
+ // spelled-out falsity counts as ON. " 1", "yes", typos: all refuse.
276
+ const readOnly =
277
+ envReadOnly !== undefined
278
+ ? envReadOnly === ""
279
+ ? false
280
+ : !["0", "false", "off"].includes(envReadOnly.trim().toLowerCase())
281
+ : file.readOnly ?? DEFAULTS.readOnly;
282
+
252
283
  const defaultModelRaw =
253
284
  process.env.AGY_DEFAULT_MODEL ?? file.defaultModel ?? DEFAULTS.defaultModel;
254
285
  const defaultModel =
@@ -329,6 +360,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
329
360
  engine,
330
361
  acp,
331
362
  mode,
363
+ readOnly,
332
364
  skipPermissions,
333
365
  defaultModel,
334
366
  defaultThinking,
@@ -345,10 +377,30 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
345
377
  };
346
378
  }
347
379
 
348
- /** Atomically persist a config patch (temp + rename). */
380
+ /** Atomically persist a config patch (temp + rename). Merges onto the RAW
381
+ * FILE values, never loadConfig(): env overrides must not be baked into the
382
+ * file (AGY_READONLY=1 in the env would otherwise persist readOnly: true to
383
+ * disk on any unrelated save, outliving the env var). Nested objects are
384
+ * re-normalized to their known keys: a raw passthrough would resurrect
385
+ * legacy keys (acp.permissions) that loadConfig normalization drops. */
349
386
  export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFIG_PATH): AgyConfig {
350
- const current = loadConfig(configPath);
351
- const next: AgyConfig = { ...current, ...patch };
387
+ const file = readConfigFile(configPath);
388
+ // Null-safe on nested objects: a hand-edited or legacy file can carry
389
+ // "acp": null; loadConfig tolerates it, so must saveConfig. Key presence
390
+ // triggers normalization (a null becomes the default shape, never null).
391
+ const fileAcp: Partial<AcpConfig> = file.acp && typeof file.acp === "object" ? file.acp : {};
392
+ const fileApprovals: Partial<GateConfig> = file.approvals && typeof file.approvals === "object" ? file.approvals : {};
393
+ const next: AgyConfig = {
394
+ ...DEFAULTS,
395
+ ...file,
396
+ ...(file.acp !== undefined
397
+ ? { acp: { bin: fileAcp.bin ?? DEFAULTS.acp.bin, usageEstimate: fileAcp.usageEstimate ?? DEFAULTS.acp.usageEstimate } }
398
+ : {}),
399
+ ...(file.approvals !== undefined
400
+ ? { approvals: { gateMode: fileApprovals.gateMode ?? DEFAULTS.approvals.gateMode, mode: fileApprovals.mode ?? DEFAULTS.approvals.mode } }
401
+ : {}),
402
+ ...patch,
403
+ };
352
404
  const dir = path.dirname(configPath);
353
405
  fs.mkdirSync(dir, { recursive: true });
354
406
  const tmp = `${configPath}.${process.pid}.tmp`;
package/src/driver.ts CHANGED
@@ -80,10 +80,11 @@ interface ActiveTurn {
80
80
  }
81
81
 
82
82
  /** The skip-permissions flag actually passed to agy. Plan mode never
83
- * carries it: the flag auto-approves every permission request including
84
- * plan mode's own approval gate, silently turning review-only into full
85
- * write access. #start (argv + profile) and #recycleCause (comparison)
86
- * must both go through this, or plan turns recycle on every turn. */
83
+ * carries it: the flag auto-approves every permission request, and the CLI
84
+ * does not gate writes under plan mode (upstream #1181), so an
85
+ * auto-approved plan run is a write-capable run. #start (argv + profile)
86
+ * and #recycleCause (comparison) must both go through this, or plan turns
87
+ * recycle on every turn. */
87
88
  function effectiveSkipPermissions(mode: DriverTurnRequest["mode"], skipPermissions: boolean): boolean {
88
89
  return mode !== "plan" && skipPermissions;
89
90
  }
@@ -352,12 +353,11 @@ export class StreamDriver implements TurnDriver {
352
353
  this.#generation += 1;
353
354
  const generation = this.#generation;
354
355
  // Plan mode never carries the skip flag: the flag auto-approves ALL
355
- // permission requests including plan mode's own approval gate, which
356
- // would silently turn "review-only" into full write access (probed
357
- // 2026-09-25: with the flag a plan session wrote files; without it,
358
- // file and command attempts end exit 0 in ~20-30s with a "confirm
359
- // plan" message). The profile stores the EFFECTIVE value so the
360
- // recycle comparison stays consistent with the argv.
356
+ // permission requests, and the CLI does not gate writes under plan
357
+ // mode (upstream google-antigravity/antigravity-cli#1181, probed
358
+ // 2026-10-07: the write executes in the same turn). An auto-approved
359
+ // plan run is a write-capable run. The profile stores the EFFECTIVE
360
+ // value so the recycle comparison stays consistent with the argv.
361
361
  const skipPermissions = effectiveSkipPermissions(request.mode, request.skipPermissions);
362
362
  this.#profile = {
363
363
  cwd: request.cwd,
package/src/provider.ts CHANGED
@@ -1435,13 +1435,34 @@ export function createStreamSimple(
1435
1435
  const config = loadConfig();
1436
1436
  const engine = deps.engine ?? config.engine;
1437
1437
  const selected = engine === "acp" && deps.acpDriver ? deps.acpDriver : deps.driver;
1438
- // RC01: ACP modes are permission modes, there is no review-only plan.
1439
- // A plan turn on ACP would silently run non-plan; fail visibly instead.
1440
- if (selected === deps.acpDriver && config.mode === "plan") {
1438
+ // Plan-shaped provider turns are fail-closed on BOTH engines. ACP has
1439
+ // no plan mode at all (RC01). Stream-json is worse and probed
1440
+ // 2026-10-07: the engine WARNS that --mode plan is a no-op while
1441
+ // --disable-slash-commands is set, executes file writes even with
1442
+ // slash expansion left on (it stages a plan.md and still writes in the
1443
+ // same turn), ignores --agent toolset restrictions, and file writes
1444
+ // bypass the permission system entirely. A plan-labeled turn here is
1445
+ // write-capable, so refuse visibly instead of running false safety.
1446
+ if (config.readOnly || config.mode === "plan") {
1441
1447
  const partial = newAssistant(model);
1442
1448
  const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
1443
- deps.log?.("turn-error", { reason: "acp-plan-refused" }, "warn");
1444
- finalize(stream, blocks, "error", "ACP engine has no plan mode. /agy mode accept-edits, or /agy engine stream-json.");
1449
+ if (config.readOnly) {
1450
+ deps.log?.("turn-error", { reason: "readonly-refused" }, "warn");
1451
+ finalize(
1452
+ stream,
1453
+ blocks,
1454
+ "error",
1455
+ "readOnly cannot be enforced on provider turns: the agy CLI has no review-only enforcement on any path. Plan mode stages a plan and executes the write in the same turn (probed 2026-10-07, google-antigravity/antigravity-cli#1181). Enforce read-only in the host (pi tool restrictions or the approval gate), then /agy readonly off.",
1456
+ );
1457
+ return stream;
1458
+ }
1459
+ deps.log?.("turn-error", { reason: "plan-refused" }, "warn");
1460
+ finalize(
1461
+ stream,
1462
+ blocks,
1463
+ "error",
1464
+ "Plan mode cannot be enforced on provider turns: the agy CLI stages a plan and executes writes in the same turn (probed 2026-10-07, google-antigravity/antigravity-cli#1181), and the ACP engine has no plan mode (RC01). /agy mode accept-edits, and enforce review-only in the host.",
1465
+ );
1445
1466
  return stream;
1446
1467
  }
1447
1468
  if (selected && roundTrips) {