Row 90, round 2. Dewey built it, Filbert approved it (comment 28068) on manifest 95611274 (21 paths, baseb8539480). 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 onc047d028plus 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]>
145 lines
7.9 KiB
JavaScript
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}`;
|
|
}
|