diff --git a/apps/hub/src/builds/posix-sandbox.test.ts b/apps/hub/src/builds/posix-sandbox.test.ts index ee011a64..68ce7356 100644 --- a/apps/hub/src/builds/posix-sandbox.test.ts +++ b/apps/hub/src/builds/posix-sandbox.test.ts @@ -1,5 +1,11 @@ import { expect, test } from "bun:test"; -import { publicHostName } from "./posix-sandbox.ts"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SandboxManager } from "@anthropic-ai/sandbox-runtime"; +import { rgPath } from "@vscode/ripgrep"; +import { buildPackageLayout } from "./package-layout.ts"; +import { createPosixBuildExecutor, publicHostName } from "./posix-sandbox.ts"; test("the open network admits only public names, however a private address is spelled", () => { for (const host of [ @@ -28,3 +34,73 @@ test("the open network admits only public names, however a private address is sp ]) expect([host, publicHostName(host)]).toEqual([host, true]); }); + +// Exercise real isolation where the host has the required OS sandbox tools. +test.skipIf( + !SandboxManager.isSupportedPlatform() || + SandboxManager.checkDependencies({ command: rgPath }).errors.length > 0, +)( + "workspace output is writable while host files and approved inputs stay protected", + async () => { + const root = await mkdtemp(join(tmpdir(), "builder-sandbox-test-")); + let executor: + | Awaited> + | undefined; + try { + const workspace = join(root, "workspace"); + const outside = join(root, "host-only.txt"); + await mkdir(workspace); + await writeFile(outside, "host-only-sentinel"); + await writeFile(join(workspace, "brief.md"), "Approved brief"); + const layout = await buildPackageLayout(); + executor = await createPosixBuildExecutor( + workspace, + { commandTimeoutMs: 10_000, maxToolResultBytes: 10_000 }, + layout, + { readOnly: ["brief.md"] }, + ); + const run = (name: string, args: Record) => + executor!.run( + { id: crypto.randomUUID(), name, arguments: args }, + new AbortController().signal, + ); + const written = await run("write_file", { + path: "output.txt", + content: "Workspace output", + }); + expect(written.isError).not.toBe(true); + expect(await readFile(join(workspace, "output.txt"), "utf8")).toBe( + "Workspace output", + ); + const output = await run("read_file", { path: "output.txt" }); + expect(output.isError).not.toBe(true); + expect(output.content).toContain("Workspace output"); + const brief = await run("read_file", { path: "brief.md" }); + expect(brief.isError).not.toBe(true); + expect(brief.content).toContain("Approved brief"); + + const denied = await run("read_file", { path: outside }); + expect(denied.isError).toBe(true); + expect(denied.content).not.toContain("host-only-sentinel"); + expect((await run("read_file", { path: layout.childPath })).isError).toBe( + true, + ); + expect( + (await run("write_file", { path: "brief.md", content: "changed" })) + .isError, + ).toBe(true); + expect( + (await run("write_file", { path: outside, content: "changed" })) + .isError, + ).toBe(true); + expect(await readFile(join(workspace, "brief.md"), "utf8")).toBe( + "Approved brief", + ); + expect(await readFile(outside, "utf8")).toBe("host-only-sentinel"); + } finally { + await executor?.dispose(); + await rm(root, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/apps/hub/src/builds/posix-sandbox.ts b/apps/hub/src/builds/posix-sandbox.ts index 3478d90d..0e7a9f15 100644 --- a/apps/hub/src/builds/posix-sandbox.ts +++ b/apps/hub/src/builds/posix-sandbox.ts @@ -432,6 +432,22 @@ export async function createPosixBuildExecutor( const nodeShim = join(shimDirectory, "node"); await writeFile(nodeShim, `#!/bin/sh\nexec ${quote(executable)} "$@"\n`); await chmod(nodeShim, 0o755); + // Resolve workspace packages before isolation: Bun's runtime lookup scans repository paths + // the sandbox must keep hidden. The bundled runner needs no host package discovery. + const toolChild = join(shimDirectory, "posix-child.js"); + const toolBuild = await Bun.build({ + entrypoints: [layout.childPath], + outdir: shimDirectory, + naming: "posix-child.js", + target: "bun", + conditions: ["intx-src"], + packages: "bundle", + minify: true, + }); + if (!toolBuild.success) + throw new BuildExecutionError( + `tool_runner_build_failed: ${toolBuild.logs.join("; ")}`, + ); // An agent that can read check-app's source reads it instead of its own work. Each command is // compiled to an executable, which macOS runs with its reads denied (Seatbelt checks // process-exec apart from file-read*), so the source never sits where the agent can read it. @@ -514,8 +530,6 @@ export async function createPosixBuildExecutor( executable, shimDirectory, ...layout.dependencyRoots, - // Bun lists the entry module's directory before loading it. - layout.assetDirectory, home, ...(options.browsers ? [options.browsers] : []), ...(sockets ? [sockets] : []), @@ -580,7 +594,7 @@ export async function createPosixBuildExecutor( return linux ? `exec ${wrappedCommand}` : wrappedCommand; }; stage = "wrap"; - const childCommand = `exec ${[executable, "--no-env-file", "--conditions=intx-src", layout.childPath, cwd].map(quote).join(" ")}`; + const childCommand = `exec ${[executable, "--no-env-file", toolChild, cwd].map(quote).join(" ")}`; const wrapped = await wrapShell(childCommand); // Agent tool calls use `wrapped` above with Unix sockets blocked by seccomp; // sidecar-owned helpers wrapped below (dev app relay, browser, check-app)