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, () => {