From 46eb5195b17e8d0d805c1ae270bc2ef39370a7d7 Mon Sep 17 00:00:00 2001 From: etodanik Date: Tue, 3 Nov 2026 15:14:31 +0300 Subject: [PATCH 1/2] fix(hub): bundle workspace tool runner before sandbox startup Bundle posix-child.ts and its dependencies before entering the sandbox. Run the bundle with Bun so tool startup no longer needs repository-wide package discovery that the sandbox correctly blocks. This affects tool calls for design process. --- apps/hub/src/builds/posix-sandbox.test.ts | 75 ++++++++++++++++++++++- apps/hub/src/builds/posix-sandbox.ts | 18 +++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/hub/src/builds/posix-sandbox.test.ts b/apps/hub/src/builds/posix-sandbox.test.ts index ee011a64..eaecd412 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,70 @@ 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("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..6ec35a58 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. @@ -580,7 +596,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) From 450e2d24eed67ef59ecd0c3ea8b582fdb6297d60 Mon Sep 17 00:00:00 2001 From: etodanik Date: Wed, 4 Nov 2026 02:37:28 +0300 Subject: [PATCH 2/2] fix(hub): remove obsolete sandbox source read access The bundled tool runner no longer needs to read the hub source directory. Remove that allowlist entry and verify source reads are denied in the existing sandbox behavior test. --- apps/hub/src/builds/posix-sandbox.test.ts | 3 +++ apps/hub/src/builds/posix-sandbox.ts | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/hub/src/builds/posix-sandbox.test.ts b/apps/hub/src/builds/posix-sandbox.test.ts index eaecd412..68ce7356 100644 --- a/apps/hub/src/builds/posix-sandbox.test.ts +++ b/apps/hub/src/builds/posix-sandbox.test.ts @@ -82,6 +82,9 @@ test.skipIf( 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, diff --git a/apps/hub/src/builds/posix-sandbox.ts b/apps/hub/src/builds/posix-sandbox.ts index 6ec35a58..0e7a9f15 100644 --- a/apps/hub/src/builds/posix-sandbox.ts +++ b/apps/hub/src/builds/posix-sandbox.ts @@ -530,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] : []),