Files
stack/packages/seat/src/sandbox.mjs
T
jason.woltjeandClaude Opus 5.5 57948a81f3 seat, cli, harness: role sessions confined to their own files (#1586)
Row 92, round 2. Darkwing built it. A role session runs in a bwrap
sandbox that sees only its own workspace, a private /proc, /dev and IPC
namespace, and the variables ENV_ALLOW passes. The launcher checks the
session credential before a Claude launch, records a runner killed from
the host as killed rather than failed, and the bus unit template carries
the credential file (decision 119). The harness gate and the cli, seat
and harness READMEs say what the sandbox does and does not hide.

Candidate manifest 8bf4141cbe69fc7bed188555bcc88dcab0b1ea2149912cb6f970ba8c2629dd97
(13 paths), base ac37cd17, packet agents/darkwing/work/queue-92/r2/.
Approvals: Dewey 27978, Filbert 27984 (round 1: Dewey 27942, Filbert
asked for changes in 27951). Sage's gate rerun 22:27:37Z-22:30:35Z on
1b7f3781 plus the patch, manifest 13/13: seat 33, cli 120, harness 65,
bus 108, webui 86, control-board 124, and every scripts/test-*.sh exited 0
(test-release 4, Docker cases skipped at a dead socket).

Live at the next bus restart and the next role session launch (decision
109). Installing the new unit and restarting is Mos's call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:30:53 -05:00

144 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",
].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}`;
}