From 755186db3c638bd22952a59641a1b751bf7001b0 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 13:41:03 -0600 Subject: [PATCH 1/3] feat(provider): add claude cli provider --- docs/providers.md | 62 +++++++ docs/safety.md | 9 +- src/exec.ts | 14 +- src/provider.test.ts | 268 ++++++++++++++++++++++++++++ src/provider.ts | 415 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 762 insertions(+), 6 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 9453888..e7c39d8 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` +- `claude`: shells out to Claude Code in print mode (`claude -p`) - `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,67 @@ 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`. +## Claude + +The `claude` provider shells out to the local +[Claude Code CLI](https://code.claude.com/docs/en/cli-usage) in non-interactive +print mode. + +Install Claude Code and authenticate with an Anthropic API key: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +claude --version +``` + +Provider selection: + +```bash +clawpatch review --provider claude +CLAWPATCH_PROVIDER=claude clawpatch review +clawpatch fix --finding --provider claude +clawpatch doctor --provider claude +``` + +For low-cost smoke checks, pass a smaller model explicitly: + +```bash +clawpatch review --provider claude --model claude-haiku-4-5-20251001 --limit 1 +``` + +How the Claude provider works: + +- Doctor: `clawpatch doctor --provider claude` only checks that the Claude Code + binary is available, reads `claude --version`, and blocks known vulnerable + versions. It does not validate auth or make a network call; auth failures are + reported on the first provider-backed command. +- Auth/isolation: provider runs use `--bare` with a default-deny environment. + Clawpatch forwards only minimal execution variables and `ANTHROPIC_API_KEY`; + it does not pass host `HOME`, OAuth/keychain state, or whole Claude + config/cache directories. +- Structured output: provider runs use `--output-format json --json-schema` + and parse the returned `structured_output` field. +- Read-only operations (map, review, revalidate): use + `--tools "Read,Grep,Glob" --permission-mode dontAsk`. +- Write operation (fix): uses Claude's default tool set with + `--permission-mode acceptEdits`. Clawpatch still relies on its existing clean + worktree preflight before `fix`. +- Ambient config isolation: runs add `--strict-mcp-config` with an empty MCP + configuration, `--disable-slash-commands`, and `--no-chrome`. +- Model selection: `--model ` is passed through to Claude. +- Reasoning effort: `low`, `medium`, `high`, and `xhigh` are passed as + `--effort`. Clawpatch `minimal` maps to Claude `low`; Clawpatch `none` is + treated as no override because Claude does not accept `--effort none`. +- `skipGitRepoCheck`: Claude has no equivalent flag, so this option is a no-op + for the Claude provider. +- Timeout: 180 seconds by default, override with `CLAWPATCH_CLAUDE_TIMEOUT_MS` + or `CLAWPATCH_PROVIDER_TIMEOUT_MS`. + +Permission caveat: Claude tool restrictions are enforced by Claude Code. For +write operations during `fix`, Claude may edit the current worktree. For +untrusted code, run `clawpatch fix --provider claude` inside 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..d2c8a23 100644 --- a/docs/safety.md +++ b/docs/safety.md @@ -13,10 +13,11 @@ 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. The `claude` provider restricts available tools to read/search tools + for read-only operations. 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 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..1c73747 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; + replaceEnv?: 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.replaceEnv === true + ? 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..5224212 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -8,11 +8,21 @@ import { revalidateOutputSchema, reviewOutputSchema } from "./types.js"; const { addCodexSandboxArgs, addCodexModelArgs, + addClaudeModelArgs, acpxFailureMessage, + assertClaudeVersionAllowed, + claudeArgs, + claudeEffort, + claudeEnv, + claudeExitCode, + claudeFailureMessage, + claudeTimeoutMs, codexFailureMessage, extractAcpxJson, + extractClaudeStructuredOutput, extractOpencodeJson, parseAcpxAgent, + parseClaudeVersion, parseCodexJson, piThinkingLevel, providerJsonSchema, @@ -248,6 +258,263 @@ describe("piThinkingLevel", () => { }); }); +describe("Claude provider helpers", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("builds read-only structured-output args with isolation flags", () => { + const args = claudeArgs( + { type: "object" }, + { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + true, + ); + + expect(args).toEqual([ + "-p", + "--output-format", + "json", + "--json-schema", + '{"type":"object"}', + "--tools", + "Read,Grep,Glob", + "--permission-mode", + "dontAsk", + "--no-session-persistence", + "--bare", + "--strict-mcp-config", + "--mcp-config", + '{"mcpServers":{}}', + "--disable-slash-commands", + "--no-chrome", + ]); + }); + + it("builds write-capable fix args only for non-read-only operations", () => { + const args = claudeArgs( + { type: "object" }, + { model: null, reasoningEffort: null, skipGitRepoCheck: false }, + false, + ); + + expect(args).toContain("default"); + expect(args).toContain("acceptEdits"); + expect(args).not.toContain("Read,Grep,Glob"); + expect(args).not.toContain("dontAsk"); + }); + + it("passes model and supported effort while ignoring skipGitRepoCheck", () => { + const args = ["-p"]; + + addClaudeModelArgs(args, { + model: "sonnet", + reasoningEffort: "xhigh", + skipGitRepoCheck: true, + }); + + expect(args).toEqual(["-p", "--model", "sonnet", "--effort", "xhigh"]); + }); + + it("maps minimal to low and none to no effort flag", () => { + expect(claudeEffort("minimal")).toBe("low"); + + const args = ["-p"]; + addClaudeModelArgs(args, { model: null, reasoningEffort: "none", skipGitRepoCheck: false }); + + expect(args).toEqual(["-p"]); + }); + + it("uses a default-deny env allowlist with optional API key", () => { + process.env = { + PATH: "/bin", + HOME: "/secret-home", + ANTHROPIC_API_KEY: "secret", + OPENAI_API_KEY: "must-not-leak", + CLAUDE_CODE_OAUTH_TOKEN: "must-not-leak", + }; + + expect(claudeEnv(false, "/tmp/claude")).toEqual({ + PATH: "/bin", + HOME: "/tmp/claude/home", + XDG_CONFIG_HOME: "/tmp/claude/xdg-config", + XDG_CACHE_HOME: "/tmp/claude/xdg-cache", + XDG_DATA_HOME: "/tmp/claude/xdg-data", + TMPDIR: "/tmp/claude", + TEMP: "/tmp/claude", + TMP: "/tmp/claude", + }); + expect(claudeEnv(true, "/tmp/claude")).toEqual({ + PATH: "/bin", + HOME: "/tmp/claude/home", + XDG_CONFIG_HOME: "/tmp/claude/xdg-config", + XDG_CACHE_HOME: "/tmp/claude/xdg-cache", + XDG_DATA_HOME: "/tmp/claude/xdg-data", + TMPDIR: "/tmp/claude", + TEMP: "/tmp/claude", + TMP: "/tmp/claude", + ANTHROPIC_API_KEY: "secret", + }); + }); + + it("preserves a Windows-style Path variable in the Claude env allowlist", () => { + process.env = { + Path: "C:\\Tools", + ANTHROPIC_API_KEY: "secret", + }; + + expect(claudeEnv(true, "C:\\Temp\\claude")).toMatchObject({ + Path: "C:\\Tools", + ANTHROPIC_API_KEY: "secret", + }); + expect(claudeEnv(true, "C:\\Temp\\claude")).not.toHaveProperty("PATH"); + }); + + it("extracts structured_output from Claude JSON envelopes", () => { + const stdout = JSON.stringify({ + type: "result", + subtype: "success", + result: "done", + structured_output: { findings: [], inspected: { files: [], symbols: [], notes: [] } }, + }); + + expect(extractClaudeStructuredOutput(stdout)).toEqual({ + findings: [], + inspected: { files: [], symbols: [], notes: [] }, + }); + }); + + it("extracts structured_output when prose surrounds the JSON envelope", () => { + const stdout = + "leading text\n" + + JSON.stringify({ type: "result", structured_output: { outcome: "fixed" } }) + + "\ntrailing text"; + + expect(extractClaudeStructuredOutput(stdout)).toEqual({ outcome: "fixed" }); + }); + + it("uses the first JSON envelope with structured_output when multiple objects appear", () => { + const stdout = [ + JSON.stringify({ note: "ignore" }), + JSON.stringify({ structured_output: { ok: true } }), + JSON.stringify({ structured_output: { ok: false } }), + ].join("\n"); + + expect(extractClaudeStructuredOutput(stdout)).toEqual({ ok: true }); + }); + + it("throws malformed-output for empty or malformed Claude output", () => { + expectMalformed(() => extractClaudeStructuredOutput(""), /claude provider produced no output/u); + expectMalformed( + () => extractClaudeStructuredOutput("not json"), + /claude provider produced no JSON envelope/u, + ); + expectMalformed( + () => extractClaudeStructuredOutput(JSON.stringify({ result: "{}" })), + /missing structured_output/u, + ); + expectMalformed( + () => extractClaudeStructuredOutput(JSON.stringify({ structured_output: "nope" })), + /structured_output is not an object/u, + ); + }); + + it("turns Claude error envelopes into provider failures", () => { + try { + extractClaudeStructuredOutput(JSON.stringify({ error: { type: "authentication_failed" } })); + } catch (err) { + expect(err).toBeInstanceOf(ClawpatchError); + expect((err as ClawpatchError).exitCode).toBe(4); + expect((err as ClawpatchError).code).toBe("provider-failure"); + return; + } + throw new Error("expected Claude provider failure"); + }); + + it("does not include stdout or prompt previews in Claude failure messages", () => { + const message = claudeFailureMessage("SOURCE_CONTEXT_SECRET", "SOURCE_CONTEXT_SECRET", 1); + + expect(message).toBe("claude provider failed"); + expect(message).not.toContain("SOURCE_CONTEXT_SECRET"); + }); + + it("classifies Claude stderr failures without leaking stderr text", () => { + const auth = claudeFailureMessage("", "authentication failed for SOURCE_CONTEXT_SECRET", 1); + const quota = claudeFailureMessage("", "rate limit exceeded for SOURCE_CONTEXT_SECRET", 1); + + expect(auth).toBe("claude provider auth/config failed"); + expect(quota).toBe("claude provider quota/rate-limit failed"); + expect(auth).not.toContain("SOURCE_CONTEXT_SECRET"); + expect(quota).not.toContain("SOURCE_CONTEXT_SECRET"); + }); + + it("uses redacted Claude stdout envelope signals for nonzero failures", () => { + const stdout = JSON.stringify({ + type: "result", + subtype: "error_during_execution", + api_error_status: 401, + error: { type: "authentication_failed", message: "SOURCE_CONTEXT_SECRET" }, + result: "SOURCE_CONTEXT_SECRET", + }); + + const message = claudeFailureMessage(stdout, "", 1); + + expect(message).toBe("claude provider auth/config failed"); + expect(message).not.toContain("SOURCE_CONTEXT_SECRET"); + expect(claudeExitCode(stdout, "", 1)).toBe(4); + }); + + it("omits Claude error.message from stdout failure signals", () => { + const stdout = JSON.stringify({ + type: "result", + subtype: "error_during_execution", + error: { code: "invalid_request", message: "SOURCE_CONTEXT_SECRET" }, + result: "SOURCE_CONTEXT_SECRET", + }); + + const message = claudeFailureMessage(stdout, "", 1); + + expect(message).toContain("error=invalid_request"); + expect(message).not.toContain("SOURCE_CONTEXT_SECRET"); + }); + + it("classifies Claude provider failures by exit convention", () => { + expect(claudeExitCode("", "authentication failed", 1)).toBe(4); + expect(claudeExitCode("", "rate limit exceeded", 1)).toBe(5); + expect(claudeExitCode("", "command timed out after 1ms", 124)).toBe(1); + expect(claudeExitCode("", "other", 1)).toBe(1); + }); + + it("parses Claude versions and blocks verified vulnerable ranges", () => { + expect(parseClaudeVersion("2.1.144 (Claude Code)")).toEqual([2, 1, 144]); + expect(parseClaudeVersion("not a version")).toBeNull(); + + expect(() => assertClaudeVersionAllowed("2.1.52 (Claude Code)")).toThrow(/blocked/u); + expect(() => assertClaudeVersionAllowed("2.1.63 (Claude Code)")).toThrow(/blocked/u); + expect(() => assertClaudeVersionAllowed("2.1.83 (Claude Code)")).toThrow(/blocked/u); + expect(() => assertClaudeVersionAllowed("2.1.53 (Claude Code)")).not.toThrow(); + expect(() => assertClaudeVersionAllowed("2.1.84 (Claude Code)")).not.toThrow(); + expect(() => assertClaudeVersionAllowed("2.1.144 (Claude Code)")).not.toThrow(); + expect(() => assertClaudeVersionAllowed("unknown")).not.toThrow(); + }); + + it("uses Claude-specific timeout before generic provider timeout", () => { + delete process.env["CLAWPATCH_CLAUDE_TIMEOUT_MS"]; + delete process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + expect(claudeTimeoutMs()).toBe(180_000); + + process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"] = "2000"; + expect(claudeTimeoutMs()).toBe(2000); + + process.env["CLAWPATCH_CLAUDE_TIMEOUT_MS"] = "3000"; + expect(claudeTimeoutMs()).toBe(3000); + + process.env["CLAWPATCH_CLAUDE_TIMEOUT_MS"] = "bad"; + expect(claudeTimeoutMs()).toBe(180_000); + }); +}); + function schemaKeys(value: unknown): string[] { if (Array.isArray(value)) { return value.flatMap(schemaKeys); @@ -558,6 +825,7 @@ describe("extractOpencodeJson", () => { describe("providerByName", () => { it("returns provider instances for optional CLI-backed providers", () => { expect(providerByName("acpx").name).toBe("acpx"); + expect(providerByName("claude").name).toBe("claude"); 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..7719049 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -55,6 +55,9 @@ export function providerByName(name: string): Provider { if (name === "pi") { return piProvider; } + if (name === "claude") { + return claudeProvider; + } if (name === "mock") { return mockProvider; } @@ -229,6 +232,408 @@ const piProvider: Provider = { }, }; +const CLAUDE_DEFAULT_TIMEOUT_MS = 180_000; +const CLAUDE_READ_ONLY_TOOLS = "Read,Grep,Glob"; +const CLAUDE_WRITE_TOOLS = "default"; + +const claudeProvider: Provider = { + name: "claude", + async check(root: string): Promise { + const result = await runClaudeCommand(["--version"], root, undefined, { + includeAuth: false, + timeoutMs: claudeTimeoutMs(), + }); + if (result.exitCode !== 0) { + throw new ClawpatchError("claude CLI not available", 4, "provider-auth"); + } + const version = result.stdout.trim() || result.stderr.trim(); + assertClaudeVersionAllowed(version); + return version; + }, + async map(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runClaudeJson(root, prompt, options, agentMapJsonSchema, true); + return agentMapOutputSchema.parse(output); + }, + async review(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runClaudeJson(root, prompt, options, reviewJsonSchema, true); + return reviewOutputSchema.parse(output); + }, + async fix(root: string, prompt: string, options: ProviderOptions): Promise { + const output = await runClaudeJson(root, prompt, options, fixPlanJsonSchema, false); + return fixPlanOutputSchema.parse(output); + }, + async revalidate( + root: string, + prompt: string, + options: ProviderOptions, + ): Promise { + const output = await runClaudeJson(root, prompt, options, revalidateJsonSchema, true); + return revalidateOutputSchema.parse(output); + }, +}; + +async function runClaudeJson( + root: string, + prompt: string, + options: ProviderOptions, + schema: object, + readOnly: boolean, +): Promise { + const version = await claudeVersion(root); + assertClaudeVersionAllowed(version); + const args = claudeArgs(schema, options, readOnly); + const result = await runClaudeCommand(args, root, prompt, { + includeAuth: true, + timeoutMs: claudeTimeoutMs(), + }); + if (result.exitCode !== 0) { + throw new ClawpatchError( + claudeFailureMessage(result.stdout, result.stderr, result.exitCode), + claudeExitCode(result.stdout, result.stderr, result.exitCode), + "provider-failure", + ); + } + return extractClaudeStructuredOutput(result.stdout); +} + +async function claudeVersion(root: string): Promise { + const result = await runClaudeCommand(["--version"], root, undefined, { + includeAuth: false, + timeoutMs: claudeTimeoutMs(), + }); + if (result.exitCode !== 0) { + throw new ClawpatchError("claude CLI not available", 4, "provider-auth"); + } + return result.stdout.trim() || result.stderr.trim(); +} + +function claudeArgs(schema: object, options: ProviderOptions, readOnly: boolean): string[] { + const args = [ + "-p", + "--output-format", + "json", + "--json-schema", + JSON.stringify(schema), + "--tools", + readOnly ? CLAUDE_READ_ONLY_TOOLS : CLAUDE_WRITE_TOOLS, + "--permission-mode", + readOnly ? "dontAsk" : "acceptEdits", + "--no-session-persistence", + "--bare", + "--strict-mcp-config", + "--mcp-config", + JSON.stringify({ mcpServers: {} }), + "--disable-slash-commands", + "--no-chrome", + ]; + addClaudeModelArgs(args, options); + return args; +} + +function addClaudeModelArgs(args: string[], options: ProviderOptions): void { + if (options.model !== null) { + args.push("--model", options.model); + } + if (options.reasoningEffort !== null && options.reasoningEffort !== "none") { + args.push("--effort", claudeEffort(options.reasoningEffort)); + } +} + +function claudeEffort(reasoningEffort: ReasoningEffort): string { + if (reasoningEffort === "minimal") { + return "low"; + } + return reasoningEffort; +} + +async function runClaudeCommand( + args: string[], + root: string, + input: string | undefined, + options: { includeAuth: boolean; timeoutMs: number }, +): Promise>> { + const dir = await mkdtemp(join(tmpdir(), "clawpatch-claude-")); + try { + const env = claudeEnv(options.includeAuth, dir); + return await runCommandArgs(claudeExecutable(), args, root, input, { + trimOutput: false, + timeoutMs: options.timeoutMs, + env, + replaceEnv: true, + }); + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +function claudeEnv(includeAuth: boolean, baseDir: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + copyPathEnv(env); + copyEnv(env, "SystemRoot"); + copyEnv(env, "ComSpec"); + copyEnv(env, "PATHEXT"); + env["HOME"] = join(baseDir, "home"); + env["XDG_CONFIG_HOME"] = join(baseDir, "xdg-config"); + env["XDG_CACHE_HOME"] = join(baseDir, "xdg-cache"); + env["XDG_DATA_HOME"] = join(baseDir, "xdg-data"); + env["TMPDIR"] = baseDir; + env["TEMP"] = baseDir; + env["TMP"] = baseDir; + if (includeAuth) { + copyEnv(env, "ANTHROPIC_API_KEY"); + } + return env; +} + +function copyPathEnv(target: NodeJS.ProcessEnv): void { + for (const key of Object.keys(process.env)) { + if (key.toLowerCase() === "path") { + copyEnv(target, key); + return; + } + } +} + +function copyEnv(target: NodeJS.ProcessEnv, key: string): void { + const value = process.env[key]; + if (value !== undefined && value.length > 0) { + target[key] = value; + } +} + +function claudeExecutable(): string { + const configured = process.env["CLAWPATCH_CLAUDE_BIN"]?.trim(); + return configured && configured.length > 0 ? configured : "claude"; +} + +function extractClaudeStructuredOutput(stdout: string): unknown { + const text = stdout.trim(); + if (text.length === 0) { + throw new ClawpatchError("claude provider produced no output", 8, "malformed-output"); + } + const envelopes = extractJsonObjects(text); + if (envelopes.length === 0) { + throw new ClawpatchError("claude provider produced no JSON envelope", 8, "malformed-output"); + } + for (const envelope of envelopes) { + const structured = claudeStructuredOutput(envelope); + if (structured.found) { + return structured.value; + } + } + throw new ClawpatchError( + "claude provider JSON envelope is missing structured_output", + 8, + "malformed-output", + ); +} + +function claudeStructuredOutput(value: unknown): { found: boolean; value: unknown } { + if (typeof value !== "object" || value === null) { + return { found: false, value: undefined }; + } + const record = value as Record; + if (Object.hasOwn(record, "error")) { + const message = claudeEnvelopeErrorCode(record["error"]) ?? "provider-error"; + throw new ClawpatchError( + `claude provider error: ${message}`, + claudeExitCode("", message, 1), + "provider-failure", + ); + } + if (Object.hasOwn(record, "structured_output")) { + const output = record["structured_output"]; + if (typeof output !== "object" || output === null) { + throw new ClawpatchError( + "claude provider structured_output is not an object", + 8, + "malformed-output", + ); + } + return { found: true, value: output }; + } + return { found: false, value: undefined }; +} + +function claudeEnvelopeErrorCode(error: unknown): string | null { + if (typeof error === "string") { + return null; + } + if (typeof error !== "object" || error === null) { + return null; + } + const record = error as Record; + for (const key of ["type", "code"]) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return safeProviderPreview(value); + } else if (typeof value === "number") { + return String(value); + } + } + return null; +} + +function extractJsonObjects(text: string): unknown[] { + const direct = parseJsonObject(text); + if (direct.found) { + return [direct.value]; + } + const outputs: unknown[] = []; + let firstBrace = text.indexOf("{"); + while (firstBrace !== -1) { + let depth = 0; + let inString = false; + let escape = false; + let foundEnd = false; + for (let index = firstBrace; index < text.length; index += 1) { + const ch = text[index]; + if (escape) { + escape = false; + continue; + } + if (ch === "\\") { + escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (ch === "{") { + depth += 1; + } else if (ch === "}") { + depth -= 1; + if (depth === 0) { + const candidate = text.slice(firstBrace, index + 1); + const parsed = parseJsonObject(candidate); + if (parsed.found) { + outputs.push(parsed.value); + } + firstBrace = text.indexOf("{", index + 1); + foundEnd = true; + break; + } + } + } + if (!foundEnd) { + break; + } + } + return outputs; +} + +function parseJsonObject(text: string): { found: boolean; value: unknown } { + try { + const parsed = JSON.parse(text) as unknown; + return typeof parsed === "object" && parsed !== null + ? { found: true, value: parsed } + : { found: false, value: undefined }; + } catch { + return { found: false, value: undefined }; + } +} + +function claudeFailureMessage(stdout: string, stderr: string, exitCode: number | null): string { + if (exitCode === 124 || /timed out/iu.test(stderr)) { + return "claude provider timed out"; + } + const combined = `${stderr}\n${claudeFailureSignal(stdout)}`; + if (/auth|login|api key|unauthorized|authentication|oauth|not authenticated/iu.test(combined)) { + return "claude provider auth/config failed"; + } + if (/quota|rate.?limit|billing|credit/iu.test(combined)) { + return "claude provider quota/rate-limit failed"; + } + const signal = claudeFailureSignal(stdout); + return signal.length === 0 ? "claude provider failed" : `claude provider failed: ${signal}`; +} + +function claudeExitCode(stdout: string, stderr: string, exitCode: number | null): number { + const combined = `${stderr}\n${claudeFailureSignal(stdout)}`; + if (/auth|login|api key|unauthorized|authentication|oauth|not authenticated/iu.test(combined)) { + return 4; + } + if (/quota|rate.?limit|billing|credit/iu.test(combined)) { + return 5; + } + if (exitCode === 124 || /timed out/iu.test(combined)) { + return 1; + } + return 1; +} + +function claudeFailureSignal(stdout: string): string { + const parts: string[] = []; + for (const envelope of extractJsonObjects(stdout)) { + if (typeof envelope !== "object" || envelope === null) { + continue; + } + const record = envelope as Record; + const errorCode = claudeEnvelopeErrorCode(record["error"]); + if (errorCode !== null) { + parts.push(`error=${errorCode}`); + } + for (const key of ["type", "subtype", "api_error_status", "terminal_reason"]) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + parts.push(`${key}=${safeProviderPreview(value, 80)}`); + } else if (typeof value === "number") { + parts.push(`${key}=${value}`); + } + } + } + return parts.filter((part) => part.length > 0).join("; "); +} + +function assertClaudeVersionAllowed(raw: string): void { + const parsed = parseClaudeVersion(raw); + if (parsed === null) { + return; + } + if ( + compareSemver(parsed, [2, 1, 53]) < 0 || + (compareSemver(parsed, [2, 1, 63]) >= 0 && compareSemver(parsed, [2, 1, 84]) < 0) + ) { + throw new ClawpatchError( + `claude CLI version ${parsed.join(".")} is blocked by known security advisories; upgrade Claude Code to 2.1.84 or newer`, + 4, + "provider-auth", + ); + } +} + +function parseClaudeVersion(raw: string): [number, number, number] | null { + const match = raw.match(/(\d+)\.(\d+)\.(\d+)/u); + if (match === null) { + return null; + } + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compareSemver(left: [number, number, number], right: [number, number, number]): number { + for (let index = 0; index < 3; index += 1) { + const delta = left[index]! - right[index]!; + if (delta !== 0) { + return delta; + } + } + return 0; +} + +function claudeTimeoutMs(): number { + const raw = + process.env["CLAWPATCH_CLAUDE_TIMEOUT_MS"] ?? process.env["CLAWPATCH_PROVIDER_TIMEOUT_MS"]; + if (raw === undefined) { + return CLAUDE_DEFAULT_TIMEOUT_MS; + } + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : CLAUDE_DEFAULT_TIMEOUT_MS; +} + async function runPiJson( root: string, prompt: string, @@ -1067,9 +1472,19 @@ export const __testing = { acpxFailureMessage, addCodexModelArgs, addCodexSandboxArgs, + addClaudeModelArgs, + assertClaudeVersionAllowed, + claudeArgs, + claudeEffort, + claudeEnv, + claudeExitCode, + claudeFailureMessage, + claudeTimeoutMs, codexFailureMessage, extractAcpxJson, + extractClaudeStructuredOutput, extractOpencodeJson, + parseClaudeVersion, parseAcpxAgent, parseCodexJson, piThinkingLevel, From ef23aec3528e37f51fbe5e6a727b55f198a3d26b Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 14:10:28 -0600 Subject: [PATCH 2/3] fix(exec): guard shell command settlement --- src/exec.test.ts | 36 +++++++++++++++++++++++++++++++++++- src/exec.ts | 12 ++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/exec.test.ts b/src/exec.test.ts index f8270fc..66b084e 100644 --- a/src/exec.test.ts +++ b/src/exec.test.ts @@ -2,7 +2,41 @@ import { access, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { runCommandArgs } from "./exec.js"; +import { runCommand, runCommandArgs } from "./exec.js"; + +describe("runCommand", () => { + it("runs a shell command and passes stdin", async () => { + const dir = await mkdtemp(join(tmpdir(), "clawpatch-exec-shell-")); + const script = join(dir, "stdin.mjs"); + await writeFile( + script, + "process.stdin.setEncoding('utf8'); let input = ''; process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => process.stdout.write(input.toUpperCase()));", + "utf8", + ); + + const result = await runCommand( + `${JSON.stringify(process.execPath)} ${JSON.stringify(script)}`, + dir, + "ok", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("OK"); + }); + + it("trims large output by default and can preserve raw output", async () => { + const dir = await mkdtemp(join(tmpdir(), "clawpatch-exec-shell-")); + const script = join(dir, "large-output.mjs"); + await writeFile(script, "process.stdout.write('x'.repeat(9000));", "utf8"); + const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(script)}`; + + const trimmed = await runCommand(command, dir); + const raw = await runCommand(command, dir, undefined, { trimOutput: false }); + + expect(trimmed.stdout).toContain("...[trimmed]..."); + expect(raw.stdout).toHaveLength(9000); + }); +}); describe("runCommandArgs", () => { it("passes paths with spaces and quotes without shell quoting", async () => { diff --git a/src/exec.ts b/src/exec.ts index 1c73747..1f35a4c 100644 --- a/src/exec.ts +++ b/src/exec.ts @@ -40,11 +40,19 @@ export async function runCommandRaw( }); let spawnErrorMessage: string | null = null; const exitCodePromise = new Promise((resolve) => { + let settled = false; + const finish = (code: number | null): void => { + if (settled) { + return; + } + settled = true; + resolve(code); + }; child.on("error", (error: Error) => { spawnErrorMessage = error.message; - resolve(127); + finish(127); }); - child.on("close", resolve); + child.on("close", finish); }); endChildStdin(child, input); const exitCode = await exitCodePromise; From 8e1a10989cdc475d2c9990879865fcfa398e0b35 Mon Sep 17 00:00:00 2001 From: Hunter Sadler Date: Tue, 19 May 2026 14:34:29 -0600 Subject: [PATCH 3/3] docs(readme): list claude provider --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b22269f..46a540b 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,10 @@ Supported provider names today: - `codex`: local Codex CLI - `acpx`: any ACP-compatible coding agent (Codex / Claude / Pi / Gemini / ...) via openclaw/acpx +- `claude`: local Claude Code CLI in print mode - `grok`: local Grok Build CLI - `opencode`: local OpenCode CLI +- `pi`: local Pi coding agent in print mode - `mock`: deterministic test provider - `mock-fail`: failure test provider