Commits move as v2 bundles over bus-host/git.sock (decision 135). The bus
host keeps one bare working repository per business and project, seeds main
from the project root under a per-repository lock, fetches a pushed bundle
with fsck into a temp ref, requires history shared with main, and moves the
task's work ref only as a fast-forward. Host and runner git run with no
system or global config, hooks at /dev/null and local transports only. The
socket binds only for roles holding git.push.working or review.verdict.
The broker records a push as the action.allowed for git.push.working with
its commit, takes review.request for the latest push and one review.verdict
per request, and refuses task.close on a pushed task without an approve
verdict for the latest push. recordEvent reserves both shapes, so agents
can't forge either.
Reviewed by Dewey (28196) and Sage (28197), both approve. Gate on 7a729aef:
14 node suites and 9 scripts green.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
150 lines
8.3 KiB
JavaScript
150 lines
8.3 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,
|
|
// the launch socket for the role that may launch, and the
|
|
// host's git socket for a role holding git.push.working or
|
|
// review.verdict (#1587)
|
|
// 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/git-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 git socket (null for a role
|
|
// that neither pushes nor reviews), 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, gitSocket = 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" });
|
|
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review" });
|
|
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}`;
|
|
}
|