Files
stack/packages/seat/src/sandbox.mjs
T
jason.woltjeandClaude Opus 5.5 e918a915a8 tasks, bus, harness, seat: the approved requirement ids as data (#1584)
Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on
manifest 95611274 (21 paths, base b8539480). The approved PRD's
requirement ids live in packages/tasks/requirements.json. task_create
refuses an id outside it, and the PM session gets a list_requirements
tool. Round 2 adds requirements.mjs to the sandbox's RUNNER_FILES
(decision 127). Sage gate on c047d028 plus the patch, 23:50:16Z to
23:54:26Z: tasks 89, bus 111, harness 66, webui 101, cli 123, seat 33,
business 60, control-board 124, queue 150, conversation 182, discord 178,
ledger 78, runs 43, mosaic 69, all fail 0. Every scripts/test-*.sh
exited 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 18:55:49 -05:00

145 lines
7.9 KiB
JavaScript

// The confinement a managed role session runs in (#1586). The runner and
// everything it starts, the harness and every shell the harness opens, run
// inside a bubblewrap mount namespace built from an empty root. The session
// sees only the binds below, each at its own host path so session.json's
// paths mean the same inside and out:
//
// read-only /usr, /etc and the host's /bin, /lib, /lib64, /sbin links;
// the repository files the runner loads; the adapter; for pi
// the repository's node_modules, for Claude Code its pinned
// binary; node when it lives outside /usr; the broker socket,
// and the launch socket for the role that may launch
// read-write the instance's workspace and the run directory, except the
// run's bundle/ and session.json, which stay read-only
// private /tmp (the run's tmp/), HOME (the run's home/), /dev, /proc
//
// Nothing else exists inside: no data root, no other workspace or run
// directory, no ~/.config, ~/.mosaic or ~/.ssh, no checkout beyond the
// runner's files. The root is remounted read-only, so a write anywhere but
// a read-write bind fails. The session keeps a user and PID namespace (the
// runner is pid 1, as before) and an IPC namespace. The runner dies with the
// bwrap process outside, as it did with unshare's --kill-child: setpriv
// gives it SIGKILL as its parent-death signal. bwrap's own --die-with-parent
// isn't used, because it also kills bwrap when the host dies, and a session
// outlives a host that died hard (the next host recovers it). The network,
// abstract Unix sockets and the host UID are not confined
// (packages/harness/README.md, "Limits").
//
// There is no unconfined fallback: a bwrap that is missing or fails refuses
// the launch (sandbox-unavailable).
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readlinkSync, realpathSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
export const BWRAP = "/usr/bin/bwrap";
const KILL_WITH_PARENT = ["/usr/bin/setpriv", "--pdeathsig", "KILL", "--"];
// What the runner imports, and the three files the harness starts from the
// bundle's settings (claude-gate, mcp-server, pi-extension) with theirs.
// packages/seat/tests/sandbox.test.mjs walks the imports and fails when one
// is missing here.
export const RUNNER_FILES = Object.freeze([
"packages/harness/src/runner.mjs",
"packages/harness/src/bundle.mjs",
"packages/harness/src/gate.mjs",
"packages/harness/src/tools.mjs",
"packages/harness/src/claude-gate.mjs",
"packages/harness/src/mcp-server.mjs",
"packages/harness/src/pi-extension.mjs",
"packages/bus/src/client.mjs",
"packages/bus/src/broker.mjs",
"packages/business/src/vocabulary.mjs",
"packages/tasks/src/requirements.mjs",
].map((p) => join(REPO, p)));
const SYSTEM_LINKS = ["/bin", "/lib", "/lib64", "/sbin"];
const NAMESPACES = ["--unshare-user", "--unshare-pid", "--unshare-ipc", "--as-pid-1"];
// The system part every session gets, and the probe runs: /usr, the host's
// links into it, /etc, a private /dev and /proc, and node when it lives
// outside /usr.
function systemBinds(node = process.execPath) {
const out = [{ kind: "ro", src: "/usr", why: "the system's programs and libraries: sh, node, git, coreutils" }];
for (const p of SYSTEM_LINKS) {
let st;
try {
st = lstatSync(p);
} catch {
continue;
}
if (st.isSymbolicLink()) out.push({ kind: "symlink", src: readlinkSync(p), dest: p, why: `the host's ${p} link into /usr` });
else out.push({ kind: "ro", src: p, why: `the host's ${p}, which isn't a link into /usr` });
}
out.push(
{ kind: "ro", src: "/etc", why: "name resolution, CA certificates, passwd for the user's name" },
{ kind: "dev", dest: "/dev", why: "a private /dev with null, zero, urandom and a pty" },
{ kind: "proc", dest: "/proc", why: "the session's own PID namespace; node and the shell read /proc/self" },
);
const real = realpathSync(node);
if (!real.startsWith("/usr/")) out.push({ kind: "ro", src: real, dest: node, why: "node, which runs the runner and the gate, lives outside /usr" });
return out;
}
// The binds for one session, in mount order; each says why the session
// needs it. paths: the run directory, the instance's workspace, the adapter,
// the harness ("pi" or "claude-code"), the broker socket, the launch socket
// (null for a role that may not launch), the Claude Code binary
// (MOSAIC_CLAUDE_BIN, or null) and node. `readOnly` adds files, for tests only.
export function binds({ runDir, workspace, adapter, harness, brokerSocket, launchSocket = null, claudeBin = null, node = process.execPath, readOnly = [] }) {
const out = systemBinds(node);
for (const f of RUNNER_FILES) out.push({ kind: "ro", src: f, why: "a repository file the runner loads" });
out.push({ kind: "ro", src: adapter, why: "the harness adapter the runner starts each turn" });
if (harness === "pi") out.push({ kind: "ro", src: realpathSync(join(REPO, "node_modules")), dest: join(REPO, "node_modules"), why: "pi and its packages, on the session's PATH" });
if (harness === "claude-code" && claudeBin) {
const bin = realpathSync(claudeBin);
if (!bin.startsWith("/usr/")) out.push({ kind: "ro", src: bin, dest: claudeBin, why: "the pinned Claude Code binary (MOSAIC_CLAUDE_BIN)" });
}
out.push({ kind: "ro", src: brokerSocket, why: "the broker socket the runner claims and calls through" });
if (launchSocket) out.push({ kind: "ro", src: launchSocket, why: "the launch socket, for the role whose launch tool calls it" });
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
out.push(
{ kind: "rw", src: workspace, why: "the instance's workspace, where the harness works" },
{ kind: "rw", src: runDir, why: "the run directory: turns/, session/, tools.sock, turn.done, home/, tmp/" },
{ kind: "ro", src: join(runDir, "bundle"), why: "the run's bundle stays as the host wrote it" },
{ kind: "ro", src: join(runDir, "session.json"), why: "the session file stays as the host wrote it" },
{ kind: "rw", src: join(runDir, "tmp"), dest: "/tmp", why: "a private /tmp, on disk in the run directory" },
);
return out;
}
function argsOf(list) {
const args = [];
for (const b of list) {
if (b.kind === "ro") args.push("--ro-bind", b.src, b.dest ?? b.src);
else if (b.kind === "rw") args.push("--bind", b.src, b.dest ?? b.src);
else if (b.kind === "symlink") args.push("--symlink", b.src, b.dest);
else if (b.kind === "dev") args.push("--dev", b.dest);
else if (b.kind === "proc") args.push("--proc", b.dest);
}
return args;
}
// The run's home/ and tmp/, made before the spawn: binds need their sources.
export function prepareRun(runDir) {
for (const d of ["home", "tmp"]) mkdirSync(join(runDir, d), { recursive: true, mode: 0o700 });
}
// The full command line for `command` inside the sandbox. HOME is the run's
// home/, empty at the start; the harness makes what it needs there.
export function sandboxArgv(list, runDir, command, { bwrap = BWRAP } = {}) {
return [bwrap, ...NAMESPACES, ...argsOf(list), "--remount-ro", "/", "--setenv", "HOME", join(runDir, "home"), "--setenv", "TMPDIR", "/tmp", "--chdir", runDir, "--", ...KILL_WITH_PARENT, ...command];
}
// Whether bwrap runs here at all: the namespaces and the system binds, then
// setpriv and `true`. Returns null, or why not.
export function sandboxProblem({ bwrap = BWRAP } = {}) {
if (!existsSync(bwrap)) return `${bwrap} is missing`;
const r = spawnSync(bwrap, [...NAMESPACES, ...argsOf(systemBinds()), "--remount-ro", "/", "--", ...KILL_WITH_PARENT, "/usr/bin/true"], { encoding: "utf8", timeout: 10000 });
if (r.status === 0) return null;
const why = (r.stderr ?? "").trim().split("\n")[0] || (r.error ? r.error.code : `exit ${r.status ?? r.signal}`);
return `${bwrap} failed: ${why}`;
}