From ab6ed4408af16c124a87ac20ebbb3741f8350330 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 12:52:33 -0600 Subject: [PATCH 1/3] feat(provider): add gemini cli provider --- docs/providers.md | 51 +++++ docs/safety.md | 7 +- src/exec.ts | 14 +- src/provider.test.ts | 221 ++++++++++++++++++++++ src/provider.ts | 434 ++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 720 insertions(+), 7 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 9453888..63a858d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -15,6 +15,7 @@ Provider names today: - `codex`: shells out to `codex exec` (default) - `acpx`: routes through any ACP-compatible coding agent via `acpx` +- `gemini`: shells out to Google Gemini CLI in headless mode - `grok`: shells out to the xAI Grok Build CLI in headless mode (`grok --prompt-file`) - `opencode`: shells out to `opencode run --format json` - `pi`: shells out to `pi -p` (non-interactive print mode) @@ -105,6 +106,56 @@ Migration note: `--provider codex --model gpt-5-codex` is not equivalent to `--provider acpx --model gpt-5-codex`; the latter selects an ACP agent named `gpt-5-codex`. Use `--provider acpx --model codex:gpt-5-codex`. +## Gemini + +The `gemini` provider shells out to the local +[Gemini CLI](https://github.com/google-gemini/gemini-cli) in headless mode. + +Install Gemini CLI and authenticate using one of the upstream-supported flows: + +```bash +npm install -g @google/gemini-cli +gemini --version +``` + +Provider selection: + +```bash +CLAWPATCH_GEMINI_TRUST_WORKSPACE=true clawpatch review --provider gemini +CLAWPATCH_GEMINI_TRUST_WORKSPACE=true clawpatch fix --finding --provider gemini +CLAWPATCH_GEMINI_TRUST_WORKSPACE=true clawpatch doctor --provider gemini +``` + +How the Gemini provider works: + +- Headless mode: `gemini --skip-trust -p "" --approval-mode= --output-format=json` +- Prompt delivery: Clawpatch writes the full prompt to stdin; it does not pass large prompts on argv +- Read-only operations (map, review, revalidate): use `--approval-mode=plan` +- Write operations (fix): use `--approval-mode=auto_edit`, not yolo mode +- Output: parses Gemini's JSON envelope and extracts the string `response` field before validating Clawpatch JSON +- Model selection: `--model ` is passed through when configured +- Reasoning effort and `skipGitRepoCheck`: not supported by Gemini CLI and are treated as no-ops +- Timeout: 180 seconds by default, override with `CLAWPATCH_GEMINI_TIMEOUT_MS` or `CLAWPATCH_PROVIDER_TIMEOUT_MS` + +Security gates: + +- Gemini CLI must be patched for GHSA-wpqr-6v78-jr5g. Clawpatch accepts + stable versions `>=0.39.1` and preview versions `>=0.40.0-preview.3`. + Set `CLAWPATCH_GEMINI_ALLOW_UNPATCHED=1` only for local diagnostics. +- Clawpatch uses `--skip-trust` because Gemini headless execution requires an + explicit trusted-workspace path. You must opt in with + `CLAWPATCH_GEMINI_TRUST_WORKSPACE=true`; use this only in an isolated checkout + with no untrusted project Gemini configuration or secrets. +- Gemini subprocesses run with isolated temp `HOME` and XDG dirs. Clawpatch + copies only the minimal verified Gemini auth/config files into that temp home, + and forwards a small env allowlist: path/temp basics, explicit Google/Gemini + auth variables, proxy and certificate vars, and `NO_COLOR`. Wildcard secret + prefixes are not forwarded. +- Clawpatch passes `--extensions none` and prompts read-only operations not to use + network, MCP, skills, subagents, shell, or write tools. Enforcement still + depends on Gemini CLI policy behavior, so review untrusted code in an isolated + checkout. + ## Grok The `grok` provider shells out to the local [Grok Build CLI](https://x.ai/cli). diff --git a/docs/safety.md b/docs/safety.md index 9b25fb5..2be5054 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -13,10 +13,9 @@ Current safety rules: - `fix` refuses a dirty source worktree by default. - `.clawpatch/` state changes are allowed during runs. - review and revalidate provider calls use a read-only sandbox for the `codex` - provider. The `acpx` provider relies on `acpx --approve-reads` plus an explicit - read-only prompt directive; underlying agents that bypass ACP permissions (e.g. - agents running in their own full-access mode) may not be strictly sandboxed. - See docs/providers.md. + provider. Other local CLI providers rely on their own approval modes and + explicit read-only prompt directives; underlying agents may not be strictly + sandboxed. See docs/providers.md. - provider output must pass runtime schema validation. - feature locks are stored in feature records and `.clawpatch/locks/`; `status` surfaces both, and `clean-locks` clears both. diff --git a/src/exec.ts b/src/exec.ts index 5e339af..0d96788 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -66,13 +66,23 @@ export async function runCommandArgs( args: string[], cwd: string, input?: string, - options: { trimOutput?: boolean; env?: NodeJS.ProcessEnv; timeoutMs?: number } = {}, + options: { + trimOutput?: boolean; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + inheritEnv?: boolean; + } = {}, ): Promise { const started = Date.now(); const spawnSpec = commandSpawnSpec(program, args); const child = spawn(spawnSpec.program, spawnSpec.args, { cwd, - env: options.env === undefined ? process.env : { ...process.env, ...options.env }, + env: + options.env === undefined + ? process.env + : options.inheritEnv === false + ? options.env + : { ...process.env, ...options.env }, detached: process.platform !== "win32" && options.timeoutMs !== undefined, shell: false, stdio: ["pipe", "pipe", "pipe"], diff --git a/src/provider.test.ts b/src/provider.test.ts index 239185e..8e60e73 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ClawpatchError } from "./errors.js"; import { __testing, extractJson, providerByName } from "./provider.js"; import { safeProviderPreview } from "./provider-json.js"; @@ -11,9 +14,17 @@ const { acpxFailureMessage, codexFailureMessage, extractAcpxJson, + extractGeminiResponse, extractOpencodeJson, + geminiArgs, + geminiEnv, + geminiIsolatedEnv, + geminiPrompt, + geminiSelectedAuthType, + isGeminiPatched, parseAcpxAgent, parseCodexJson, + parseGeminiVersion, piThinkingLevel, providerJsonSchema, } = __testing; @@ -248,6 +259,215 @@ describe("piThinkingLevel", () => { }); }); +describe("Gemini provider", () => { + const originalGeminiSecret = process.env["GEMINI_SECRET_TEST"]; + const originalGeminiApiKey = process.env["GEMINI_API_KEY"]; + const originalOpenAiKey = process.env["OPENAI_API_KEY"]; + + afterEach(() => { + restoreEnv("GEMINI_SECRET_TEST", originalGeminiSecret); + restoreEnv("GEMINI_API_KEY", originalGeminiApiKey); + restoreEnv("OPENAI_API_KEY", originalOpenAiKey); + }); + + it("builds the HITL-verified review command shape with model passthrough", () => { + expect( + geminiArgs( + { model: "gemini-3-pro", reasoningEffort: "high", skipGitRepoCheck: true }, + "plan", + ), + ).toEqual([ + "--skip-trust", + "-p", + "", + "--approval-mode=plan", + "--output-format=json", + "--extensions", + "none", + "--model", + "gemini-3-pro", + ]); + }); + + it("builds fix commands with auto_edit instead of yolo", () => { + expect( + geminiArgs({ model: null, reasoningEffort: null, skipGitRepoCheck: false }, "auto_edit"), + ).toEqual([ + "--skip-trust", + "-p", + "", + "--approval-mode=auto_edit", + "--output-format=json", + "--extensions", + "none", + ]); + }); + + it("adds read-only safety instructions to plan prompts", () => { + const prompt = geminiPrompt("Inspect src/index.ts", { type: "object" }, true); + + expect(prompt).toContain("READ-ONLY REVIEW MODE"); + expect(prompt).toContain("Do not exit plan mode"); + expect(prompt).toContain("Provider output schema"); + }); + + it("extracts Clawpatch response text from Gemini JSON envelopes", () => { + const stdout = JSON.stringify({ + response: '```json\n{"findings":[],"inspected":{"files":[],"symbols":[],"notes":[]}}\n```', + stats: { tools: { totalCalls: 0, byName: {} } }, + future: true, + }); + + expect(extractGeminiResponse(stdout)).toContain('"findings":[]'); + }); + + it("throws malformed-output for missing Gemini response", () => { + expectMalformed( + () => extractGeminiResponse(JSON.stringify({ stats: {} })), + /missing.*response/u, + ); + }); + + it("throws malformed-output for multiple Gemini JSON envelopes", () => { + const stdout = [JSON.stringify({ response: "{}" }), JSON.stringify({ response: "{}" })].join( + "\n", + ); + + expectMalformed(() => extractGeminiResponse(stdout), /2 JSON envelopes/u); + }); + + it("throws provider-failure for Gemini error envelopes", () => { + try { + extractGeminiResponse(JSON.stringify({ error: { message: "quota exceeded" } })); + } catch (err) { + expect(err).toBeInstanceOf(ClawpatchError); + expect((err as ClawpatchError).code).toBe("provider-failure"); + expect((err as ClawpatchError).exitCode).toBe(5); + return; + } + throw new Error("expected provider failure"); + }); + + it("checks patched Gemini CLI versions", () => { + expect(parseGeminiVersion("0.42.0")).toBe("0.42.0"); + expect(isGeminiPatched("0.39.1")).toBe(true); + expect(isGeminiPatched("0.40.0-preview.3")).toBe(true); + expect(isGeminiPatched("0.39.0")).toBe(false); + expect(isGeminiPatched("0.40.0-preview.2")).toBe(false); + }); + + it("uses an explicit environment allowlist", () => { + process.env["GEMINI_API_KEY"] = "allowed"; + process.env["GEMINI_SECRET_TEST"] = "blocked"; + process.env["OPENAI_API_KEY"] = "blocked"; + + const env = geminiEnv({ + home: "/tmp/gemini-home", + xdgConfig: "/tmp/gemini-config", + xdgCache: "/tmp/gemini-cache", + xdgData: "/tmp/gemini-data", + }); + + expect(env["GEMINI_API_KEY"]).toBe("allowed"); + expect(env["HOME"]).toBe("/tmp/gemini-home"); + expect(env["XDG_CONFIG_HOME"]).toBe("/tmp/gemini-config"); + expect(env["GEMINI_SECRET_TEST"]).toBeUndefined(); + expect(env["OPENAI_API_KEY"]).toBeUndefined(); + }); + + it("seeds only minimal Gemini auth files and sanitized settings into an isolated home", async () => { + const sourceHome = await mkdtempForTest("clawpatch-gemini-source-home-"); + const originalHome = process.env["HOME"]; + await mkdir(join(sourceHome, ".gemini"), { recursive: true }); + await writeFile(join(sourceHome, ".gemini", "oauth_creds.json"), "oauth", "utf8"); + await writeFile( + join(sourceHome, ".gemini", "settings.json"), + JSON.stringify({ + security: { auth: { selectedType: "oauth-personal", token: "blocked" } }, + hooks: { onStart: "blocked" }, + }), + "utf8", + ); + await writeFile(join(sourceHome, ".gemini", "hooks.json"), "blocked", "utf8"); + process.env["HOME"] = sourceHome; + + const isolated = await geminiIsolatedEnv(); + + try { + expect(isolated.env["HOME"]).not.toBe(sourceHome); + expect(isolated.env["XDG_CONFIG_HOME"]).toContain(isolated.root); + expect( + await readFile(join(isolated.env["HOME"]!, ".gemini", "oauth_creds.json"), "utf8"), + ).toBe("oauth"); + expect( + JSON.parse(await readFile(join(isolated.env["HOME"]!, ".gemini", "settings.json"), "utf8")), + ).toEqual({ + security: { auth: { selectedType: "oauth-personal" } }, + }); + expect( + await readFile(join(isolated.env["HOME"]!, ".gemini", "settings.json"), "utf8"), + ).not.toContain("blocked"); + await expect( + readFile(join(isolated.env["HOME"]!, ".gemini", "hooks.json"), "utf8"), + ).rejects.toThrow(); + } finally { + await rm(isolated.root, { recursive: true, force: true }); + await rm(sourceHome, { recursive: true, force: true }); + restoreEnv("HOME", originalHome); + } + }); + + it("extracts only the Gemini auth selection from settings", () => { + expect( + geminiSelectedAuthType({ + security: { auth: { selectedType: "oauth-personal", token: "blocked" } }, + hooks: { onStart: "blocked" }, + }), + ).toBe("oauth-personal"); + expect( + geminiSelectedAuthType({ + security: { auth: { selectedType: "" } }, + }), + ).toBeNull(); + }); + + it("does not write Gemini settings when the source has no selected auth type", async () => { + const sourceHome = await mkdtempForTest("clawpatch-gemini-no-auth-home-"); + const originalHome = process.env["HOME"]; + await mkdir(join(sourceHome, ".gemini"), { recursive: true }); + await writeFile( + join(sourceHome, ".gemini", "settings.json"), + JSON.stringify({ hooks: { onStart: "blocked" } }), + "utf8", + ); + process.env["HOME"] = sourceHome; + + const isolated = await geminiIsolatedEnv(); + + try { + await expect( + readFile(join(isolated.env["HOME"]!, ".gemini", "settings.json"), "utf8"), + ).rejects.toThrow(); + } finally { + await rm(isolated.root, { recursive: true, force: true }); + await rm(sourceHome, { recursive: true, force: true }); + restoreEnv("HOME", originalHome); + } + }); +}); + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +async function mkdtempForTest(prefix: string): Promise { + return mkdtemp(join(tmpdir(), prefix)); +} + function schemaKeys(value: unknown): string[] { if (Array.isArray(value)) { return value.flatMap(schemaKeys); @@ -558,6 +778,7 @@ describe("extractOpencodeJson", () => { describe("providerByName", () => { it("returns provider instances for optional CLI-backed providers", () => { expect(providerByName("acpx").name).toBe("acpx"); + expect(providerByName("gemini").name).toBe("gemini"); expect(providerByName("grok").name).toBe("grok"); expect(providerByName("opencode").name).toBe("opencode"); expect(providerByName("pi").name).toBe("pi"); diff --git a/src/provider.ts b/src/provider.ts index 3bc387d..931b3ba 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,8 +1,9 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { runCommandArgs } from "./exec.js"; import { ClawpatchError } from "./errors.js"; +import { pathExists } from "./fs.js"; import { agentMapJsonSchema, fixPlanJsonSchema, @@ -43,6 +44,9 @@ export function providerByName(name: string): Provider { if (name === "codex") { return codexProvider; } + if (name === "gemini") { + return geminiProvider; + } if (name === "opencode") { return opencodeProvider; } @@ -197,6 +201,9 @@ const grokProvider: Provider = { }; const PI_DEFAULT_TIMEOUT_MS = 180_000; +const GEMINI_DEFAULT_TIMEOUT_MS = 180_000; +const GEMINI_PATCHED_STABLE = "0.39.1"; +const GEMINI_PATCHED_PREVIEW = "0.40.0-preview.3"; const piProvider: Provider = { name: "pi", @@ -229,6 +236,423 @@ const piProvider: Provider = { }, }; +const geminiProvider: Provider = { + name: "gemini", + async check(root: string): Promise { + const result = await runGeminiCommand(root, ["--version"]); + if (result.exitCode !== 0) { + throw new ClawpatchError( + geminiFailureMessage(result.stdout, result.stderr), + providerExitCode(`${result.stderr}\n${result.stdout}`), + "provider-auth", + ); + } + const version = result.stdout.trim() || result.stderr.trim(); + assertGeminiPatched(version); + return `${version} (patched for GHSA-wpqr-6v78-jr5g)`; + }, + async map(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runGeminiJson(root, prompt, options, agentMapJsonSchema, "plan"); + return agentMapOutputSchema.parse(output); + }, + async review(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runGeminiJson(root, prompt, options, reviewJsonSchema, "plan"); + return reviewOutputSchema.parse(output); + }, + async fix(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runGeminiJson(root, prompt, options, fixPlanJsonSchema, "auto_edit"); + return fixPlanOutputSchema.parse(output); + }, + async revalidate( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runGeminiJson(root, prompt, options, revalidateJsonSchema, "plan"); + return revalidateOutputSchema.parse(output); + }, +}; + +async function runGeminiJson( + root: string, + prompt: string, + options: ProviderOptions, + schema: object, + approvalMode: "plan" | "auto_edit", +): Promise { + requireGeminiTrustedWorkspace(); + const args = geminiArgs(options, approvalMode); + const result = await runGeminiCommand( + root, + args, + geminiPrompt(prompt, schema, approvalMode === "plan"), + ); + if (result.exitCode !== 0) { + throw new ClawpatchError( + geminiFailureMessage(result.stdout, result.stderr), + providerExitCode(`${result.stderr}\n${result.stdout}`), + "provider-failure", + ); + } + const response = extractGeminiResponse(result.stdout); + const parsed = extractJson(response); + if (parsed === null) { + throw new ClawpatchError( + `gemini provider response contained no parseable Clawpatch JSON ` + + `(response preview: ${safeProviderPreview(response)})`, + 8, + "malformed-output", + ); + } + return parsed; +} + +function geminiArgs(options: ProviderOptions, approvalMode: "plan" | "auto_edit"): string[] { + const args = [ + "--skip-trust", + "-p", + "", + `--approval-mode=${approvalMode}`, + "--output-format=json", + "--extensions", + "none", + ]; + if (options.model !== null) { + args.push("--model", options.model); + } + return args; +} + +async function runGeminiCommand(root: string, args: string[], input?: string) { + const isolated = await geminiIsolatedEnv(); + try { + return await runCommandArgs(geminiExecutable(), args, root, input, { + trimOutput: false, + timeoutMs: geminiTimeoutMs(), + env: isolated.env, + inheritEnv: false, + }); + } finally { + await rm(isolated.root, { recursive: true, force: true }).catch(() => {}); + } +} + +function geminiExecutable(): string { + const configured = process.env["CLAWPATCH_GEMINI_BIN"]?.trim(); + if (configured === undefined || configured.length === 0) { + return "gemini"; + } + if (configured.includes("\0")) { + throw new ClawpatchError("invalid CLAWPATCH_GEMINI_BIN", 2, "invalid-usage"); + } + return configured; +} + +async function geminiIsolatedEnv(): Promise<{ env: NodeJS.ProcessEnv; root: string }> { + const root = await mkdtemp(join(tmpdir(), "clawpatch-gemini-")); + const home = join(root, "home"); + const xdgConfig = join(root, "xdg-config"); + const xdgCache = join(root, "xdg-cache"); + const xdgData = join(root, "xdg-data"); + await Promise.all([ + mkdir(join(home, ".gemini"), { recursive: true }), + mkdir(xdgConfig, { recursive: true }), + mkdir(xdgCache, { recursive: true }), + mkdir(xdgData, { recursive: true }), + ]); + await seedGeminiState(home); + return { + root, + env: geminiEnv({ + home, + xdgConfig, + xdgCache, + xdgData, + }), + }; +} + +async function seedGeminiState(home: string): Promise { + const sourceHome = process.env["HOME"]; + if (sourceHome === undefined || sourceHome.length === 0) { + return; + } + const sourceDir = join(sourceHome, ".gemini"); + const targetDir = join(home, ".gemini"); + for (const file of ["google_accounts.json", "oauth_creds.json", "installation_id"]) { + const source = join(sourceDir, file); + if (await pathExists(source)) { + await copyFile(source, join(targetDir, file)); + } + } + await writeMinimalGeminiSettings(sourceDir, targetDir); +} + +async function writeMinimalGeminiSettings(sourceDir: string, targetDir: string): Promise { + const source = join(sourceDir, "settings.json"); + if (!(await pathExists(source))) { + return; + } + let raw: string; + try { + raw = await readFile(source, "utf8"); + } catch { + return; + } + let settings: unknown; + try { + settings = JSON.parse(raw) as unknown; + } catch { + return; + } + const selectedType = geminiSelectedAuthType(settings); + if (selectedType === null) { + return; + } + await writeFile( + join(targetDir, "settings.json"), + JSON.stringify({ security: { auth: { selectedType } } }), + "utf8", + ); +} + +function geminiSelectedAuthType(settings: unknown): string | null { + if (typeof settings !== "object" || settings === null) { + return null; + } + const security = (settings as Record)["security"]; + if (typeof security !== "object" || security === null) { + return null; + } + const auth = (security as Record)["auth"]; + if (typeof auth !== "object" || auth === null) { + return null; + } + const selectedType = (auth as Record)["selectedType"]; + return typeof selectedType === "string" && selectedType.length > 0 ? selectedType : null; +} + +function geminiEnv(paths?: { + home: string; + xdgConfig: string; + xdgCache: string; + xdgData: string; +}): NodeJS.ProcessEnv { + const allowed = [ + "PATH", + "TMPDIR", + "GEMINI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", + "GOOGLE_CLOUD_QUOTA_PROJECT", + "GOOGLE_GENAI_USE_VERTEXAI", + "HTTPS_PROXY", + "HTTP_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "NO_COLOR", + ]; + const env: NodeJS.ProcessEnv = {}; + if (paths !== undefined) { + env["HOME"] = paths.home; + env["XDG_CONFIG_HOME"] = paths.xdgConfig; + env["XDG_CACHE_HOME"] = paths.xdgCache; + env["XDG_DATA_HOME"] = paths.xdgData; + } + for (const key of allowed) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + if (process.platform === "win32") { + for (const key of ["ComSpec", "SystemRoot", "WINDIR", "PATHEXT"]) { + const value = process.env[key]; + if (value !== undefined) { + env[key] = value; + } + } + } + return env; +} + +function geminiPrompt(prompt: string, schema: object, readOnly: boolean): string { + const promptBody = readOnly + ? "READ-ONLY REVIEW MODE.\n" + + "Do not modify, create, or delete any files in the workspace.\n" + + "Do not run shell commands, use network tools, use MCP servers, activate skills, or launch subagents.\n" + + "Do not exit plan mode. Only inspect files needed to answer with the JSON output below.\n\n" + + prompt + : prompt; + return `${promptBody} + +Provider output schema: +${JSON.stringify(schema, null, 2)} + +Return only one JSON object matching the schema.`; +} + +function extractGeminiResponse(stdout: string): string { + const text = stdout.trim(); + if (text.length === 0) { + throw new ClawpatchError("gemini provider produced no output", 8, "malformed-output"); + } + const envelope = parseGeminiEnvelope(text); + if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) { + throw new ClawpatchError( + "gemini provider produced a non-object JSON envelope", + 8, + "malformed-output", + ); + } + const record = envelope as Record; + const error = record["error"]; + if (error !== undefined && error !== null) { + throw new ClawpatchError( + `gemini provider error: ${safeProviderPreview(geminiErrorText(error))}`, + providerExitCode(geminiErrorText(error)), + "provider-failure", + ); + } + if (typeof record["response"] !== "string") { + throw new ClawpatchError( + "gemini provider JSON envelope is missing string field `response`", + 8, + "malformed-output", + ); + } + return record["response"]; +} + +function parseGeminiEnvelope(text: string): unknown { + try { + return JSON.parse(text) as unknown; + } catch {} + const values: unknown[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + try { + values.push(JSON.parse(trimmed) as unknown); + } catch { + throw new ClawpatchError( + `gemini provider produced malformed JSON envelope ` + + `(output preview: ${safeProviderPreview(text)})`, + 8, + "malformed-output", + ); + } + } + if (values.length === 1) { + return values[0]; + } + throw new ClawpatchError( + `gemini provider produced ${values.length} JSON envelopes; expected exactly one`, + 8, + "malformed-output", + ); +} + +function geminiErrorText(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (typeof error !== "object" || error === null) { + return String(error); + } + const record = error as Record; + for (const key of ["message", "status", "code"]) { + if (typeof record[key] === "string" || typeof record[key] === "number") { + return String(record[key]); + } + } + return JSON.stringify(record); +} + +function geminiFailureMessage(stdout: string, stderr: string): string { + const output = stderr.trim().length > 0 ? stderr : stdout; + const preview = safeProviderPreview(output); + return preview.length === 0 ? "gemini provider failed" : `gemini provider failed: ${preview}`; +} + +function assertGeminiPatched(versionText: string): void { + if (process.env["CLAWPATCH_GEMINI_ALLOW_UNPATCHED"] === "1") { + return; + } + const version = parseGeminiVersion(versionText); + if (version === null || !isGeminiPatched(version)) { + throw new ClawpatchError( + `gemini CLI version ${versionText || "unknown"} is not in the patched range for ` + + `GHSA-wpqr-6v78-jr5g; install @google/gemini-cli >=${GEMINI_PATCHED_STABLE} ` + + `or >=${GEMINI_PATCHED_PREVIEW}`, + 4, + "provider-auth", + ); + } +} + +function parseGeminiVersion(versionText: string): string | null { + return versionText.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/u)?.[0] ?? null; +} + +function isGeminiPatched(version: string): boolean { + if (version.includes("-preview.")) { + return compareGeminiVersions(version, GEMINI_PATCHED_PREVIEW) >= 0; + } + return compareGeminiVersions(version, GEMINI_PATCHED_STABLE) >= 0; +} + +function compareGeminiVersions(left: string, right: string): number { + const a = geminiVersionParts(left); + const b = geminiVersionParts(right); + for (let index = 0; index < 4; index += 1) { + const diff = a[index]! - b[index]!; + if (diff !== 0) { + return diff; + } + } + return 0; +} + +function geminiVersionParts(version: string): [number, number, number, number] { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-preview\.(\d+))?/u); + if (match === null) { + return [0, 0, 0, 0]; + } + return [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + match[4] === undefined ? Number.MAX_SAFE_INTEGER : Number(match[4]), + ]; +} + +function requireGeminiTrustedWorkspace(): void { + if (process.env["CLAWPATCH_GEMINI_TRUST_WORKSPACE"] === "true") { + return; + } + throw new ClawpatchError( + "gemini provider requires CLAWPATCH_GEMINI_TRUST_WORKSPACE=true because it runs Gemini CLI with --skip-trust; use only in an isolated trusted checkout", + 2, + "invalid-usage", + ); +} + +function geminiTimeoutMs(): number { + const raw = + process.env["CLAWPATCH_GEMINI_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + if (raw === undefined) { + return GEMINI_DEFAULT_TIMEOUT_MS; + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : GEMINI_DEFAULT_TIMEOUT_MS; +} + async function runPiJson( root: string, prompt: string, @@ -1069,9 +1493,17 @@ export const __testing = { addCodexSandboxArgs, codexFailureMessage, extractAcpxJson, + extractGeminiResponse, + geminiIsolatedEnv, + geminiSelectedAuthType, extractOpencodeJson, + geminiArgs, + geminiEnv, + geminiPrompt, + isGeminiPatched, parseAcpxAgent, parseCodexJson, + parseGeminiVersion, piThinkingLevel, providerJsonSchema, }; From 6f92d7224c7b357cbf8d0fbd964e2b4e8fd71a01 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 14:45:42 -0600 Subject: [PATCH 2/3] fix(provider): harden review parsing paths --- src/prompt.ts | 12 +++++------- src/provider-json.ts | 20 ++++++++++++++++++-- src/provider.test.ts | 30 ++++++++++++++++++++++++++++++ src/provider.ts | 5 +++++ 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/prompt.ts b/src/prompt.ts index e5078a1..628436b 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -117,13 +117,11 @@ export async function buildReviewPromptBundle( ]; const fileBlocks: string[] = []; const includedFiles: ReviewPromptFileManifest[] = []; - for (const ref of owned) { - const file = await fileBlockWithManifest(root, ref.path, "owned"); - fileBlocks.push(file.block); - includedFiles.push(file.manifest); - } - for (const ref of context) { - const file = await fileBlockWithManifest(root, ref.path, "context"); + const files = await Promise.all([ + ...owned.map((ref) => fileBlockWithManifest(root, ref.path, "owned")), + ...context.map((ref) => fileBlockWithManifest(root, ref.path, "context")), + ]); + for (const file of files) { fileBlocks.push(file.block); includedFiles.push(file.manifest); } diff --git a/src/provider-json.ts b/src/provider-json.ts index 41ee1bf..391be53 100644 --- a/src/provider-json.ts +++ b/src/provider-json.ts @@ -1,9 +1,16 @@ import { ClawpatchError } from "./errors.js"; +const MAX_FALLBACK_JSON_SCAN_BYTES = 4 * 1024 * 1024; +const MAX_FALLBACK_JSON_DEPTH = 1_000; +const MAX_FALLBACK_JSON_CANDIDATES = 64; + export function extractJson(text: string): unknown | null { try { return JSON.parse(text); } catch {} + if (Buffer.byteLength(text, "utf8") > MAX_FALLBACK_JSON_SCAN_BYTES) { + return null; + } const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/u); if (fenceMatch && fenceMatch[1]) { const candidate = fenceMatch[1].trim(); @@ -12,7 +19,12 @@ export function extractJson(text: string): unknown | null { } catch {} } let firstBrace = text.indexOf("{"); + let candidates = 0; while (firstBrace !== -1) { + candidates += 1; + if (candidates > MAX_FALLBACK_JSON_CANDIDATES) { + return null; + } let depth = 0; let inString = false; let escape = false; @@ -31,8 +43,12 @@ export function extractJson(text: string): unknown | null { continue; } if (!inString) { - if (ch === "{") depth += 1; - else if (ch === "}") { + if (ch === "{") { + depth += 1; + if (depth > MAX_FALLBACK_JSON_DEPTH) { + return null; + } + } else if (ch === "}") { depth -= 1; if (depth === 0) { const candidate = text.slice(firstBrace, i + 1); diff --git a/src/provider.test.ts b/src/provider.test.ts index 8e60e73..c91bfbc 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -12,6 +12,7 @@ const { addCodexSandboxArgs, addCodexModelArgs, acpxFailureMessage, + assertGeminiPatched, codexFailureMessage, extractAcpxJson, extractGeminiResponse, @@ -122,6 +123,11 @@ describe("extractJson", () => { expect(extractJson("no json here at all")).toBeNull(); expect(extractJson("just some words { unbalanced")).toBeNull(); }); + + it("bounds fallback JSON extraction work", () => { + expect(extractJson(`${"{".repeat(1_001)}${"}".repeat(1_001)}`)).toBeNull(); + expect(extractJson(`${"{ nope } ".repeat(65)}{"ok":true}`)).toBeNull(); + }); }); describe("parseCodexJson", () => { @@ -356,6 +362,30 @@ describe("Gemini provider", () => { expect(isGeminiPatched("0.40.0-preview.2")).toBe(false); }); + it("warns when bypassing the Gemini patched-version gate", () => { + const original = process.env["CLAWPATCH_GEMINI_ALLOW_UNPATCHED"]; + const write = process.stderr.write; + const writes: string[] = []; + process.env["CLAWPATCH_GEMINI_ALLOW_UNPATCHED"] = "1"; + process.stderr.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + assertGeminiPatched("0.1.0"); + } finally { + process.stderr.write = write; + if (original === undefined) { + delete process.env["CLAWPATCH_GEMINI_ALLOW_UNPATCHED"]; + } else { + process.env["CLAWPATCH_GEMINI_ALLOW_UNPATCHED"] = original; + } + } + + expect(writes.join("")).toContain("bypasses the Gemini CLI security version gate"); + }); + it("uses an explicit environment allowlist", () => { process.env["GEMINI_API_KEY"] = "allowed"; process.env["GEMINI_SECRET_TEST"] = "blocked"; diff --git a/src/provider.ts b/src/provider.ts index 931b3ba..c1c195e 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -582,6 +582,10 @@ function geminiFailureMessage(stdout: string, stderr: string): string { function assertGeminiPatched(versionText: string): void { if (process.env["CLAWPATCH_GEMINI_ALLOW_UNPATCHED"] === "1") { + process.stderr.write( + "warning: CLAWPATCH_GEMINI_ALLOW_UNPATCHED=1 bypasses the Gemini CLI " + + "security version gate for GHSA-wpqr-6v78-jr5g; use only for local diagnostics.\n", + ); return; } const version = parseGeminiVersion(versionText); @@ -1500,6 +1504,7 @@ export const __testing = { geminiArgs, geminiEnv, geminiPrompt, + assertGeminiPatched, isGeminiPatched, parseAcpxAgent, parseCodexJson, From c33caf49a67cde20e43699578a748dc402a90878 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Wed, 20 May 2026 08:25:06 -0600 Subject: [PATCH 3/3] fix(provider): align gemini reviews with partitioned output --- src/provider.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/provider.ts b/src/provider.ts index 88f3068..93c84de 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -496,15 +496,19 @@ const geminiProvider: Provider = { }, async map(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runGeminiJson(root, prompt, options, agentMapJsonSchema, "plan"); - return agentMapOutputSchema.parse(output); + return parseOrThrow(agentMapOutputSchema, output, "gemini agent-map"); }, - async review(root: string, prompt: string, options: ProviderOptions): Promise { + async review( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { const output = await runGeminiJson(root, prompt, options, reviewJsonSchema, "plan"); - return reviewOutputSchema.parse(output); + return parseReviewOutput(output); }, async fix(root: string, prompt: string, options: ProviderOptions): Promise { const output = await runGeminiJson(root, prompt, options, fixPlanJsonSchema, "auto_edit"); - return fixPlanOutputSchema.parse(output); + return parseOrThrow(fixPlanOutputSchema, output, "gemini fix-plan"); }, async revalidate( root: string, @@ -512,7 +516,7 @@ const geminiProvider: Provider = { options: ProviderOptions, ): Promise { const output = await runGeminiJson(root, prompt, options, revalidateJsonSchema, "plan"); - return revalidateOutputSchema.parse(output); + return parseOrThrow(revalidateOutputSchema, output, "gemini revalidate"); }, };