From 162c9e16669eab4fc3f27ecb7a78b50d33f03cea Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:56:43 -0400 Subject: [PATCH 1/3] fix(run-claude-cli): run each leg with the CLI install and action dirs bound read-only When the CLI install tree or _work/_actions exists, each leg now runs as bwrap --die-with-parent --dev-bind / / --ro-bind ... -- . A leg runs as the owning uid, so it could restore the write bits the mode lock removes; a read-only bind cannot be undone from inside the leg. The mode lock stays as a second layer. bwrap forwards no signal, so --die-with-parent makes a leg timeout or stdout overflow kill the CLI (SIGKILL) instead of leaving it running. Timeout and overflow are still classified from spawnSync's error code. Co-Authored-By: Claude Opus 5.5 --- .github/actions/run-claude-cli/lane.cjs | 26 +++- .github/actions/run-claude-cli/lane.test.cjs | 125 ++++++++++++++++++- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/.github/actions/run-claude-cli/lane.cjs b/.github/actions/run-claude-cli/lane.cjs index be3aded..8a36a2d 100644 --- a/.github/actions/run-claude-cli/lane.cjs +++ b/.github/actions/run-claude-cli/lane.cjs @@ -590,6 +590,27 @@ function lockTrees(dirs, modes) { for (const dir of dirs) walk(dir); } +// The command one leg runs. With trees to protect, bwrap binds each one +// read-only over a view of the whole filesystem, so the leg cannot restore the +// write bits lockTrees removed. --die-with-parent kills the CLI when a timeout +// or an overflow kills bwrap, which forwards no signal. +function legCommand(config, readOnly) { + if (readOnly.length === 0) return [config.cliPath, cliArgs(config)]; + return [ + "bwrap", + [ + "--die-with-parent", + "--dev-bind", + "/", + "/", + ...readOnly.flatMap((dir) => ["--ro-bind", dir, dir]), + "--", + config.cliPath, + ...cliArgs(config), + ], + ]; +} + function restoreModes(modes) { for (const { entry, mode } of modes.toReversed()) { try { @@ -615,7 +636,8 @@ function runLegs( const sizes = fileSizes(baseEnv); const modes = []; try { - lockTrees(readOnlyPaths(config, baseEnv), modes); + const readOnly = readOnlyPaths(config, baseEnv); + lockTrees(readOnly, modes); for (const [position, leg] of active.entries()) { const index = config.legs.indexOf(leg); const streamFile = path.join(config.workDir, `leg-${index + 1}.jsonl`); @@ -624,7 +646,7 @@ function runLegs( `leg-${index + 1}-execution.json`, ); const startedAt = now().toISOString(); - const child = spawn(config.cliPath, cliArgs(config), { + const child = spawn(...legCommand(config, readOnly), { env: legEnv(baseEnv, leg, config), input: prompt, stdio: ["pipe", "pipe", "inherit"], diff --git a/.github/actions/run-claude-cli/lane.test.cjs b/.github/actions/run-claude-cli/lane.test.cjs index 61785fa..b1d58f7 100644 --- a/.github/actions/run-claude-cli/lane.test.cjs +++ b/.github/actions/run-claude-cli/lane.test.cjs @@ -11,6 +11,7 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); +const { spawnSync } = require("node:child_process"); const test = require("node:test"); const { @@ -54,6 +55,14 @@ const fileCommands = ${JSON.stringify(FILE_COMMAND_VARIABLES)}.filter((n) => n i for (const file of (process.env.FAKE_APPEND ?? "").split(":").filter(Boolean)) { fs.appendFileSync(file, "PLANTED=1\\n"); } +const chmods = (process.env.FAKE_CHMOD ?? "").split(":").filter(Boolean).map((file) => { + try { + fs.chmodSync(file, 0o644); + return "changed"; + } catch (error) { + return error.code; + } +}); const writes = (process.env.FAKE_WRITE ?? "").split(":").filter(Boolean).map((file) => { try { fs.appendFileSync(file, "PLANTED=1\\n"); @@ -62,7 +71,7 @@ const writes = (process.env.FAKE_WRITE ?? "").split(":").filter(Boolean).map((fi return error.code; } }); -fs.appendFileSync(process.env.FAKE_CALLS, JSON.stringify({ leg, seen, fileCommands, writes, +fs.appendFileSync(process.env.FAKE_CALLS, JSON.stringify({ leg, seen, fileCommands, chmods, writes, scrub: process.env.CLAUDE_CODE_SUBPROCESS_ENV_SCRUB, watchdog: process.env.CLAUDE_CODE_RETRY_WATCHDOG, wait: process.env.CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS, @@ -70,6 +79,7 @@ fs.appendFileSync(process.env.FAKE_CALLS, JSON.stringify({ leg, seen, fileComman stdin: fs.readFileSync(0, "utf8") }) + "\\n"); if (process.env["FAKE_" + leg + "_SLEEP"]) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000); + if (process.env.FAKE_SURVIVED) fs.writeFileSync(process.env.FAKE_SURVIVED, ""); } process.stdout.write(fs.readFileSync(process.env["FAKE_" + leg], "utf8")); process.exit(Number(process.env["FAKE_" + leg + "_EXIT"] ?? 0)); @@ -79,6 +89,20 @@ fs.chmodSync(fakeCli, 0o755); let caseNumber = 0; +// Runs what follows `--` when the legs ask for bwrap, so cases with a tree to +// protect test the mode lock on hosts without bubblewrap. +function withoutBwrap(command, args, options) { + if (command !== "bwrap") return spawnSync(command, args, options); + const rest = args.slice(args.indexOf("--") + 1); + return spawnSync(rest[0], rest.slice(1), options); +} + +const bwrapWorks = + spawnSync("bwrap", ["--dev-bind", "/", "/", "--", "true"], { + stdio: "ignore", + }).status === 0; +const withBwrap = { skip: !bwrapWorks && "bwrap cannot run here" }; + function result(fields) { return { type: "result", @@ -201,7 +225,7 @@ function runCase({ legTimeoutMs = 0, extraEnv = {}, prepare = () => ({}), - spawn, + spawn = withoutBwrap, }) { caseNumber += 1; const dir = path.join(root, `case-${caseNumber}`); @@ -265,7 +289,7 @@ function runCase({ workDir: dir, runId: "123", }; - const outcome = runLegs(config, { baseEnv, ...(spawn ? { spawn } : {}) }); + const outcome = runLegs(config, { baseEnv, spawn }); const callLines = fs.existsSync(calls) ? fs.readFileSync(calls, "utf8").trim().split("\n").filter(Boolean) : []; @@ -549,6 +573,101 @@ test("_work/_actions beside RUNNER_WORKSPACE is read-only during the legs and wr fs.appendFileSync(action, "after\n"); }); +function recordingSpawn(spawned) { + return (command, args) => { + spawned.push([command, args]); + return { + status: 0, + signal: null, + stdout: Buffer.from(SUCCESS.map((m) => JSON.stringify(m)).join("\n")), + }; + }; +} + +test("with the install and action trees present, each leg runs the CLI under bwrap that binds both read-only after the root", () => { + let cli; + let actions; + const spawned = []; + const { config } = runCase({ + key: CREDIT_STOP, + sub: SUCCESS, + prepare: (dir) => { + cli = path.join(dir, "cli"); + writableTree(cli); + actions = path.join(dir, "_work", "_actions"); + fs.mkdirSync(actions, { recursive: true }); + fs.mkdirSync(path.join(dir, "_work", "repo")); + return { RUNNER_WORKSPACE: path.join(dir, "_work", "repo") }; + }, + spawn: recordingSpawn(spawned), + }); + assert.equal(spawned.length, 1); + const [command, args] = spawned[0]; + assert.equal(command, "bwrap"); + assert.deepEqual(args.slice(0, args.indexOf("--") + 2), [ + "--die-with-parent", + "--dev-bind", + "/", + "/", + "--ro-bind", + cli, + cli, + "--ro-bind", + actions, + actions, + "--", + fakeCli, + ]); + assert.deepEqual(args.slice(args.indexOf("--") + 2), cliArgs(config)); +}); + +test("with neither tree present, the leg runs the CLI directly", () => { + const spawned = []; + const { config } = runCase({ + key: SUCCESS, + sub: null, + spawn: recordingSpawn(spawned), + }); + assert.deepEqual(spawned, [[fakeCli, cliArgs(config)]]); +}); + +test("under bwrap a leg cannot put the write bits back on the install tree", withBwrap, () => { + let installed; + const { calls } = runCase({ + key: SUCCESS, + sub: null, + prepare: (dir) => { + installed = writableTree(path.join(dir, "cli")); + return { FAKE_CHMOD: installed, FAKE_WRITE: installed }; + }, + spawn: spawnSync, + }); + assert.deepEqual(calls[0].chmods, ["EROFS"]); + assert.notEqual(calls[0].writes[0], "written"); + assert.equal(fs.readFileSync(installed, "utf8"), "original\n"); + assert.equal(fs.statSync(installed).mode & 0o777, 0o755); +}); + +test("a CLI under bwrap does not outlive a timed-out leg", withBwrap, () => { + let survived; + const { outputs } = runCase({ + key: SUCCESS, + sub: null, + keySleep: true, + legTimeoutMs: 300, + prepare: (dir) => { + writableTree(path.join(dir, "cli")); + survived = path.join(dir, "survived"); + return { FAKE_SURVIVED: survived }; + }, + spawn: spawnSync, + }); + assert.equal(outputs["lane-class"], "timeout"); + // The fake CLI sleeps 5 seconds before it writes the marker. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6000); + assert.equal(fs.existsSync(survived), false); +}); + test("the install tree is writable again when a leg throws", asNonRoot, () => { let installed; assert.throws(() => From 5a15fa2b0e163fc3dc47b001fe1c9dd610884dde Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:18:05 -0400 Subject: [PATCH 2/3] fix(run-claude-cli): run each leg in its own PID namespace --die-with-parent kills only the process bwrap execs, so a process a leg detached kept running after a timeout or overflow killed bwrap. --unshare-pid makes bwrap the namespace's init, and its death takes every process in the namespace with it. Tests: a detached process does not outlive a timed-out leg (fails without --unshare-pid), and under real bwrap a leg cannot restore the write bits on _work/_actions. Co-Authored-By: Claude Opus 5.5 --- .github/actions/run-claude-cli/lane.cjs | 7 +++- .github/actions/run-claude-cli/lane.test.cjs | 41 ++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.github/actions/run-claude-cli/lane.cjs b/.github/actions/run-claude-cli/lane.cjs index 8a36a2d..15a1a5e 100644 --- a/.github/actions/run-claude-cli/lane.cjs +++ b/.github/actions/run-claude-cli/lane.cjs @@ -592,14 +592,17 @@ function lockTrees(dirs, modes) { // The command one leg runs. With trees to protect, bwrap binds each one // read-only over a view of the whole filesystem, so the leg cannot restore the -// write bits lockTrees removed. --die-with-parent kills the CLI when a timeout -// or an overflow kills bwrap, which forwards no signal. +// write bits lockTrees removed. bwrap forwards no signal, so when a timeout or +// an overflow kills it, --die-with-parent kills the namespace's init and +// --unshare-pid takes every process the leg started down with it, including +// one it detached. function legCommand(config, readOnly) { if (readOnly.length === 0) return [config.cliPath, cliArgs(config)]; return [ "bwrap", [ "--die-with-parent", + "--unshare-pid", "--dev-bind", "/", "/", diff --git a/.github/actions/run-claude-cli/lane.test.cjs b/.github/actions/run-claude-cli/lane.test.cjs index b1d58f7..917a914 100644 --- a/.github/actions/run-claude-cli/lane.test.cjs +++ b/.github/actions/run-claude-cli/lane.test.cjs @@ -77,6 +77,12 @@ fs.appendFileSync(process.env.FAKE_CALLS, JSON.stringify({ leg, seen, fileComman wait: process.env.CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS, args: process.argv.slice(2), stdin: fs.readFileSync(0, "utf8") }) + "\\n"); +if (process.env.FAKE_DETACHED) { + require("node:child_process").spawn(process.execPath, ["-e", + 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);' + + 'require("node:fs").writeFileSync(process.env.FAKE_DETACHED, "");'], + { detached: true, stdio: "ignore" }).unref(); +} if (process.env["FAKE_" + leg + "_SLEEP"]) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000); if (process.env.FAKE_SURVIVED) fs.writeFileSync(process.env.FAKE_SURVIVED, ""); @@ -98,7 +104,7 @@ function withoutBwrap(command, args, options) { } const bwrapWorks = - spawnSync("bwrap", ["--dev-bind", "/", "/", "--", "true"], { + spawnSync("bwrap", ["--unshare-pid", "--dev-bind", "/", "/", "--", "true"], { stdio: "ignore", }).status === 0; const withBwrap = { skip: !bwrapWorks && "bwrap cannot run here" }; @@ -606,6 +612,7 @@ test("with the install and action trees present, each leg runs the CLI under bwr assert.equal(command, "bwrap"); assert.deepEqual(args.slice(0, args.indexOf("--") + 2), [ "--die-with-parent", + "--unshare-pid", "--dev-bind", "/", "/", @@ -648,8 +655,32 @@ test("under bwrap a leg cannot put the write bits back on the install tree", wit assert.equal(fs.statSync(installed).mode & 0o777, 0o755); }); -test("a CLI under bwrap does not outlive a timed-out leg", withBwrap, () => { +test("under bwrap a leg cannot put the write bits back on _work/_actions", withBwrap, () => { + let action; + const { calls } = runCase({ + key: SUCCESS, + sub: null, + prepare: (dir) => { + const work = path.join(dir, "_work"); + fs.mkdirSync(path.join(work, "repo"), { recursive: true }); + action = writableTree(path.join(work, "_actions", "owner", "action")); + return { + RUNNER_WORKSPACE: path.join(work, "repo"), + FAKE_CHMOD: action, + FAKE_WRITE: action, + }; + }, + spawn: spawnSync, + }); + assert.deepEqual(calls[0].chmods, ["EROFS"]); + assert.notEqual(calls[0].writes[0], "written"); + assert.equal(fs.readFileSync(action, "utf8"), "original\n"); + assert.equal(fs.statSync(action).mode & 0o777, 0o755); +}); + +test("neither the CLI under bwrap nor a process it detaches outlives a timed-out leg", withBwrap, () => { let survived; + let detached; const { outputs } = runCase({ key: SUCCESS, sub: null, @@ -658,14 +689,16 @@ test("a CLI under bwrap does not outlive a timed-out leg", withBwrap, () => { prepare: (dir) => { writableTree(path.join(dir, "cli")); survived = path.join(dir, "survived"); - return { FAKE_SURVIVED: survived }; + detached = path.join(dir, "detached"); + return { FAKE_SURVIVED: survived, FAKE_DETACHED: detached }; }, spawn: spawnSync, }); assert.equal(outputs["lane-class"], "timeout"); - // The fake CLI sleeps 5 seconds before it writes the marker. + // The fake CLI writes its marker after 5 seconds, the detached process after 3. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6000); assert.equal(fs.existsSync(survived), false); + assert.equal(fs.existsSync(detached), false); }); test("the install tree is writable again when a leg throws", asNonRoot, () => { From 8afb95e8d4a901609865bd738508c72599438af0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:20:58 -0400 Subject: [PATCH 3/3] fix(run-claude-cli): drop --unshare-pid from the leg's bwrap Inside the new PID namespace /proc is still the host's procfs, so a bwrap nested in the leg (the CLI's Bash sandbox) fails with "open /proc/3/ns/ns failed". The leg goes back to the argv from 162c9e1. The _work/_actions real-bwrap test stays. Co-Authored-By: Claude Opus 5.5 --- .github/actions/run-claude-cli/lane.cjs | 7 ++----- .github/actions/run-claude-cli/lane.test.cjs | 18 ++++-------------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/.github/actions/run-claude-cli/lane.cjs b/.github/actions/run-claude-cli/lane.cjs index 15a1a5e..8a36a2d 100644 --- a/.github/actions/run-claude-cli/lane.cjs +++ b/.github/actions/run-claude-cli/lane.cjs @@ -592,17 +592,14 @@ function lockTrees(dirs, modes) { // The command one leg runs. With trees to protect, bwrap binds each one // read-only over a view of the whole filesystem, so the leg cannot restore the -// write bits lockTrees removed. bwrap forwards no signal, so when a timeout or -// an overflow kills it, --die-with-parent kills the namespace's init and -// --unshare-pid takes every process the leg started down with it, including -// one it detached. +// write bits lockTrees removed. --die-with-parent kills the CLI when a timeout +// or an overflow kills bwrap, which forwards no signal. function legCommand(config, readOnly) { if (readOnly.length === 0) return [config.cliPath, cliArgs(config)]; return [ "bwrap", [ "--die-with-parent", - "--unshare-pid", "--dev-bind", "/", "/", diff --git a/.github/actions/run-claude-cli/lane.test.cjs b/.github/actions/run-claude-cli/lane.test.cjs index 917a914..adcb59b 100644 --- a/.github/actions/run-claude-cli/lane.test.cjs +++ b/.github/actions/run-claude-cli/lane.test.cjs @@ -77,12 +77,6 @@ fs.appendFileSync(process.env.FAKE_CALLS, JSON.stringify({ leg, seen, fileComman wait: process.env.CLAUDE_CODE_RETRY_WATCHDOG_MAX_WAIT_MS, args: process.argv.slice(2), stdin: fs.readFileSync(0, "utf8") }) + "\\n"); -if (process.env.FAKE_DETACHED) { - require("node:child_process").spawn(process.execPath, ["-e", - 'Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);' + - 'require("node:fs").writeFileSync(process.env.FAKE_DETACHED, "");'], - { detached: true, stdio: "ignore" }).unref(); -} if (process.env["FAKE_" + leg + "_SLEEP"]) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5000); if (process.env.FAKE_SURVIVED) fs.writeFileSync(process.env.FAKE_SURVIVED, ""); @@ -104,7 +98,7 @@ function withoutBwrap(command, args, options) { } const bwrapWorks = - spawnSync("bwrap", ["--unshare-pid", "--dev-bind", "/", "/", "--", "true"], { + spawnSync("bwrap", ["--dev-bind", "/", "/", "--", "true"], { stdio: "ignore", }).status === 0; const withBwrap = { skip: !bwrapWorks && "bwrap cannot run here" }; @@ -612,7 +606,6 @@ test("with the install and action trees present, each leg runs the CLI under bwr assert.equal(command, "bwrap"); assert.deepEqual(args.slice(0, args.indexOf("--") + 2), [ "--die-with-parent", - "--unshare-pid", "--dev-bind", "/", "/", @@ -678,9 +671,8 @@ test("under bwrap a leg cannot put the write bits back on _work/_actions", withB assert.equal(fs.statSync(action).mode & 0o777, 0o755); }); -test("neither the CLI under bwrap nor a process it detaches outlives a timed-out leg", withBwrap, () => { +test("a CLI under bwrap does not outlive a timed-out leg", withBwrap, () => { let survived; - let detached; const { outputs } = runCase({ key: SUCCESS, sub: null, @@ -689,16 +681,14 @@ test("neither the CLI under bwrap nor a process it detaches outlives a timed-out prepare: (dir) => { writableTree(path.join(dir, "cli")); survived = path.join(dir, "survived"); - detached = path.join(dir, "detached"); - return { FAKE_SURVIVED: survived, FAKE_DETACHED: detached }; + return { FAKE_SURVIVED: survived }; }, spawn: spawnSync, }); assert.equal(outputs["lane-class"], "timeout"); - // The fake CLI writes its marker after 5 seconds, the detached process after 3. + // The fake CLI sleeps 5 seconds before it writes the marker. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 6000); assert.equal(fs.existsSync(survived), false); - assert.equal(fs.existsSync(detached), false); }); test("the install tree is writable again when a leg throws", asNonRoot, () => {