@estebanforge/pi-antigravity-bridge 1.7.0 → 1.7.1

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,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.1] - 2026-09-23
6
+
7
+ ### Fixed
8
+
9
+ - **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.
10
+
5
11
  ## [1.7.0] - 2026-09-23
6
12
 
7
13
  ### Added
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.1",
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",
@@ -26,6 +26,7 @@ import path from "node:path";
26
26
  import { parseAuthPort, readLastUrl } from "./browser-capture.js";
27
27
  import { frameCarriesUsage } from "./events.js";
28
28
  import type { AgyUsage } from "../driver-types.js";
29
+ import { redactText } from "../redact.js";
29
30
  import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
30
31
 
31
32
  export interface AcpMcpServer {
@@ -110,7 +111,9 @@ export class AcpConnection {
110
111
  }
111
112
 
112
113
  get stderrTail(): string {
113
- return this.#stderrTail;
114
+ // Redacted at the boundary: the raw tail stays internal, every consumer
115
+ // (doctor display, driver exit logs, failure messages) sees redacted text.
116
+ return redactText(this.#stderrTail);
114
117
  }
115
118
 
116
119
  /** Gate B watch: latched true once any session/update frame carried
@@ -152,8 +155,8 @@ export class AcpConnection {
152
155
  // try/catch in #write cannot see them. Without this listener an EPIPE
153
156
  // (server died mid-handshake) is uncaught and kills pi.
154
157
  child.stdin?.on("error", (err) => {
155
- this.#opts.log("stdin-error", { message: err.message });
156
- if (!this.#exited && !this.#killed) this.#finish(err.message);
158
+ this.#opts.log("stdin-error", { message: redactText(err.message) });
159
+ if (!this.#exited && !this.#killed) this.#finish(redactText(err.message));
157
160
  });
158
161
 
159
162
  const rpc = new JsonRpcSession({
@@ -169,12 +172,14 @@ export class AcpConnection {
169
172
  this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
170
173
  });
171
174
  child.on("error", (err) => {
172
- this.#opts.log("spawn-error", { message: err.message });
173
- this.#finish(err.message);
175
+ this.#opts.log("spawn-error", { message: redactText(err.message) });
176
+ this.#finish(redactText(err.message));
174
177
  });
175
178
  child.on("exit", (code, signal) => {
176
179
  this.#opts.log("exit", { code: code ?? signal ?? "?" });
177
- this.#finish(this.#stderrTail.trim());
180
+ // The reason rides into pending-request rejections, so it must be
181
+ // redacted here (server stderr can carry auth material).
182
+ this.#finish(redactText(this.#stderrTail.trim()));
178
183
  });
179
184
 
180
185
  const result = (await this.request(
@@ -444,7 +449,7 @@ export class AcpConnection {
444
449
  this.#authUrlWatcher?.close();
445
450
  this.#authUrlWatcher = undefined;
446
451
  this.abortAll(`connection exited: ${reason || "process gone"}`);
447
- this.#opts.onExit({ code: null, signal: null, stderrTail: this.#stderrTail });
452
+ this.#opts.onExit({ code: null, signal: null, stderrTail: redactText(this.#stderrTail) });
448
453
  }
449
454
  }
450
455
 
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/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
@@ -19,6 +19,7 @@ import { randomUUID } from "node:crypto";
19
19
  import { parseAgyLine } from "./stream-events.js";
20
20
  import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
21
21
  import { gateHooksStaged } from "./approval-hook.js";
22
+ import { redactText } from "./redact.js";
22
23
  import type {
23
24
  AgyUsage,
24
25
  DriverActivity,
@@ -403,7 +404,9 @@ export class StreamDriver implements TurnDriver {
403
404
  } else {
404
405
  this.#failTurn(
405
406
  turn,
406
- this.#stderrTail.trim() || `agy exited with status ${code ?? "signal"}`,
407
+ // agy stderr can carry auth material; the tail becomes the
408
+ // user-facing error and a daily-log value.
409
+ redactText(this.#stderrTail.trim()) || `agy exited with status ${code ?? "signal"}`,
407
410
  );
408
411
  }
409
412
  }
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) {