import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { build } from "esbuild"; import { afterEach, describe, expect, test } from "vitest"; import { buildSandboxCommand } from "../src/agents/sandbox/launcher.js"; import { decodeSingleFrame } from "../src/agents/sandbox/protocol.js"; const temporaryDirectories: string[] = []; const servers: net.Server[] = []; afterEach(async () => { delete process.env.SANDBOX_CANARY_SECRET; await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); }); describe("disposable model sandbox", () => { test("black-box canary observes only scratch storage, the broker socket, and its own process", async () => { const brokerDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "thoughtstream-sandbox-canary-")); temporaryDirectories.push(brokerDirectory); const canarySocket = path.join(brokerDirectory, "canary.sock"); const server = net.createServer((socket) => { socket.once("data", (chunk) => { if (chunk.toString("utf8") === "ping") socket.end("pong"); else socket.destroy(); }); }); servers.push(server); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(canarySocket, () => resolve()); }); await fs.chmod(canarySocket, 0o666); await fs.chmod(brokerDirectory, 0o755); process.env.SANDBOX_CANARY_SECRET = "must-not-cross-the-boundary"; const canaryBundle = path.join(brokerDirectory, "canary.cjs"); await build({ entryPoints: [path.resolve("test/fixtures/sandbox-canary.ts")], bundle: true, platform: "node", format: "cjs", target: "node22", outfile: canaryBundle, logLevel: "silent", }); const sandbox = buildSandboxCommand(canaryBundle, brokerDirectory, process.execPath); const child = spawn(sandbox.command, sandbox.args, { stdio: ["pipe", "pipe", "pipe"], env: { PATH: "/usr/bin:/bin", LANG: "C" }, detached: true, }); child.stdin.end(); const stdout: Buffer[] = []; const stderr: Buffer[] = []; child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); const exitCode = await new Promise((resolve, reject) => { child.once("error", reject); child.once("exit", resolve); }); expect(exitCode, Buffer.concat(stderr).toString("utf8")).toBe(0); const canary = decodeSingleFrame(Buffer.concat(stdout), 64 * 1024) as Record; expect(canary).toEqual({ uid: 65_534, gid: 65_534, cwd: "/tmp", environmentKeys: ["HOME", "LANG", "PATH", "THOUGHTSTREAM_BROKER_SOCKET", "TMPDIR"], secretEnvironmentAbsent: true, homeDenied: true, repoDenied: true, etcPasswdDenied: true, hostProcDenied: true, resourceLimits: { "Max address space": "17179869184 17179869184 bytes", "Max core file size": "0 0 bytes", "Max cpu time": "20 20 seconds", "Max file size": "8388608 8388608 bytes", "Max open files": "64 64 files", }, heapLimitBytes: expect.any(Number), runtimeWriteDenied: true, scratchWritten: true, tcpDenied: true, dnsDenied: true, udpDenied: true, brokerUnixSocketAllowed: true, childProcessDenied: true, workerThreadDenied: true, wasiDenied: true, nativeAddonDenied: true, }); expect(canary.heapLimitBytes).toEqual(expect.any(Number)); expect(canary.heapLimitBytes as number).toBeLessThanOrEqual(384 * 1024 * 1024); }, 15_000); });