// 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.ok(!list.some((b) => b.src === realpathSync(join(REPO, "node_modules"))), "Claude Code gets no node_modules"); }); // 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"]); });