Files
stack/packages/seat/tests/sandbox.test.mjs
T
jason.woltjeandClaude Opus 5.5 4da6815293 review loop: working repositories, git socket, push and review verdicts, row 93 (#1587)
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]>
2026-10-13 10:48:34 -05:00

274 lines
14 KiB
JavaScript

// The sandbox a role session runs in (src/sandbox.mjs, #1586). A real
// session, the runner in its bwrap sandbox against an in-process broker,
// answers each message by running the body in /bin/sh; the tests read the
// answers. Stand-ins for the founder's files sit in the test's own
// directory, plus a file in the host's /dev/shm and a SysV shared memory
// segment, each checked to exist on the host first, so a refusal means the
// sandbox hid it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { Broker } from "../../bus/src/broker.mjs";
import { serve } from "../../bus/src/server.mjs";
import { Store } from "../../bus/src/store.mjs";
import { SOURCES } from "../../harness/src/bundle.mjs";
import { startTimeOf } from "../src/proc.mjs";
import { newRun, RUNNER, runnerOf, sessionEnv, spawnSession, writeSessionFile } from "../src/session.mjs";
import { binds, prepareRun, REPO, RUNNER_FILES, sandboxArgv, sandboxProblem } from "../src/sandbox.mjs";
const FAKE = new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url).pathname;
const skip = sandboxProblem() ?? false;
function scratch(t) {
const root = realpathSync(mkdtempSync(join(tmpdir(), "mosaic-sandbox-")));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
}
test("RUNNER_FILES is exactly what the runner and the harness sources it starts import", () => {
const seen = new Set();
const walk = (file) => {
if (seen.has(file)) return;
seen.add(file);
for (const [, rel] of readFileSync(file, "utf8").matchAll(/(?:from|import\()\s*["'](\.\.?\/[^"']+)["']/g)) walk(resolve(dirname(file), rel));
};
for (const file of [RUNNER, ...Object.values(SOURCES)]) walk(file);
assert.deepEqual([...seen].sort(), [...RUNNER_FILES].sort());
});
test("sandboxProblem names a missing bwrap and a bwrap that fails", (t) => {
const root = scratch(t);
assert.equal(sandboxProblem({ bwrap: join(root, "nope") }), `${join(root, "nope")} is missing`);
const bad = join(root, "bwrap");
writeFileSync(bad, "#!/bin/sh\necho 'bwrap: No permissions to create new namespace' >&2\nexit 1\n");
chmodSync(bad, 0o755);
assert.equal(sandboxProblem({ bwrap: bad }), `${bad} failed: bwrap: No permissions to create new namespace`);
if (!skip) assert.equal(sandboxProblem(), null);
});
test("every bind says why; the bundle and session file stay read-only over the run directory", (t) => {
const root = scratch(t);
const runDir = join(root, "run");
const list = binds({ runDir, workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "claude-code", brokerSocket: join(root, "bus.sock") });
assert.ok(list.every((b) => b.why));
const at = (src) => list.findIndex((b) => b.src === src);
assert.equal(list[at(runDir)].kind, "rw");
for (const f of ["bundle", "session.json"]) {
assert.equal(list[at(join(runDir, f))].kind, "ro");
assert.ok(at(join(runDir, f)) > at(runDir), `${f} is mounted over the run directory`);
}
assert.deepEqual(list.at(-1), { kind: "rw", src: join(runDir, "tmp"), dest: "/tmp", why: list.at(-1).why });
assert.equal(at(join(root, "launch.sock")), -1, "no launch socket for a role that may not launch");
assert.equal(at(join(root, "git.sock")), -1, "no git socket for a role that neither pushes nor reviews");
assert.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "Claude Code gets no node_modules");
});
// Row 93 (#1587): the git socket is bound read-only, before the workspace, when the launcher passes one.
test("the host's git socket is bound only when given, read-only and before the writable binds", (t) => {
const root = scratch(t);
const args = { runDir: join(root, "run"), workspace: join(root, "ws"), adapter: join(root, "a.sh"), harness: "pi", brokerSocket: join(root, "bus.sock") };
const list = binds({ ...args, gitSocket: join(root, "git.sock") });
const at = (src) => list.findIndex((b) => b.src === src);
assert.equal(list[at(join(root, "git.sock"))].kind, "ro");
assert.match(list[at(join(root, "git.sock"))].why, /push_working and fetch_review/);
assert.ok(at(join(root, "git.sock")) < at(join(root, "ws")));
assert.equal(binds(args).findIndex((b) => b.src === join(root, "git.sock")), -1);
assert.ok(RUNNER_FILES.includes(join(REPO, "packages/harness/src/git-tools.mjs")));
});
// The founder's files and the other sessions' state, as stand-ins. The
// /dev/shm file and the SysV segment are the host's, made for this test and
// removed after it.
function standIns(t, root, dataRoot) {
const s = {
secret: join(root, "home", ".config", "mosaic-dev", "secrets", "stand-in.env"),
mosaic: join(root, "home", ".mosaic", "fleet", "token"),
otherWs: join(root, "ws-other"),
shm: join("/dev/shm", basename(root)),
};
writeFileSync(s.shm, "stand-in\n", { mode: 0o600 });
t.after(() => rmSync(s.shm, { force: true }));
const ipc = spawnSync("/usr/bin/ipcmk", ["-M", "4096"], { encoding: "utf8" });
assert.equal(ipc.status, 0, ipc.stderr);
s.segment = Number(ipc.stdout.trim().split(" ").at(-1));
t.after(() => spawnSync("/usr/bin/ipcrm", ["-m", String(s.segment)]));
for (const f of [s.secret, s.mosaic]) {
mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
writeFileSync(f, "stand-in\n", { mode: 0o600 });
}
mkdirSync(s.otherWs);
writeFileSync(join(s.otherWs, "notes.md"), "another instance's work\n");
const other = newRun(dataRoot, "acme");
writeFileSync(join(other.runDir, "session.json"), "{}\n", { mode: 0o600 });
s.otherRun = other.runDir;
return s;
}
// [what, shell command, may the session do it, the output it must give].
function checks({ s, ws, runDir, run, dataRoot, store }) {
return [
["HOME is the run's home/, empty", `printf %s "$HOME:"; ls -A "$HOME"`, true, `${join(runDir, "home")}:`],
["TMPDIR is the private /tmp", `printf %s "$TMPDIR"`, true, "/tmp"],
["read a stand-in ~/.config/mosaic-dev/secrets", `cat '${s.secret}'`, false],
["read a stand-in ~/.mosaic", `cat '${s.mosaic}'`, false],
["read another instance's workspace", `cat '${s.otherWs}/notes.md'`, false],
["read another run directory", `cat '${s.otherRun}/session.json'`, false],
["read the data root's store", `cat '${store}' >/dev/null`, false],
["the data root holds only this run", `ls -A '${dataRoot}'; ls -A '${dataRoot}/launches/acme'`, true, `launches\n${run}`],
["read the checkout outside the runner's files", `cat '${join(REPO, "AGENTS.md")}' >/dev/null`, false],
["read the seat's own source", `cat '${join(REPO, "packages", "seat", "src", "session.mjs")}' >/dev/null`, false],
["read a stand-in in the host's /dev/shm", `cat '${s.shm}'`, false],
["see the test's own pid in /proc", `test -e /proc/${process.pid}`, false],
["read its own IPC namespace's segment list", "test -r /proc/sysvipc/shm", true],
["see a SysV segment the host made", `awk '$2 == ${s.segment} { f = 1 } END { exit !f }' /proc/sysvipc/shm`, false],
["write to /", "touch /probe", false],
["write beside the workspace", `touch '${dirname(ws)}/probe'`, false],
["write in the data root", `touch '${dataRoot}/probe'`, false],
["write a runner file", `test -w '${RUNNER}'`, false],
["write the run's bundle", `test -w '${runDir}/bundle/prompt.md' || touch '${runDir}/bundle/probe'`, false],
["write the session file", `test -w '${runDir}/session.json'`, false],
["write and read its workspace", `echo ws > '${ws}/probe' && cat '${ws}/probe'`, true, "ws"],
["write and read its run directory", `echo run > '${runDir}/probe' && cat '${runDir}/probe'`, true, "run"],
["write and read /tmp", "echo tmp > /tmp/probe && cat /tmp/probe", true, "tmp"],
["run node", `'${process.execPath}' -e 'console.log(6 * 7)'`, true, "42"],
["run pi, the session's harness", `'${join(REPO, "node_modules", ".bin", "pi")}' --version >/dev/null`, true],
];
}
// The checks whose answer isn't the expected one, by name.
async function wrong(table, sh) {
const out = [];
for (const [what, cmd, may, expect] of table) {
const r = await sh(cmd);
if ((r.rc === 0) !== may || (may && expect !== undefined && r.out !== expect)) out.push(what);
}
return out;
}
// A real session for coder, its adapter a shell that runs each message's
// body, or the fake adapter for a TOOL message.
// expose(standIns) names extra read-only binds, for the test that breaks
// the sandbox on purpose.
async function session(t, { expose = () => [] } = {}) {
const root = scratch(t);
const dataRoot = join(root, "data");
mkdirSync(dataRoot, { mode: 0o700 });
const store = new Store(dataRoot);
const broker = new Broker({
store,
businesses: {
acme: {
id: "acme",
human: "jason",
arbiters: { technical: "pm", delivery: "pm" },
roles: {
pm: { authority: { withinRole: ["message.send"], crossRole: [] } },
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
},
},
},
});
const brokerSocket = join(root, "bus.sock");
const server = await serve({ broker, path: brokerSocket });
t.after(async () => {
await server.close().catch(() => {});
store.close();
});
const { run, runDir } = newRun(dataRoot, "acme");
const s = standIns(t, root, dataRoot);
const bundle = join(runDir, "bundle");
const ws = join(root, "ws");
mkdirSync(bundle, { mode: 0o700 });
mkdirSync(ws);
writeFileSync(join(bundle, "prompt.md"), "# test\n");
writeFileSync(join(bundle, "policy.json"), JSON.stringify({ harness: "pi", workspace: ws, tools: [], typed: [], network: "none", needs: [], credentials: [] }));
writeFileSync(join(bundle, "tools.json"), "[]");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, [
`body=$(printf '%s\\n' "$MOSAIC_REQUEST" | sed '1,/^$/d')`,
`case $body in TOOL\\ *) exec '${process.execPath}' '${FAKE}' ;; esac`,
`out=$(cd "$MOSAIC_WORKSPACE" && /bin/sh -c "$body" 2>&1); rc=$?`,
`printf 'rc=%s\\n%s' "$rc" "$out"`,
`echo done > "$MOSAIC_TURN_MARKER"`,
"",
].join("\n"), { mode: 0o644 });
writeSessionFile(runDir, {
business: "acme", instance: "coder", run, launchedBy: "pm", harness: "pi", provider: "fake", model: "fake-model",
brokerSocket, launchSocket: join(root, "launch.sock"), workspace: ws, sessionDir: join(runDir, "session"), adapter,
bundle: { prompt: join(bundle, "prompt.md"), policy: join(bundle, "policy.json"), tools: join(bundle, "tools.json") },
toolSocket: join(runDir, "tools.sock"), turnMarker: join(runDir, "turn.done"), pollInterval: 50,
});
const paths = { workspace: ws, adapter, harness: "pi", brokerSocket, readOnly: [FAKE, ...expose(s)] };
const child = spawnSession({ runDir, env: sessionEnv({ HOME: process.env.HOME, PATH: process.env.PATH }, run), paths });
const exited = new Promise((r) => child.once("close", (code, signal) => r({ code, signal })));
t.after(async () => {
try {
process.kill(child.pid, "SIGTERM");
} catch {}
await exited;
});
await runnerOf(child);
const cap = broker.bindLaunch({ business: "acme", role: "coder", run, harness: "pi", pid: child.pid, startTime: startTimeOf(child.pid) });
const pm = broker.bindLaunch({ business: "acme", role: "pm", run: "pm-run", harness: "pi" });
broker.request(pm, { verb: "role.claim" });
child.stdin.end(`${JSON.stringify({ cap })}\n`);
const ask = async (body) => {
const sent = broker.request(pm, { verb: "message.send", args: { to: "coder", body, class: "REQUEST" } });
const end = Date.now() + 15000;
while (Date.now() < end) {
const reply = broker.request(pm, { verb: "message.receive" }).find((m) => m.in_reply_to === sent.id);
if (reply) return reply.body;
await new Promise((r) => setTimeout(r, 25));
}
throw new Error(`no answer to ${body}`);
};
const sh = async (cmd) => {
const m = (await ask(cmd)).match(/^rc=(\d+)(?:\n([\s\S]*))?$/);
assert.ok(m, `the session's shell answered ${cmd}`);
return { rc: Number(m[1]), out: m[2] ?? "" };
};
const facts = { s, ws, runDir, run, dataRoot, store: store.path, root, adapter, brokerSocket };
for (const f of [s.secret, s.mosaic, s.shm, join(s.otherWs, "notes.md"), join(s.otherRun, "session.json"), store.path, join(REPO, "AGENTS.md"), `/proc/${process.pid}`]) {
assert.ok(existsSync(f), `${f} exists on the host`);
}
const listed = spawnSync("awk", [`$2 == ${s.segment} { f = 1 } END { exit !f }`, "/proc/sysvipc/shm"]);
assert.equal(listed.status, 0, `segment ${s.segment} is listed on the host`);
return { ...facts, ask, sh };
}
test("a confined session's shell reaches its own workspace and run directory and nothing of the founder's or another session's", { skip, timeout: 60000 }, async (t) => {
const x = await session(t);
assert.deepEqual(await wrong(checks(x), x.sh), []);
// The private /tmp is the run's tmp/ on the host, not the host's /tmp.
assert.equal(readFileSync(join(x.runDir, "tmp", "probe"), "utf8"), "tmp\n");
assert.equal(readFileSync(join(x.ws, "probe"), "utf8"), "ws\n");
// The harness reaches its tool socket: the refusal is the tool server's,
// not a failed connect.
assert.equal(await x.ask("TOOL no_such_tool {}"), "refused: unknown-tool");
});
test("a bind that exposes a stand-in, or a missing workspace bind, fails the matching check", { skip, timeout: 60000 }, async (t) => {
const x = await session(t, { expose: (s) => [s.secret, s.mosaic] });
assert.deepEqual(await wrong(checks(x), x.sh), ["read a stand-in ~/.config/mosaic-dev/secrets", "read a stand-in ~/.mosaic"]);
// The same checks in a sandbox started directly from binds(), without the
// session: all pass with every bind, and the workspace check fails
// without the workspace's.
const all = binds({ runDir: x.runDir, workspace: x.ws, adapter: x.adapter, harness: "pi", brokerSocket: x.brokerSocket });
const direct = (list) => async (cmd) => {
const argv = sandboxArgv(list, x.runDir, ["/bin/sh", "-c", cmd]);
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
return { rc: r.status, out: (r.stdout + r.stderr).replace(/\n$/, "") };
};
prepareRun(x.runDir);
assert.deepEqual(await wrong(checks(x), direct(all)), []);
assert.deepEqual(await wrong(checks(x), direct(all.filter((b) => b.src !== x.ws))), ["write and read its workspace"]);
});