Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion apps/hub/src/builds/posix-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -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 [
Expand Down Expand Up @@ -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<ReturnType<typeof createPosixBuildExecutor>>
| 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<string, unknown>) =>
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,
);
20 changes: 17 additions & 3 deletions apps/hub/src/builds/posix-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] : []),
Expand Down Expand Up @@ -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(" ")}`;
Comment thread
etodanik marked this conversation as resolved.
Resolved
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)
Expand Down