@estebanforge/pi-antigravity-bridge 1.7.0 → 1.7.2

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,22 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.2] - 2026-09-24
6
+
7
+ ### Added
8
+
9
+ - **ACP permission requests park on a human decision.** `session/request_permission` was answered silently in-process (skip turns auto-allowed, everything else auto-denied), making ACP turns all-or-nothing with no per-tool human say. Interactive turns now hold the JSON-RPC request open and surface the server's options as a pi select dialog under the dialog lock, with the turn budget paused while the human decides: 480 s park, deny on timeout/esc/throw/connection death, and a cancel instead of an answer when the server offers no reject option (a deny must never degrade into picking an allow). `skipPermissions` keeps the synchronous auto-allow; `approvals.mode allow` maps to it, deny and headless stay fail-closed. `allow_always`/`reject_always` answers are remembered per connection for identical later requests, and first settle wins: a late dialog answer after a timeout is forgotten. Nine new tests pin the behavior, including a scenario proving a late answer writes nothing. 1217 tests, tsc clean.
10
+
11
+ ### Changed
12
+
13
+ - **Removed the dead `acp.permissions` config key.** It documented the in-connection auto policy but never had a consumer. Configs carrying it keep loading (unknown `acp` keys are ignored) and it drops out of the file on the next save.
14
+
15
+ ## [1.7.1] - 2026-09-23
16
+
17
+ ### Fixed
18
+
19
+ - **Subprocess stderr can no longer leak secrets into logs or the chat.** The `agy` CLI can print auth material (API keys, OAuth tokens, credential headers) to stderr, and six surfaces carried that text verbatim: the daily support log users attach to bug reports, the stream-json turn-failure error, the ACP connection-exit rejection reasons and `/agy doctor` tail, the web tools' failure note, and the AskAntigravity delegation exit note. A key in an auth error therefore landed on disk and in the transcript unchanged. Redaction now runs on the reassembled stderr tail at exit and getter boundaries - never per chunk - so a token split across two pipe events still matches, and the raw tail stays internal. New `redactText()` scrubs four secret-shaped pattern classes (API-key prefixes, OAuth tokens, credential headers, token-ish JSON fields), anchored with a word boundary so ordinary text like `desk-123456789012` survives, and carries no length cap because callers already bound their own output sizes. The daily log scrubber additionally scans string values, not just secret-shaped keys, and routes `Error.message` through the same pass; values are scanned before truncation so a cut token cannot lose the characters a pattern needs. Best-effort by design: a last line against leak paths, not a sandbox. Ten new tests pin the behavior, two of them spawning real processes (a secret-bearing crash and a token split across stderr chunks). 1200 tests, tsc clean.
20
+
5
21
  ## [1.7.0] - 2026-09-23
6
22
 
7
23
  ### Added
@@ -298,6 +298,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
298
298
  // a restart. Without this the driver defaults to "estimate" and the
299
299
  // config knob (incl. "off") is dead.
300
300
  usageEstimate: () => loadConfig().acp.usageEstimate,
301
+ // ACP request_permission parity with the approval gate: skipPermissions
302
+ // turns auto-allow in the driver; approvals.mode allow short-circuits
303
+ // to the same synchronous allow; everything else opens a dialog (esc
304
+ // and headless deny fail-closed) under the shared dialog lock.
305
+ permissionPolicy: () => (loadConfig().approvals.mode === "allow" ? "auto" : "deny"),
306
+ onPermissionRequest: async (req) => {
307
+ const ui = activeUi;
308
+ if (loadConfig().approvals.mode === "deny" || !ui) return undefined;
309
+ return await withDialogLock(async () => {
310
+ const labels = req.options.map((o) => o.name ?? o.kind ?? o.optionId ?? "?");
311
+ const title = req.toolCall?.title ? `agy requests: ${req.toolCall.title}` : "agy requests permission";
312
+ const picked = await ui.select(title, labels);
313
+ if (picked === undefined) return undefined;
314
+ const idx = labels.indexOf(picked);
315
+ return idx >= 0 ? (req.options[idx]?.optionId ?? undefined) : undefined;
316
+ });
317
+ },
301
318
  ...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
302
319
  log: acpLog,
303
320
  mcpServers: () => {
@@ -1218,7 +1235,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1218
1235
  if (rest.length > 0) {
1219
1236
  // Only the keyword compares case-insensitively; the path keeps its case.
1220
1237
  const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
1221
- // Spread, not a bare acp patch: a bare {bin, permissions} object
1238
+ // Spread, not a bare acp patch: a bare {bin} object
1222
1239
  // would drop sibling keys (usageEstimate) from the file.
1223
1240
  saveConfig({ acp: { ...loadConfig().acp, bin } });
1224
1241
  ui?.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -11,9 +11,10 @@
11
11
  // the effort tier baked in, e.g. gemini-3.8-flash-low)
12
12
  // - session/cancel returns -32601 on RC01 (not implemented) — the driver
13
13
  // treats that as "cancel unsupported" and falls back to teardown+kill
14
- // - session/request_permission is answered in-connection: policy per turn
15
- // (skipPermissions on -> first allow option; off -> first reject option,
16
- // fail-closed)
14
+ // - session/request_permission: skipPermissions turns answer the first
15
+ // allow option synchronously; otherwise a wired human handler PARKS the
16
+ // request (the JSON-RPC reply waits for the decision), and everything
17
+ // else fail-closes to the first reject option
17
18
  // - session/load replays history as notifications BEFORE its response; the
18
19
  // driver suppresses updates while the load is in flight
19
20
  // - mcpServers entries: {name, type:"http", url, headers:[]} — headers is a
@@ -26,6 +27,7 @@ import path from "node:path";
26
27
  import { parseAuthPort, readLastUrl } from "./browser-capture.js";
27
28
  import { frameCarriesUsage } from "./events.js";
28
29
  import type { AgyUsage } from "../driver-types.js";
30
+ import { redactText } from "../redact.js";
29
31
  import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
30
32
 
31
33
  export interface AcpMcpServer {
@@ -64,9 +66,17 @@ export interface AcpConnectionOptions {
64
66
  * the same param). Evaluated lazily per call. */
65
67
  mcpServers?: () => AcpMcpServer[];
66
68
  /** Permission policy for session/request_permission, evaluated per request:
67
- * "auto" selects the first allow option; "deny" fail-closes to the first
68
- * reject option. Absent = deny (fail closed). */
69
+ * "auto" selects the first allow option; "deny" consults the human
70
+ * handler (parking) or fail-closes to the first reject option. Absent =
71
+ * deny (fail closed). */
69
72
  permissions?: () => "auto" | "deny";
73
+ /** Human gate for "deny"-policy turns. The request is PARKED: the JSON-RPC
74
+ * reply waits until the handler settles. The returned optionId is
75
+ * validated against the server's options; undefined/null deny. Absent =
76
+ * synchronous deny (fail closed). */
77
+ onPermissionRequest?: AcpPermissionHandler;
78
+ /** Park budget for one permission dialog. Tests shrink it. */
79
+ permissionParkMs?: number;
70
80
  /** Record file for BROWSER-captured OAuth URLs (src/acp/browser-capture.ts).
71
81
  * The server hands the login URL only to the browser-open call, so the
72
82
  * wrapper records it there; the connection watches the file and logs
@@ -77,6 +87,30 @@ export interface AcpConnectionOptions {
77
87
  const INIT_TIMEOUT_MS = 30_000;
78
88
  const SESSION_OP_TIMEOUT_MS = 20_000;
79
89
 
90
+ /** Human-decision budget for one parked permission request. Mirrors the
91
+ * approval-gate park budget (APPROVAL_PARK_TIMEOUT_MS): the dialog must
92
+ * deny by itself, never hold the agy prompt open forever. */
93
+ const PERMISSION_PARK_MS = 480_000;
94
+
95
+ /** One session/request_permission option block (the subset we consume). */
96
+ export interface AcpPermissionOption {
97
+ optionId?: string;
98
+ kind?: string;
99
+ name?: string;
100
+ }
101
+
102
+ /** Payload handed to the permission handler when the server asks for a human
103
+ * decision. Options are server-defined data; the handler returns the chosen
104
+ * optionId, or undefined/null to deny. */
105
+ export interface AcpPermissionRequest {
106
+ sessionId?: string;
107
+ options: AcpPermissionOption[];
108
+ toolCall?: { toolCallId?: string; kind?: string; title?: string };
109
+ }
110
+
111
+ export type AcpPermissionHandler = (req: AcpPermissionRequest) => Promise<string | null | undefined>;
112
+
113
+
80
114
  export class AcpConnection {
81
115
  #opts: AcpConnectionOptions;
82
116
  #child: ChildProcess | undefined;
@@ -96,6 +130,13 @@ export class AcpConnection {
96
130
  lastConfigOptions: unknown = undefined;
97
131
  #authUrlWatcher: fs.FSWatcher | undefined;
98
132
  #lastAuthUrl: string | null = null;
133
+ /** Parked permission dialogs: each entry settles its own request with a
134
+ * deny. #finish drains the set so a dying connection never leaves the
135
+ * server's request hanging. */
136
+ #permissionPending = new Set<() => void>();
137
+ /** allow_always / reject_always answers, keyed by toolCall kind|title for
138
+ * the life of this connection: identical requests skip the dialog. */
139
+ #permissionMemory = new Map<string, string>();
99
140
 
100
141
  constructor(opts: AcpConnectionOptions) {
101
142
  this.#opts = opts;
@@ -110,7 +151,9 @@ export class AcpConnection {
110
151
  }
111
152
 
112
153
  get stderrTail(): string {
113
- return this.#stderrTail;
154
+ // Redacted at the boundary: the raw tail stays internal, every consumer
155
+ // (doctor display, driver exit logs, failure messages) sees redacted text.
156
+ return redactText(this.#stderrTail);
114
157
  }
115
158
 
116
159
  /** Gate B watch: latched true once any session/update frame carried
@@ -152,8 +195,8 @@ export class AcpConnection {
152
195
  // try/catch in #write cannot see them. Without this listener an EPIPE
153
196
  // (server died mid-handshake) is uncaught and kills pi.
154
197
  child.stdin?.on("error", (err) => {
155
- this.#opts.log("stdin-error", { message: err.message });
156
- if (!this.#exited && !this.#killed) this.#finish(err.message);
198
+ this.#opts.log("stdin-error", { message: redactText(err.message) });
199
+ if (!this.#exited && !this.#killed) this.#finish(redactText(err.message));
157
200
  });
158
201
 
159
202
  const rpc = new JsonRpcSession({
@@ -161,6 +204,24 @@ export class AcpConnection {
161
204
  onRequest: (method, params) => this.#onServerRequest(method, params),
162
205
  onNotification: (method, params) => this.#onNotification(method, params),
163
206
  onParseError: (line) => this.#opts.log("parse-error", { line }),
207
+ onOverflow: (detail) => {
208
+ // The session already aborted its pending requests with the typed
209
+ // reason; finish so later events are ignored, then take the whole
210
+ // process GROUP down: a flooder is broken or hostile, and the
211
+ // binary may be a wrapper script with live descendants.
212
+ this.#opts.log("frame-overflow", { detail });
213
+ this.#finish(`stdout frame overflow: ${detail}`);
214
+ const child = this.#child;
215
+ try {
216
+ if (child?.pid && process.platform !== "win32") {
217
+ process.kill(-child.pid, "SIGKILL");
218
+ } else {
219
+ child?.kill("SIGKILL");
220
+ }
221
+ } catch {
222
+ /* already gone */
223
+ }
224
+ },
164
225
  });
165
226
  this.#rpc = rpc;
166
227
 
@@ -169,12 +230,14 @@ export class AcpConnection {
169
230
  this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
170
231
  });
171
232
  child.on("error", (err) => {
172
- this.#opts.log("spawn-error", { message: err.message });
173
- this.#finish(err.message);
233
+ this.#opts.log("spawn-error", { message: redactText(err.message) });
234
+ this.#finish(redactText(err.message));
174
235
  });
175
236
  child.on("exit", (code, signal) => {
176
237
  this.#opts.log("exit", { code: code ?? signal ?? "?" });
177
- this.#finish(this.#stderrTail.trim());
238
+ // The reason rides into pending-request rejections, so it must be
239
+ // redacted here (server stderr can carry auth material).
240
+ this.#finish(redactText(this.#stderrTail.trim()));
178
241
  });
179
242
 
180
243
  const result = (await this.request(
@@ -302,21 +365,12 @@ export class AcpConnection {
302
365
  await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
303
366
  }
304
367
 
305
- /** Protocol-level permission answering: policy from the driver ("auto"
306
- * selects the first allow option; "deny" fail-closes to the first reject
307
- * option). Never hangs: options are data from the server and the answer
308
- * is computed synchronously. */
368
+ /** Protocol-level permission answering: policy "auto" selects the first
369
+ * allow option synchronously; "deny" consults the parked human handler
370
+ * (#answerPermission) or fail-closes. See there for the full matrix. */
309
371
  #onServerRequest(method: string, params: unknown): Promise<unknown> {
310
372
  if (method === "session/request_permission") {
311
- const options = (
312
- typeof params === "object" && params !== null ? (params as Record<string, unknown>).options : undefined
313
- ) as Array<{ optionId?: string; kind?: string }> | undefined;
314
- const allow = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
315
- const deny = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
316
- const policy = this.#opts.permissions?.() ?? "deny";
317
- const chosen = policy === "auto" ? (allow ?? deny ?? options?.[0]) : (deny ?? options?.[0]);
318
- this.#opts.log("permission", { optionId: chosen?.optionId, policy });
319
- return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen?.optionId } });
373
+ return this.#answerPermission(params as AcpPermissionRequest);
320
374
  }
321
375
  // fs/* and terminal/* are declined: our client capabilities are off and
322
376
  // agy keeps executing its own tools (plan §8 capability posture).
@@ -324,6 +378,86 @@ export class AcpConnection {
324
378
  return Promise.reject(new Error(`client capability not enabled: ${method}`));
325
379
  }
326
380
 
381
+ /** Answer session/request_permission. "auto" (skipPermissions turns)
382
+ * selects the first allow option synchronously. Otherwise a wired handler
383
+ * parks the request — the reply waits for the human — while every other
384
+ * path fail-closes synchronously: no handler, esc, throw, timeout,
385
+ * connection death. A chosen *_always option is remembered per connection
386
+ * for identical later requests (same toolCall kind+title). */
387
+ #answerPermission(params: AcpPermissionRequest): Promise<unknown> {
388
+ const options = Array.isArray(params?.options) ? params.options : [];
389
+ const allow = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
390
+ const denyId = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"))?.optionId;
391
+ const policy = this.#opts.permissions?.() ?? "deny";
392
+ if (policy === "auto") {
393
+ const chosen = (allow ?? options[0])?.optionId;
394
+ this.#opts.log("permission", { optionId: chosen, policy });
395
+ return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen } });
396
+ }
397
+ const handler = this.#opts.onPermissionRequest;
398
+ if (!handler) {
399
+ this.#opts.log("permission", { optionId: denyId, policy });
400
+ return Promise.resolve(
401
+ denyId !== undefined
402
+ ? { outcome: { outcome: "selected", optionId: denyId } }
403
+ : { outcome: { outcome: "cancelled" } },
404
+ );
405
+ }
406
+ // Always-memory needs a stable identity: the title. Without one, every
407
+ // untitled call would collapse onto one key and inherit its answer.
408
+ const memKey = params?.toolCall?.title ? `${params.toolCall.kind ?? ""}|${params.toolCall.title}` : undefined;
409
+ const remembered = memKey !== undefined ? this.#permissionMemory.get(memKey) : undefined;
410
+ if (remembered !== undefined && options.some((o) => o.optionId === remembered)) {
411
+ this.#opts.log("permission", { optionId: remembered, policy: "memory" });
412
+ return Promise.resolve({ outcome: { outcome: "selected", optionId: remembered } });
413
+ }
414
+ const parkMs = this.#opts.permissionParkMs ?? PERMISSION_PARK_MS;
415
+ return new Promise((resolve) => {
416
+ let timer: NodeJS.Timeout | undefined;
417
+ // First settle wins: a late dialog answer after a timeout/deny must
418
+ // not re-log or record an always-memory from a dead request.
419
+ let settled = false;
420
+ const settle = (optionId: string | undefined, why: string): void => {
421
+ if (settled) return;
422
+ settled = true;
423
+ if (timer) clearTimeout(timer);
424
+ this.#permissionPending.delete(settleDeny);
425
+ this.#opts.log("permission", { optionId, policy: why });
426
+ // undefined optionId (no reject option on the table) cancels
427
+ // instead of selecting nothing.
428
+ resolve(
429
+ optionId !== undefined
430
+ ? { outcome: { outcome: "selected", optionId } }
431
+ : { outcome: { outcome: "cancelled" } },
432
+ );
433
+ };
434
+ const settleDeny = (): void => settle(denyId, "dialog-deny");
435
+ timer = setTimeout(() => {
436
+ this.#opts.log("permission-timeout", { parkMs });
437
+ settleDeny();
438
+ }, parkMs);
439
+ this.#permissionPending.add(settleDeny);
440
+ Promise.resolve()
441
+ .then(() => handler(params))
442
+ .then((picked) => {
443
+ if (settled) return;
444
+ const chosen = picked != null ? options.find((o) => o.optionId === picked) : undefined;
445
+ if (!chosen?.optionId) {
446
+ settle(denyId, "dialog-deny");
447
+ return;
448
+ }
449
+ if (chosen.kind === "allow_always" || chosen.kind === "reject_always") {
450
+ if (memKey !== undefined) this.#permissionMemory.set(memKey, chosen.optionId);
451
+ }
452
+ settle(chosen.optionId, "dialog");
453
+ })
454
+ .catch((err: unknown) => {
455
+ this.#opts.log("permission-error", { message: err instanceof Error ? err.message : String(err) });
456
+ settle(denyId, "dialog-error");
457
+ });
458
+ });
459
+ }
460
+
327
461
  #onNotification(method: string, params: unknown): void {
328
462
  if (method === "session/update") {
329
463
  // Latched before the suppression gate: history-replay frames are the
@@ -443,8 +577,13 @@ export class AcpConnection {
443
577
  this.#exited = true;
444
578
  this.#authUrlWatcher?.close();
445
579
  this.#authUrlWatcher = undefined;
580
+ // Pending permission dialogs must not outlive the connection: deny them
581
+ // so their replies race the teardown instead of hanging the server.
582
+ const pendings = [...this.#permissionPending];
583
+ this.#permissionPending.clear();
584
+ for (const deny of pendings) deny();
446
585
  this.abortAll(`connection exited: ${reason || "process gone"}`);
447
- this.#opts.onExit({ code: null, signal: null, stderrTail: this.#stderrTail });
586
+ this.#opts.onExit({ code: null, signal: null, stderrTail: redactText(this.#stderrTail) });
448
587
  }
449
588
  }
450
589
 
package/src/acp/driver.ts CHANGED
@@ -15,12 +15,16 @@
15
15
  // probed once per connection; when upstream ships it, abort goes graceful
16
16
  // - overall-timer pause uses remaining-budget semantics on G9 parks (never a
17
17
  // fresh cap); every park carries its own timeout (BRIDGE_TIMEOUT_MS)
18
- // - single `auto` permission policy: request_permission answered
19
- // in-connection (plan §9.3); no provider involvement
18
+ // - request_permission: skipPermissions turns answer the first allow option
19
+ // synchronously; otherwise an optional human handler (extension dialog)
20
+ // PARKS the request with the turn budget paused; no handler = deny
21
+ // fail-closed. Approvals-mode parity: a permissionPolicy override maps
22
+ // approvals.mode allow to the same synchronous auto path
20
23
 
21
24
  import { randomUUID } from "node:crypto";
22
25
  import type { UsageEstimate } from "../config.js";
23
- import { AcpConnection, resolveAcpBinary, type AcpMcpServer } from "./connection.js";
26
+ import { AcpConnection, resolveAcpBinary, type AcpMcpServer, type AcpPermissionRequest } from "./connection.js";
27
+ import type { AcpPermissionHandler } from "./connection.js";
24
28
  import { mapStopReason, mapUpdate, TextAccumulator, type AcpEditDiff } from "./events.js";
25
29
  import { estimateTokens, synthesizeUsage } from "./usage-estimate.js";
26
30
  import type {
@@ -53,6 +57,16 @@ export interface AcpDriverOptions {
53
57
  * none. A function is resolved per turn (follows live config, like bin).
54
58
  * Default "estimate". */
55
59
  usageEstimate?: UsageEstimate | (() => UsageEstimate);
60
+ /** Human gate for ACP request_permission on non-skip turns. Forwarded to
61
+ * the connection; the driver parks the turn budget around each call
62
+ * (dialog time does not eat the deadline). */
63
+ onPermissionRequest?: AcpPermissionHandler;
64
+ /** Park budget for one permission dialog (tests shrink it). */
65
+ permissionParkMs?: number;
66
+ /** Standing policy consulted when the turn is not skipPermissions:
67
+ * "auto" answers the first allow option synchronously, "deny" consults
68
+ * the human handler or fail-closes. Default deny. */
69
+ permissionPolicy?: () => "auto" | "deny";
56
70
  log?: (msg: string, data?: unknown) => void;
57
71
  }
58
72
 
@@ -166,6 +180,33 @@ export class AcpDriver implements TurnDriver {
166
180
  this.#emit(t, activity);
167
181
  }
168
182
 
183
+ /** Run the human permission dialog with the turn parked: same budget
184
+ * semantics as bridge parks (dialog time does not consume the deadline).
185
+ * The finally unparks the CAPTURED turn, never whatever is active at
186
+ * exit: the turn can close mid-dialog (abort, server death) and a new
187
+ * turn must not inherit this park's accounting. */
188
+ async #parkForPermission(req: AcpPermissionRequest): Promise<string | null | undefined> {
189
+ const t = this.#active;
190
+ const parked = t !== undefined && !t.closed;
191
+ if (parked) {
192
+ t.parks += 1;
193
+ this.#clearIdle(t);
194
+ this.#pauseOverall(t);
195
+ }
196
+ try {
197
+ return await this.#opts.onPermissionRequest!(req);
198
+ } finally {
199
+ if (parked && !t.closed && t.parks > 0) {
200
+ t.parks -= 1;
201
+ if (t.parks === 0) {
202
+ this.#armIdle(t);
203
+ this.#resumeOverall(t);
204
+ this.#log("unparked");
205
+ }
206
+ }
207
+ }
208
+ }
209
+
169
210
  kickIdle(): void {
170
211
  const t = this.#active;
171
212
  if (!t || t.closed) return;
@@ -476,8 +517,17 @@ export class AcpDriver implements TurnDriver {
476
517
  cwd: request.cwd,
477
518
  mcpServers: this.#opts.mcpServers,
478
519
  log: (msg, data) => this.#log(msg, data),
479
- // Fail-closed permissions: only turns with skipPermissions answer allow.
480
- permissions: () => (this.#active?.request.skipPermissions ? "auto" : "deny"),
520
+ // Fail-closed permissions: skipPermissions answers allow; a standing
521
+ // policy override (approvals.mode allow) may widen that; everything
522
+ // else goes to the human handler or denies.
523
+ permissions: () =>
524
+ this.#active?.request.skipPermissions
525
+ ? "auto"
526
+ : (this.#opts.permissionPolicy?.() ?? "deny"),
527
+ onPermissionRequest: this.#opts.onPermissionRequest
528
+ ? (req) => this.#parkForPermission(req)
529
+ : undefined,
530
+ permissionParkMs: this.#opts.permissionParkMs,
481
531
  onUpdate: (sessionId, update) => this.#onConnectionUpdate(sessionId, update),
482
532
  onExit: (info) => this.#onConnectionExit(conn, info),
483
533
  });
@@ -16,6 +16,8 @@
16
16
  // - `abortAll()` rejects every pending request (Gate D teardown: no promise
17
17
  // survives a killed connection).
18
18
 
19
+ import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "../frame-guard.js";
20
+
19
21
  export interface JsonRpcErrorShape {
20
22
  code: number;
21
23
  message: string;
@@ -41,6 +43,9 @@ export interface JsonRpcSessionOptions {
41
43
  onNotification?: (method: string, params: unknown) => void;
42
44
  /** Unparseable line (logged, never fatal). */
43
45
  onParseError?: (line: string) => void;
46
+ /** Frame-cap breach: the peer flooded stdout with no newline. The session
47
+ * aborts itself; the transport must kill the process. */
48
+ onOverflow?: (detail: string) => void;
44
49
  /** Default timeout for requests without an explicit one (ms). 0 = none. */
45
50
  defaultTimeoutMs?: number;
46
51
  }
@@ -58,6 +63,7 @@ export class JsonRpcSession {
58
63
  #pending = new Map<number, Pending>();
59
64
  #nextId = 1;
60
65
  #aborted = false;
66
+ #overflowed = false;
61
67
  #lineBuf = "";
62
68
  parseErrors = 0;
63
69
 
@@ -68,26 +74,62 @@ export class JsonRpcSession {
68
74
 
69
75
  /** Feed raw transport bytes. Stdio chunks are NOT newline-aligned: bytes
70
76
  * are buffered and only complete lines are parsed (a frame split across a
71
- * 64KB pipe boundary must never be dropped). */
77
+ * 64KB pipe boundary must never be dropped). The buffer is capped: a peer
78
+ * flooding stdout with no newline breaches the cap and the session dies
79
+ * (onOverflow + abortAll), it never grows without bound. */
72
80
  feed(chunk: string): void {
81
+ if (this.#overflowed) return;
73
82
  this.#lineBuf += chunk;
83
+ if (this.#lineBuf.length > MAX_FRAME_BYTES) {
84
+ const detail = `${this.#lineBuf.length} bytes buffered with no newline (cap ${MAX_FRAME_BYTES})`;
85
+ this.#overflow(detail);
86
+ return;
87
+ }
74
88
  const lines = this.#lineBuf.split("\n");
75
89
  this.#lineBuf = lines.pop() ?? "";
76
90
  for (const raw of lines) {
77
91
  const line = raw.trim();
78
92
  if (!line) continue;
93
+ // Known foreign noise (e.g. the Chromium launcher behind a browser
94
+ // login) is dropped before parsing, not counted as a parse error.
95
+ if (isKnownNoiseLine(line)) continue;
79
96
  let msg: JsonRpcIncoming;
80
97
  try {
81
98
  msg = JSON.parse(line) as JsonRpcIncoming;
82
99
  } catch {
83
- this.parseErrors += 1;
84
- this.#opts.onParseError?.(line.slice(0, 200));
85
- continue;
100
+ // A noise FRAGMENT without its newline glued itself onto the next
101
+ // real frame; strip everything up to the marker and re-parse.
102
+ const repaired = stripGluedNoise(line);
103
+ if (repaired) {
104
+ try {
105
+ msg = JSON.parse(repaired) as JsonRpcIncoming;
106
+ } catch {
107
+ this.parseErrors += 1;
108
+ this.#opts.onParseError?.(line.slice(0, 200));
109
+ continue;
110
+ }
111
+ } else {
112
+ // Malformed lines are counted and dropped, never fatal (the
113
+ // parseAgyLine lesson: a chatty banner must not kill the reader
114
+ // loop).
115
+ this.parseErrors += 1;
116
+ this.#opts.onParseError?.(line.slice(0, 200));
117
+ continue;
118
+ }
86
119
  }
87
120
  this.#handleMessage(msg);
88
121
  }
89
122
  }
90
123
 
124
+ #overflow(detail: string): void {
125
+ this.#overflowed = true;
126
+ this.#lineBuf = "";
127
+ // Aborting first gives pending requests the typed overflow reason; the
128
+ // transport's own teardown afterwards is a no-op on the empty map.
129
+ this.abortAll(`stdout frame overflow: ${detail}`);
130
+ this.#opts.onOverflow?.(detail);
131
+ }
132
+
91
133
  #handleMessage(msg: JsonRpcIncoming): void {
92
134
  // Response to one of our requests.
93
135
  if (msg.method === undefined && msg.id !== undefined && msg.id !== null) {
package/src/ask-tool.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  snapshotConversations,
26
26
  } from "./discovery.js";
27
27
  import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
28
+ import { redactText } from "./redact.js";
28
29
  import { acquireBridgeSuppression } from "./mcp-registration.js";
29
30
  import { AGY_EFFORT_ORDER, spawnAgyModelsRaw, toAgyEffort } from "./models.js";
30
31
 
@@ -742,8 +743,10 @@ export async function registerAskAntigravityTool(
742
743
  }
743
744
 
744
745
  if (outcome.exitCode !== 0) {
745
- const note = details.stderr.trim()
746
- ? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
746
+ // stderr can carry auth material; the note is chat-visible.
747
+ const stderr = redactText(details.stderr.trim());
748
+ const note = stderr
749
+ ? `agy exited with status ${outcome.exitCode}: ${stderr}`
747
750
  : `agy exited with status ${outcome.exitCode}`;
748
751
  return {
749
752
  content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
package/src/config.ts CHANGED
@@ -76,10 +76,6 @@ export interface GateConfig {
76
76
  export interface AcpConfig {
77
77
  /** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
78
78
  bin: string;
79
- /** Single policy today: auto-approve request_permission in-connection
80
- * (parity with skipPermissions). Kept as a key so future policies do not
81
- * change the config shape. */
82
- permissions: "auto";
83
79
  /** Gate B stopgap: client-side token estimates for ACP turns so pi's
84
80
  * usage surfaces show nonzero numbers. Estimates are labeled as such in
85
81
  * /agy doctor and auto-disable when the server sends real usage. */
@@ -187,7 +183,7 @@ const DEFAULTS: AgyConfig = {
187
183
  turnTimeoutMin: 0, // placeholder: the real default is resolved per loadConfig call (defaultTurnTimeoutMin)
188
184
  inactivityTimeoutMin: 5,
189
185
  approvals: { gateMode: "auto", mode: "ask" },
190
- acp: { bin: "", permissions: "auto", usageEstimate: "estimate" },
186
+ acp: { bin: "", usageEstimate: "estimate" },
191
187
  };
192
188
 
193
189
  /** Hard ceiling for a free-typed turn cap: one day, in minutes. */
@@ -317,7 +313,6 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
317
313
  bin:
318
314
  process.env.AGY_ACP_BIN ??
319
315
  (typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
320
- permissions: "auto",
321
316
  usageEstimate,
322
317
  };
323
318
 
package/src/daily-log.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  // - One appendFile per record: O_APPEND keeps single-line writes atomic,
13
13
  // so two pi tabs sharing the dir stay line-consistent.
14
14
  // - No secrets, no prompt content: values of secret-shaped keys are
15
- // redacted and long strings are truncated before they reach disk.
15
+ // redacted wholesale, secret-shaped material inside string values
16
+ // (stderr tails, error messages) is scrubbed, and long strings are
17
+ // truncated before they reach disk.
16
18
  // - Retention: files older than `retentionDays` are pruned once per
17
19
  // process, so the dir cannot grow unbounded.
18
20
  // - Volume tiers: default installs write ONLY errors — routine logging
@@ -22,6 +24,7 @@
22
24
 
23
25
  import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
24
26
  import path from "node:path";
27
+ import { redactText } from "./redact.js";
25
28
 
26
29
  export type LogLevel = "debug" | "info" | "warn" | "error";
27
30
 
@@ -63,14 +66,24 @@ const MAX_RECORD = 4000;
63
66
  // lives in `value`, which a name-only regex would miss.
64
67
  const SECRET_KEY = /token|secret|password|passphrase|authorization|api[-_]?key|cookie|headers/i;
65
68
 
66
- /** Redact secret-shaped values and cap runaway strings before disk. */
69
+ /** Redact secret-shaped keys wholesale, scrub secrets embedded in string
70
+ * values, and cap runaway strings before disk. */
67
71
  function scrub(value: unknown, depth: number): unknown {
68
72
  if (value === null || value === undefined) return value;
69
73
  if (value instanceof Error) {
70
- return { name: value.name, message: value.message, stack: scrub(value.stack, depth) };
74
+ // message goes through the string branch: errors are the main carrier of
75
+ // subprocess stderr text (the exact leak class this scrubber exists for).
76
+ return {
77
+ name: value.name,
78
+ message: scrub(value.message, depth),
79
+ stack: scrub(value.stack, depth),
80
+ };
71
81
  }
72
82
  if (typeof value === "string") {
73
- return value.length > MAX_STRING ? value.slice(0, MAX_STRING) + "…(truncated)" : value;
83
+ // Value scan first, truncate second: a cut token could lose the tail
84
+ // characters the pattern needs to match.
85
+ const clean = redactText(value);
86
+ return clean.length > MAX_STRING ? clean.slice(0, MAX_STRING) + "…(truncated)" : clean;
74
87
  }
75
88
  if (typeof value === "number" || typeof value === "boolean") return value;
76
89
  if (depth >= MAX_DEPTH) return "(depth limit)";
package/src/driver.ts CHANGED
@@ -17,8 +17,10 @@
17
17
  import { spawn, type ChildProcess } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { parseAgyLine } from "./stream-events.js";
20
+ import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "./frame-guard.js";
20
21
  import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
21
22
  import { gateHooksStaged } from "./approval-hook.js";
23
+ import { redactText } from "./redact.js";
22
24
  import type {
23
25
  AgyUsage,
24
26
  DriverActivity,
@@ -403,7 +405,9 @@ export class StreamDriver implements TurnDriver {
403
405
  } else {
404
406
  this.#failTurn(
405
407
  turn,
406
- this.#stderrTail.trim() || `agy exited with status ${code ?? "signal"}`,
408
+ // agy stderr can carry auth material; the tail becomes the
409
+ // user-facing error and a daily-log value.
410
+ redactText(this.#stderrTail.trim()) || `agy exited with status ${code ?? "signal"}`,
407
411
  );
408
412
  }
409
413
  }
@@ -421,6 +425,18 @@ export class StreamDriver implements TurnDriver {
421
425
  // Buffer the trailing partial line: a frame split across pipe chunks is
422
426
  // reassembled when its newline arrives (mirrors JsonRpcSession.feed).
423
427
  this.#stdoutBuf += chunk;
428
+ if (this.#stdoutBuf.length > MAX_FRAME_BYTES) {
429
+ // A no-newline flood over the cap means the peer is broken or
430
+ // hostile: fail the turn and tear the process tree down instead of
431
+ // growing memory without bound.
432
+ const detail = `${this.#stdoutBuf.length} bytes buffered with no newline (cap ${MAX_FRAME_BYTES})`;
433
+ this.#stdoutBuf = "";
434
+ const turn = this.#active;
435
+ this.#log("frame-overflow", { detail });
436
+ if (turn && !turn.closed) this.#failTurn(turn, `stdout frame overflow: ${detail}`);
437
+ this.#killChild();
438
+ return;
439
+ }
424
440
  const lines = this.#stdoutBuf.split("\n");
425
441
  this.#stdoutBuf = lines.pop() ?? "";
426
442
  const turn = this.#active;
@@ -428,7 +444,19 @@ export class StreamDriver implements TurnDriver {
428
444
  if (turn.idleTimer) turn.idleTimer.refresh();
429
445
  for (const line of lines) {
430
446
  if (!line.trim()) continue;
431
- this.#applyParsed(turn, parseAgyLine(line));
447
+ // Known foreign noise (e.g. the Chromium launcher behind a browser
448
+ // login) is dropped; a noise FRAGMENT glued onto a real frame is
449
+ // stripped so the frame still parses.
450
+ if (isKnownNoiseLine(line)) continue;
451
+ let parsed = parseAgyLine(line);
452
+ // Repair applies only to true parse failures (raw is the string
453
+ // fragment): a valid JSON object agy sent with an unrecognized shape
454
+ // also lands as "unknown" and must reach the turn untouched.
455
+ if (parsed.kind === "unknown" && typeof parsed.raw === "string") {
456
+ const repaired = stripGluedNoise(line);
457
+ if (repaired) parsed = parseAgyLine(repaired);
458
+ }
459
+ this.#applyParsed(turn, parsed);
432
460
  if (turn.closed) return;
433
461
  }
434
462
  }
@@ -0,0 +1,38 @@
1
+ // Frame-guard: shared stdout framing defense for both engines.
2
+ //
3
+ // agy's stdout is a protocol stream, but foreign processes occasionally write
4
+ // into the same inherited pipe (the Chromium launcher behind a browser login
5
+ // prints "Opening in existing browser session." straight into it). Two
6
+ // defenses:
7
+ //
8
+ // 1. A hard per-frame ceiling: a line with no newline must never grow the
9
+ // reassembly buffer without bound. Breach = the peer is broken or
10
+ // hostile, the transport is dead.
11
+ // 2. Known-noise handling: a complete noise line is dropped before parsing;
12
+ // a noise FRAGMENT without a trailing newline glues itself onto the next
13
+ // real frame, so repair strips everything up to and including the marker.
14
+
15
+ /** Hard ceiling on one buffered (newline-terminated) frame. 32MB matches the
16
+ * reference ACP transport; real frames are KBs, machine-generated output
17
+ * with no newline for 32MB is a flood, not a frame. */
18
+ export const MAX_FRAME_BYTES = 32 * 1024 * 1024;
19
+
20
+ const NOISE_MARKERS = ["Opening in existing browser session."];
21
+
22
+ /** True when a complete line is pure known noise: drop before parsing. */
23
+ export function isKnownNoiseLine(line: string): boolean {
24
+ const trimmed = line.trim();
25
+ if (!trimmed) return false;
26
+ return NOISE_MARKERS.some((m) => trimmed === m);
27
+ }
28
+
29
+ /** Repair for the glue case: a noise fragment arrived without its newline and
30
+ * the next real frame is now "noise + json". Returns the remainder after the
31
+ * first known marker, or null when the line carries no marker. */
32
+ export function stripGluedNoise(line: string): string | null {
33
+ for (const marker of NOISE_MARKERS) {
34
+ const idx = line.indexOf(marker);
35
+ if (idx >= 0) return line.slice(idx + marker.length).trim();
36
+ }
37
+ return null;
38
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Secret redaction for free text: stderr tails, error messages, log string
2
+ // values. Patterns ported from pi-antigravity-acp-provider's errors.redact
3
+ // (MIT). No length cap here on purpose: callers already bound their own
4
+ // output sizes (8192 stderr tails, MAX_STRING truncation), and a cap inside
5
+ // this function would silently shorten their output.
6
+
7
+ const SECRET_PATTERNS: Array<[RegExp, string]> = [
8
+ // API-key prefixes: Google (AIza), OpenAI-style (sk-), npm, GitHub. The
9
+ // left word-boundary stops mid-word matches ("desk-123456789012").
10
+ [/\b(?:AIza|sk-|npm_|gh[opurs]_)[A-Za-z0-9_-]{12,}/gu, "<redacted>"],
11
+ // Google OAuth tokens (access ya29., refresh 1//).
12
+ [/\b(?:ya29\.|1\/\/)[A-Za-z0-9._~+/-]{12,}/gu, "<redacted>"],
13
+ // Credential header values; the key text stays (useful context).
14
+ [/((?:authorization|cookie|set-cookie)\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+/giu, "$1<redacted>"],
15
+ // Token-ish JSON fields; the key stays.
16
+ [
17
+ /((?:["']?(?:access_token|refresh_token|id_token|client_secret|api[-_]?key)["']?)\s*[:=]\s*["']?)[^"'\s,;&}]+/giu,
18
+ "$1<redacted>",
19
+ ],
20
+ ];
21
+
22
+ /** Replace secret-shaped material with `<redacted>`, preserving structure. */
23
+ export function redactText(text: string): string {
24
+ let out = text;
25
+ for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement);
26
+ return out;
27
+ }
package/src/web-tools.ts CHANGED
@@ -21,6 +21,7 @@ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync }
21
21
  import os from "node:os";
22
22
  import path from "node:path";
23
23
  import { Type } from "typebox";
24
+ import { redactText } from "./redact.js";
24
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
26
 
26
27
  export const WEB_AGENT_PREFIX = "pi-bridge-web-";
@@ -119,7 +120,7 @@ export async function runWebAgent(opts: WebRunOptions): Promise<WebRunResult> {
119
120
  const agentName = `${WEB_AGENT_PREFIX}${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
120
121
  const agentDir = path.join(root, agentName);
121
122
  mkdirSync(agentDir, { recursive: true, mode: 0o700 });
122
- // commandExecutionPolicy stays at the fork-validated "auto": the template's
123
+ // commandExecutionPolicy stays at the live-validated "auto": the template's
123
124
  // tools list has no command tool and plan mode blocks edits; an unvalidated
124
125
  // stricter enum risks agent discovery failing outright.
125
126
  writeFileSync(
@@ -178,7 +179,7 @@ export async function runWebAgent(opts: WebRunOptions): Promise<WebRunResult> {
178
179
  child.once("close", resolve);
179
180
  });
180
181
  if (!failed && opts.signal?.aborted) failed = "web run aborted";
181
- if (!failed && code !== 0) failed = `agy exited with code ${code}${stderrTail ? `: ${stderrTail.trim().slice(-300)}` : ""}`;
182
+ if (!failed && code !== 0) failed = `agy exited with code ${code}${stderrTail ? `: ${redactText(stderrTail.trim()).slice(-300)}` : ""}`;
182
183
  if (!failed && !allowedToolUsed) failed = `Antigravity returned an answer without an observed ${opts.gatedTool} step; refusing it as unverified`;
183
184
  if (!failed && response === "") failed = "Antigravity web run produced no answer";
184
185
  if (failed) {