From d444ef1104a568936256baf6bfa99977737c7a81 Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 22 Jul 2026 01:00:47 +0800 Subject: [PATCH 1/2] feat(sandbox): resolve task sandbox from config.toml + add task --read-only (port openai#508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of @cubicj's openai/codex-plugin-cc#508. The plugin hardcoded the sandbox ("read-only"/"workspace-write"), ignoring sandbox_mode in ~/.codex/config.toml. Now the task path sends sandbox: null when the caller does not pin one, letting the app-server resolve it from config — the same mechanism already used for model. Explicit --write still forces workspace-write; review paths keep their pinned read-only. - buildThreadParams/buildResumeParams: sandbox ?? null (config-driven default) - task --read-only flag: per-run read-only pin, mutually exclusive with --write - stop-review-gate hook's internal task passes --read-only explicitly so the gate stays read-only regardless of config - rescue command/agent/skill docs updated for --read-only Merged onto the fork (conflicts in codex.mjs, codex-companion.mjs, rescue.md, fake-codex-fixture, runtime.test): kept our reasoning-validation + turn-timeout-ms + model aliases + --cwd, layered cubicj's sandbox-null + --read-only on top. Our fake-codex fixture already recorded sandbox in lastThreadStart/lastThreadResume, so #508's assertions pass as-is. Verified: npm run build clean; npm test 174/171 pass / 3 pre-existing (was 4 — the background test that previously asserted a foreign model-luna value now passes). All 6 new sandbox tests green. Co-Authored-By: Claude --- plugins/codex/agents/codex-rescue.md | 2 +- plugins/codex/commands/rescue.md | 4 +- plugins/codex/scripts/codex-companion.mjs | 15 ++- plugins/codex/scripts/lib/codex.mjs | 4 +- .../codex/scripts/stop-review-gate-hook.mjs | 2 +- .../codex/skills/codex-cli-runtime/SKILL.md | 2 +- tests/runtime.test.mjs | 96 +++++++++++++++---- 7 files changed, 95 insertions(+), 30 deletions(-) diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index b985cda6..2c258328 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -33,7 +33,7 @@ Forwarding rules: - If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`. - If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`. - Treat `--effort ` and `--model ` as runtime controls and do not include them in the task text you pass through. -- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. +- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits; in those cases, add `--read-only` instead. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. - `--fresh` means do not add `--resume-last`. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index dd30cfed..e4a75e10 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--cwd |-C ] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--read-only] [--cwd |-C ] [--resume|--fresh] [--model ] [--effort ] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -17,7 +17,7 @@ Execution mode: - If the request includes `--wait`, run the `codex:codex-rescue` subagent in the foreground. - If neither flag is present, default to `--background`. Rescue tasks are open-ended and routinely exceed Claude's Bash-tool timeout; starting in background avoids the auto-background trap (see Operating rules). - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. -- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- `--model` and `--effort` are runtime-selection flags. Preserve them and `--read-only` for the forwarded `task` call, but do not treat them as part of the natural-language task text. - `--cwd ` and `-C ` are workspace-routing flags. Preserve the directory for the resume preflight and the forwarded `task` call, but do not treat either form as part of the natural-language task text. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 283bfc47..6e9336a2 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -93,7 +93,7 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--cwd ] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--read-only] [--cwd ] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -544,7 +544,7 @@ async function executeTaskRun(request) { defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, - sandbox: request.write ? "workspace-write" : "read-only", + sandbox: request.write ? "workspace-write" : request.readOnly ? "read-only" : null, onProgress: request.onProgress, persistThread: true, turnTimeoutMs: request.turnTimeoutMs, @@ -659,13 +659,14 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId, turnTimeoutMs }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, readOnly, resumeLast, jobId, turnTimeoutMs }) { return { cwd, model, effort, prompt, write, + readOnly, resumeLast, jobId, turnTimeoutMs @@ -843,7 +844,7 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file", "turn-timeout-ms"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + booleanOptions: ["json", "write", "read-only", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" } @@ -862,6 +863,10 @@ async function handleTask(argv) { throw new Error("Choose either --resume/--resume-last or --fresh."); } const write = Boolean(options.write); + const readOnly = Boolean(options["read-only"]); + if (write && readOnly) { + throw new Error("Choose either --write or --read-only."); + } const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast @@ -878,6 +883,7 @@ async function handleTask(argv) { effort, prompt, write, + readOnly, resumeLast, jobId: job.id, turnTimeoutMs: resolveTurnTimeoutMsFromOptions(options) @@ -897,6 +903,7 @@ async function handleTask(argv) { effort, prompt, write, + readOnly, resumeLast, jobId: job.id, turnTimeoutMs: resolveTurnTimeoutMsFromOptions(options), diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 52553778..f9bf71fe 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -88,7 +88,7 @@ function buildThreadParams(cwd, options = {}) { cwd, model: options.model ?? null, approvalPolicy: options.approvalPolicy ?? "never", - sandbox: options.sandbox ?? "read-only", + sandbox: options.sandbox ?? null, config: options.effort ? { model_reasoning_effort: options.effort } : null, serviceName: SERVICE_NAME, ephemeral: options.ephemeral ?? true @@ -102,7 +102,7 @@ function buildResumeParams(threadId, cwd, options = {}) { cwd, model: options.model ?? null, approvalPolicy: options.approvalPolicy ?? "never", - sandbox: options.sandbox ?? "read-only" + sandbox: options.sandbox ?? null }; } diff --git a/plugins/codex/scripts/stop-review-gate-hook.mjs b/plugins/codex/scripts/stop-review-gate-hook.mjs index 2346bdcf..a5737b58 100644 --- a/plugins/codex/scripts/stop-review-gate-hook.mjs +++ b/plugins/codex/scripts/stop-review-gate-hook.mjs @@ -102,7 +102,7 @@ function runStopReview(cwd, input = {}) { ...process.env, ...(input.session_id ? { [SESSION_ID_ENV]: input.session_id } : {}) }; - const result = spawnSync(process.execPath, [scriptPath, "task", "--json", prompt], { + const result = spawnSync(process.execPath, [scriptPath, "task", "--json", "--read-only", prompt], { cwd, env: childEnv, encoding: "utf8", diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index a3eabab8..7ad68cba 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -21,7 +21,7 @@ Execution rules: - Leave `--effort` unset unless the user explicitly requests a specific effort. - Leave model unset by default. Add `--model` only when the user explicitly asks for one. - Map `spark` to `--model gpt-5.3-codex-spark`. -- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. +- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits; in those cases, add `--read-only` instead. Command selection: - Use exactly one `task` invocation per rescue handoff. diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 052abe40..acb10958 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -161,6 +161,7 @@ test("setup reports not ready when app-server config read fails", () => { test("review renders a no-findings result from app-server review/start", () => { const repo = makeTempDir(); const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); installFakeCodex(binDir); initGitRepo(repo); fs.mkdirSync(path.join(repo, "src")); @@ -178,6 +179,8 @@ test("review renders a no-findings result from app-server review/start", () => { assert.match(result.stdout, /Reviewed uncommitted changes/); assert.match(result.stdout, /No material issues found/); assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); }); test("task runs when the active provider does not require OpenAI login", () => { @@ -434,6 +437,7 @@ test("review accepts the quoted raw argument style for built-in base-branch revi test("adversarial review renders structured findings over app-server turn/start", () => { const repo = makeTempDir(); const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); installFakeCodex(binDir); initGitRepo(repo); fs.mkdirSync(path.join(repo, "src")); @@ -450,6 +454,8 @@ test("adversarial review renders structured findings over app-server turn/start" assert.equal(result.status, 0); assert.match(result.stdout, /Missing empty-state guard/); assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); }); test("adversarial review accepts the same base-branch targeting as review", () => { @@ -548,6 +554,7 @@ test("review logs reasoning summaries and review output to the job log", () => { test("task --resume-last resumes the latest persisted task thread", () => { const repo = makeTempDir(); const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); installFakeCodex(binDir); initGitRepo(repo); fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); @@ -568,6 +575,8 @@ test("task --resume-last resumes the latest persisted task thread", () => { assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); assert.deepEqual(readPersistedJob(repo).resolved, FAKE_RESOLVED_SETTINGS); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.sandbox, null); }); test("task-resume-candidate uses an explicit workspace cwd from an unrelated invocation directory", () => { @@ -780,6 +789,7 @@ test("session start hook exports the Claude session id, transcript path, and plu test("write task output focuses on the Codex result without generic follow-up hints", () => { const repo = makeTempDir(); const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); installFakeCodex(binDir); initGitRepo(repo); fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); @@ -793,6 +803,8 @@ test("write task output focuses on the Codex result without generic follow-up hi assert.equal(result.status, 0, result.stderr); assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "workspace-write"); }); test("task --write starts Codex with workspace-write sandbox", () => { @@ -957,6 +969,7 @@ test("task forwards model selection and reasoning effort to app-server turn/star assert.equal(result.status, 0, result.stderr); const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, null); assert.equal(fakeState.lastTurnStart.model, "gpt-5.3-codex-spark"); assert.equal(fakeState.lastTurnStart.effort, "low"); assert.deepEqual(readPersistedJob(repo).resolved, { @@ -1303,30 +1316,20 @@ test("task using the shared broker still completes when Codex spawns subagents", assert.equal(result.stdout, "Handled the requested task.\nTask prompt accepted.\n"); }); -test("task --background enqueues a detached worker and exposes per-job status", async () => { +test("task --background preserves --read-only through the detached worker", async () => { const repo = makeTempDir(); const binDir = makeTempDir(); + const fakeStatePath = path.join(binDir, "fake-codex-state.json"); installFakeCodex(binDir, "slow-task"); initGitRepo(repo); fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); run("git", ["add", "README.md"], { cwd: repo }); run("git", ["commit", "-m", "init"], { cwd: repo }); - const launched = run( - "node", - [ - SCRIPT, - "task", - "--background", - "--json", - "--model", - "gpt-5.6-luna", - "--effort", - "max", - "investigate the failing test" - ], - { cwd: repo, env: buildEnv(binDir) } - ); + const launched = run("node", [SCRIPT, "task", "--background", "--read-only", "--json", "investigate the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); assert.equal(launched.status, 0, launched.stderr); const launchPayload = JSON.parse(launched.stdout); @@ -1375,9 +1378,63 @@ test("task --background enqueues a detached worker and exposes per-job status", assert.deepEqual(resultPayload.job.resolved, FAKE_RESOLVED_SETTINGS); assert.deepEqual(resultPayload.storedJob.resolved, FAKE_RESOLVED_SETTINGS); assert.match(resultPayload.storedJob.rendered, /Handled the requested task/); - const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); - assert.equal(fakeState.lastTurnStart.model, "gpt-5.6-luna"); - assert.equal(fakeState.lastTurnStart.effort, "max"); + const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); +}); + +test("task --read-only pins the app-server thread sandbox", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--read-only", "inspect the failing test"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); +}); + +test("task --read-only pins resumed app-server threads", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const result = run("node", [SCRIPT, "task", "--read-only", "--resume-last", "read-only follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); +}); + +test("task rejects --write with --read-only", () => { + const result = run("node", [SCRIPT, "task", "--write", "--read-only", "inspect the failing test"], { + cwd: ROOT + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Choose either --write or --read-only\./); }); test("review accepts (ignores) positional focus text for parity with adversarial-review", () => { @@ -2669,6 +2726,7 @@ test("stop hook runs a stop-time review task and blocks on findings when the rev assert.match(fakeState.lastTurnStart.prompt, //i); assert.match(fakeState.lastTurnStart.prompt, /Only review the work from the previous Claude turn/i); assert.match(fakeState.lastTurnStart.prompt, /I completed the refactor and updated the retry logic\./); + assert.equal(fakeState.lastThreadStart.sandbox, "read-only"); const status = run("node", [SCRIPT, "status"], { cwd: repo, From c5af938e9dbddd406dacab57818c220f8abf3811 Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 22 Jul 2026 01:33:30 +0800 Subject: [PATCH 2/2] fix(sandbox): fail closed when a read-only pin is not honored on resume (cycle-review critical) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex adversarial review of #33 flagged — and a real-codex smoke confirmed — that Codex 0.144.6 keeps a loaded thread's sandbox and ignores thread/resume overrides: `task --read-only --resume-last` on a write-capable thread ran with workspace-write access, violating the --read-only contract. Verified empirically (codex-cli 0.144.6): write-thread -> resume with --read-only returned resolved.sandbox = workspaceWrite. The code never compared the resolved sandbox to the explicit pin. Fix: assertExplicitSandboxHonored() in runAppServerTurn compares an explicit read-only pin to the app-server's resolved sandbox and throws on mismatch — fail closed instead of running write-capable under a read-only flag. Only the read-only pin is enforced (a write request on a read-only resolved sandbox is a downgrade, not a safety leak). TDD: a resume-ignores-sandbox fake-codex behavior reproduces the leak (RED), the assertion turns it green. Also confirms the port's cubicj claim: a plain task with sandbox:null resolves to read-only from config (smoke: resolved readOnly), so stock behavior is unchanged. Co-Authored-By: Claude --- plugins/codex/scripts/lib/codex.mjs | 36 +++++++++++++++++++++++++++++ tests/fake-codex-fixture.mjs | 8 ++++++- tests/runtime.test.mjs | 24 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index f9bf71fe..13a21288 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -111,6 +111,37 @@ function buildTurnInput(prompt) { return [{ type: "text", text: prompt, text_elements: [] }]; } +// Map a requested sandbox shorthand ("read-only"/"workspace-write") to the +// app-server's sandbox-type vocabulary ("readOnly"/"workspaceWrite"). +function requestedSandboxType(requested) { + if (requested === "read-only") { + return "readOnly"; + } + if (requested === "workspace-write") { + return "workspaceWrite"; + } + return null; +} + +// Fail closed when an explicit read-only pin was requested but the app-server +// resolved a write-capable sandbox. Codex 0.144.x keeps a loaded thread's +// sandbox and ignores thread/resume overrides, so without this check +// --read-only --resume-last could silently run with write access. (The write +// pin is not enforced here: a write request on a read-only resolved sandbox is +// a downgrade, not a safety leak.) +function assertExplicitSandboxHonored(requested, resolved) { + if (requestedSandboxType(requested) !== "readOnly") { + return; + } + const resolvedType = resolved?.type ?? null; + if (resolvedType === "readOnly") { + return; + } + throw new Error( + `Requested a read-only sandbox, but the app-server resolved ${resolvedType ?? "an unknown sandbox"} — refusing to run with a sandbox that does not match the explicit read-only pin. Start a fresh thread or drop --read-only.` + ); +} + function shorten(text, limit = 72) { const normalized = String(text ?? "").trim().replace(/\s+/g, " "); if (!normalized) { @@ -1229,6 +1260,11 @@ export async function runAppServerTurn(cwd, options = {}) { reasoningEffort: response.reasoningEffort, sandbox: response.sandbox }; + // Fail closed when an explicit sandbox pin is not honored. Codex app-server + // (0.144.x) keeps a loaded thread's sandbox and ignores thread/resume + // overrides, so --read-only --resume-last on a write-capable thread would + // otherwise run with write access — violating the --read-only contract. + assertExplicitSandboxHonored(options.sandbox, resolved.sandbox); await validateReasoningSelection(client, { model: options.model ?? threadSelection.model, effort: options.effort ?? threadSelection.reasoningEffort, diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index babc5341..b0854041 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -420,7 +420,13 @@ rl.on("line", (line) => { sandbox: message.params.sandbox ?? null }; saveState(state); - send({ id: message.id, result: { thread: buildThread(thread), model: selectedModel, modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: selectedEffort } }); + // resume-ignores-sandbox: simulate a real Codex app-server that keeps a + // loaded thread's sandbox and ignores the thread/resume override — so an + // explicit --read-only pin on a write-capable thread is silently lost. + const resumeSandbox = BEHAVIOR === "resume-ignores-sandbox" + ? { type: "workspaceWrite", writableRoots: [], networkAccess: false } + : { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }; + send({ id: message.id, result: { thread: buildThread(thread), model: selectedModel, modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: resumeSandbox, reasoningEffort: selectedEffort } }); break; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index acb10958..92480e68 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1428,6 +1428,30 @@ test("task --read-only pins resumed app-server threads", () => { assert.equal(fakeState.lastThreadResume.sandbox, "read-only"); }); +test("task --read-only fails closed if the app-server resumes a write-capable sandbox", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir, "resume-ignores-sandbox"); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const result = run("node", [SCRIPT, "task", "--read-only", "--resume-last", "read-only follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.notEqual(result.status, 0, "must fail closed when resolved sandbox is not read-only"); + assert.match(result.stderr, /read-only/i); +}); + test("task rejects --write with --read-only", () => { const result = run("node", [SCRIPT, "task", "--write", "--read-only", "inspect the failing test"], { cwd: ROOT