// 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}`; }