From d4a3e3e158b9ad48fb6fa4d0da8d32a585990ede Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp Date: Fri, 22 May 2026 15:38:52 -0400 Subject: [PATCH 1/2] fix(cli): pass granular Claude SDK runtime options Mirror the codex env-var pattern (PROSE_CODEX_SANDBOX_MODE, PROSE_CODEX_APPROVAL_POLICY, ...) on the claude-sdk side so non- interactive harness runs can widen the SDK's default permission gate. Background. PR #70 set settingSources: ["user", "project"] so the SDK reads ~/.claude and /.claude, but permissionMode is a query-level option that settings.json does not flow into. With the wrapper never passing permissionMode, the SDK falls back to "default" and prompts for explicit Write approval on every tool use -- making claude-sdk unrunnable in CI, conformance benchmarks, or any other context that cannot answer prompts. codex-sdk got env-var passthrough for the equivalent knobs; claude-sdk did not. Adds tools/cli/src/harnesses/claude-options.ts with claudeRuntimeOptions(env) reading PROSE_CLAUDE_PERMISSION_MODE and validating against {default, acceptEdits, bypassPermissions, plan}. claude-sdk.ts spreads the result into the query() options. Tests (vitest): - forwards PROSE_CLAUDE_PERMISSION_MODE to the SDK as permissionMode - omits permissionMode when the env var is unset - rejects invalid values with the same error shape as codex-options README documents the new env var alongside the codex section. --- tools/cli/README.md | 7 +++ tools/cli/src/harnesses/claude-options.ts | 30 +++++++++ tools/cli/src/harnesses/claude-sdk.ts | 3 + tools/cli/tests/harnesses/harnesses.test.ts | 70 +++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 tools/cli/src/harnesses/claude-options.ts diff --git a/tools/cli/README.md b/tools/cli/README.md index 04e2e58a..72a9f244 100644 --- a/tools/cli/README.md +++ b/tools/cli/README.md @@ -106,6 +106,13 @@ Codex harnesses also honor `PROSE_CODEX_ADD_DIR` as a comma-separated list of additional writable directories and `PROSE_CODEX_NETWORK` (`true` or `false`) for outbound network access. +For externally sandboxed or non-interactive runs, the `claude-sdk` harness +honors `PROSE_CLAUDE_PERMISSION_MODE` (`default`, `acceptEdits`, +`bypassPermissions`, or `plan`) and forwards it to the Claude Agent SDK as +`permissionMode`. The SDK defaults to `default` (prompt for tool use) when the +variable is unset, which is appropriate for interactive sessions but blocks +automated runs that cannot answer permission prompts. + ## Skill Setup OpenProse execution depends on the `open-prose` agent skill. Before running a diff --git a/tools/cli/src/harnesses/claude-options.ts b/tools/cli/src/harnesses/claude-options.ts new file mode 100644 index 00000000..29d1b447 --- /dev/null +++ b/tools/cli/src/harnesses/claude-options.ts @@ -0,0 +1,30 @@ +const CLAUDE_PERMISSION_MODES = ["default", "acceptEdits", "bypassPermissions", "plan"] as const; + +export type ClaudePermissionMode = (typeof CLAUDE_PERMISSION_MODES)[number]; + +export function claudeRuntimeOptions( + env: Record | undefined, +): { permissionMode?: ClaudePermissionMode } { + const permissionMode = claudeEnvOption("PROSE_CLAUDE_PERMISSION_MODE", CLAUDE_PERMISSION_MODES, env); + + return { + ...(permissionMode === undefined ? {} : { permissionMode }), + }; +} + +function claudeEnvOption( + name: string, + allowedValues: T, + env: Record | undefined, +): T[number] | undefined { + const value = env?.[name] ?? process.env[name]; + if (value === undefined || value === "") { + return undefined; + } + + if (allowedValues.includes(value)) { + return value; + } + + throw new Error(`${name} must be one of: ${allowedValues.join(", ")}`); +} diff --git a/tools/cli/src/harnesses/claude-sdk.ts b/tools/cli/src/harnesses/claude-sdk.ts index 15ad76d2..59f1718a 100644 --- a/tools/cli/src/harnesses/claude-sdk.ts +++ b/tools/cli/src/harnesses/claude-sdk.ts @@ -1,3 +1,4 @@ +import { claudeRuntimeOptions } from "./claude-options.js"; import { writeLine } from "./streams.js"; import type { Harness, HarnessRunOptions } from "./types.js"; @@ -22,6 +23,7 @@ export function createClaudeSdkHarness(options: ClaudeSdkHarnessOptions = {}): H name: "claude-sdk", async run(prompt, runOptions) { const abortController = bridgeAbortController(runOptions.signal); + const runtimeOptions = claudeRuntimeOptions(runOptions.env); const stream = await Promise.resolve( query({ prompt, @@ -33,6 +35,7 @@ export function createClaudeSdkHarness(options: ClaudeSdkHarnessOptions = {}): H ...(runOptions.cwd === undefined ? {} : { cwd: runOptions.cwd }), ...(runOptions.env === undefined ? {} : { env: runOptions.env }), includePartialMessages: true, + ...runtimeOptions, settingSources: ["user", "project"], stderr: (chunk: string) => runOptions.stderr.write(chunk), ...(runOptions.systemPromptAppend === undefined diff --git a/tools/cli/tests/harnesses/harnesses.test.ts b/tools/cli/tests/harnesses/harnesses.test.ts index d34e1100..7ce94b90 100644 --- a/tools/cli/tests/harnesses/harnesses.test.ts +++ b/tools/cli/tests/harnesses/harnesses.test.ts @@ -305,6 +305,76 @@ describe("claude-sdk harness", () => { expect(io.stderr).toBe("bad\n"); }); + test("forwards PROSE_CLAUDE_PERMISSION_MODE to the SDK as permissionMode", async () => { + const io = memoryStreams(); + const calls: unknown[] = []; + const harness = createClaudeSdkHarness({ + query: async (args) => { + calls.push(args); + return { + async *[Symbol.asyncIterator]() { + yield { type: "result", subtype: "success", result: "ok", is_error: false }; + }, + close() {}, + } as never; + }, + }); + + const exitCode = await harness.run("prose run inspector.prose.md", { + ...io.options, + env: { PROSE_CLAUDE_PERMISSION_MODE: "bypassPermissions" }, + }); + + expect(exitCode).toBe(0); + expect(calls).toEqual([ + expect.objectContaining({ + options: expect.objectContaining({ + permissionMode: "bypassPermissions", + }), + }), + ]); + }); + + test("omits permissionMode when PROSE_CLAUDE_PERMISSION_MODE is unset", async () => { + const io = memoryStreams(); + const calls: unknown[] = []; + const harness = createClaudeSdkHarness({ + query: async (args) => { + calls.push(args); + return { + async *[Symbol.asyncIterator]() { + yield { type: "result", subtype: "success", result: "ok", is_error: false }; + }, + close() {}, + } as never; + }, + }); + + await harness.run("prose status", { ...io.options }); + + expect(calls).toHaveLength(1); + const call = calls[0] as { options: Record }; + expect(call.options).not.toHaveProperty("permissionMode"); + }); + + test("rejects invalid PROSE_CLAUDE_PERMISSION_MODE", async () => { + const io = memoryStreams(); + const harness = createClaudeSdkHarness({ + query: async () => { + throw new Error("unexpected query"); + }, + }); + + await expect( + harness.run("prose run inspector.prose.md", { + ...io.options, + env: { PROSE_CLAUDE_PERMISSION_MODE: "yolo" }, + }), + ).rejects.toThrow( + "PROSE_CLAUDE_PERMISSION_MODE must be one of: default, acceptEdits, bypassPermissions, plan", + ); + }); + test("always forwards settingSources: ['user', 'project']", async () => { const io = memoryStreams(); const calls: unknown[] = []; From d312c7f31329beda32e091f155d710bba2615bbe Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp Date: Fri, 22 May 2026 16:34:45 -0400 Subject: [PATCH 2/2] fix(cli): address audit feedback for claude-sdk permissionMode Three should-fix items surfaced by an independent audit against CONTRIBUTING.md: - CHANGELOG: add [Unreleased] entry naming PROSE_CLAUDE_PERMISSION_MODE so the env var surfaces in release notes (codex env vars missed this in their original PR; not repeating the omission here). - claude-options.ts: tighten return type to Pick against the Claude SDK's own query() options type. If the SDK ever changes its permissionMode union (adds/removes a mode), this surfaces at compile time instead of silently drifting. Mirrors codex-options.ts's Pick shape. - harnesses.test.ts: pass explicit `env: {}` in the "omit when unset" test so it stops depending on the host shell's environment. Without this, running the test suite with PROSE_CLAUDE_PERMISSION_MODE exported (as during the E2E spike that motivated this PR) made the test flake. Re-verified: 16/16 vitest tests pass; E2E validator still throws expected error on PROSE_CLAUDE_PERMISSION_MODE=yolo after rebuild. --- CHANGELOG.md | 12 ++++++++++++ tools/cli/src/harnesses/claude-options.ts | 6 ++++-- tools/cli/tests/harnesses/harnesses.test.ts | 4 +++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e778bf9e..f9d311b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`claude-sdk` env-var passthrough** — The `claude-sdk` harness now honors + `PROSE_CLAUDE_PERMISSION_MODE` (`default`, `acceptEdits`, `bypassPermissions`, + or `plan`) and forwards it to the Claude Agent SDK as `permissionMode`. This + mirrors the existing `PROSE_CODEX_*` pattern and unblocks non-interactive + `prose run --harness claude-sdk` invocations (CI, conformance runs, scheduled + jobs) that previously stalled on per-write permission prompts because + `permissionMode` is a query-level SDK option that `settingSources` does not + flow into. See `tools/cli/README.md` and + `tools/cli/src/harnesses/claude-options.ts`. + ## [0.14.0] - 2026-05-19 ### Added diff --git a/tools/cli/src/harnesses/claude-options.ts b/tools/cli/src/harnesses/claude-options.ts index 29d1b447..6c509a24 100644 --- a/tools/cli/src/harnesses/claude-options.ts +++ b/tools/cli/src/harnesses/claude-options.ts @@ -1,10 +1,12 @@ +import type { ClaudeSdkQuery } from "./claude-sdk.js"; + const CLAUDE_PERMISSION_MODES = ["default", "acceptEdits", "bypassPermissions", "plan"] as const; -export type ClaudePermissionMode = (typeof CLAUDE_PERMISSION_MODES)[number]; +type ClaudeQueryOptions = NonNullable[0]["options"]>; export function claudeRuntimeOptions( env: Record | undefined, -): { permissionMode?: ClaudePermissionMode } { +): Pick { const permissionMode = claudeEnvOption("PROSE_CLAUDE_PERMISSION_MODE", CLAUDE_PERMISSION_MODES, env); return { diff --git a/tools/cli/tests/harnesses/harnesses.test.ts b/tools/cli/tests/harnesses/harnesses.test.ts index 7ce94b90..e57b2685 100644 --- a/tools/cli/tests/harnesses/harnesses.test.ts +++ b/tools/cli/tests/harnesses/harnesses.test.ts @@ -350,7 +350,9 @@ describe("claude-sdk harness", () => { }, }); - await harness.run("prose status", { ...io.options }); + // Pass an explicit empty env so the test does not depend on the + // host shell's PROSE_CLAUDE_PERMISSION_MODE. + await harness.run("prose status", { ...io.options, env: {} }); expect(calls).toHaveLength(1); const call = calls[0] as { options: Record };