import { execFile } from "node:child_process"; import { constants } from "node:fs"; import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { runAgent, type StreamEvent } from "../../src/agent/runner.js"; const execFileAsync = promisify(execFile); const originalEnv = new Map(); const itOnLinux = process.platform === "linux" ? it : it.skip; describe("real Claude SDK sandbox boundary", () => { const roots: string[] = []; afterEach(async () => { for (const [name, value] of originalEnv) { if (value === undefined) delete process.env[name]; else process.env[name] = value; } originalEnv.clear(); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); itOnLinux("denies sibling workspace, service files, credentials, and unsandboxed Bash while cph still works", async () => { await access("/usr/bin/bwrap", constants.X_OK); const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8"); expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m); const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]); const cphBin = cphPathOutput.trim(); await access(cphBin, constants.X_OK); // Keep CLAUDE_CODE_TMPDIR short enough for the SDK's AF_UNIX socket paths; // otherwise the SDK deliberately falls back to shared /tmp. const root = await mkdtemp("/tmp/cph-"); roots.push(root); const workspaceRoot = join(root, "workspaces"); const workspace = join(workspaceRoot, "org-a", "project-a"); const sibling = join(workspaceRoot, "org-b", "project-b"); const serviceSecret = join(root, "service-secrets", "platform.env"); await Promise.all([ mkdir(workspace, { recursive: true }), mkdir(sibling, { recursive: true }), mkdir(join(root, "service-secrets"), { recursive: true }), ]); await Promise.all([ writeFile(join(workspace, "allowed.txt"), "allowed\n"), writeFile(join(sibling, "secret.txt"), "sibling-secret\n"), writeFile(serviceSecret, "platform-secret\n"), ]); // macOS tmpdir is reached through /var -> /private/var. Exercise the // sandbox with canonical paths, matching the canonical cwd returned by // createAgentSecurityPolicy rather than relying on a host symlink alias. const canonicalWorkspace = await realpath(workspace); const canonicalSibling = await realpath(sibling); const canonicalServiceSecret = await realpath(serviceSecret); const resultPath = join(canonicalWorkspace, "sandbox-result.txt"); const siblingEscapePath = join(canonicalSibling, "escape.txt"); const unsandboxedEscapePath = join(await realpath(root), "unsandboxed-escape.txt"); setHostEnv({ CPH_BIN: cphBin, CPH_SANDBOX_EXTRA_DENY_READ: canonicalServiceSecret, DATABASE_URL: "postgresql://platform-secret", FEISHU_APP_SECRET: "feishu-secret", HUB_SESSION_SECRET: "session-secret", }); const bashCommand = [ "set -eu", `test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`, `if cat ${shellQuote(join(canonicalSibling, "secret.txt"))} >/dev/null 2>&1; then exit 21; fi`, `if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>/dev/null; then exit 22; fi`, `if cat ${shellQuote(canonicalServiceSecret)} >/dev/null 2>&1; then exit 23; fi`, 'test "${DATABASE_URL-unset}" = unset', 'test "${FEISHU_APP_SECRET-unset}" = unset', 'test "${HUB_SESSION_SECRET-unset}" = unset', 'test "${ANTHROPIC_AUTH_TOKEN-unset}" = unset', 'test "${ANTHROPIC_API_KEY-unset}" = unset', "cph --version | grep '^cph '", `printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`, ].join("\n"); const bypassCommand = `printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)}`; const stub = await startAnthropicStub(bypassCommand, bashCommand); const streamEvents: StreamEvent[] = []; try { const result = await runAgent({ prompt: "Run the supplied sandbox boundary probe.", model: "claude-sonnet-4-20250514", project: { projectId: "project-a", boundChatId: "chat-a", workspaceRoot, workspaceDir: workspace, }, systemPrompt: "Use the Bash tool exactly once, then report completion.", providerEnv: { ANTHROPIC_BASE_URL: stub.baseUrl, ANTHROPIC_AUTH_TOKEN: "provider-secret", ANTHROPIC_API_KEY: "provider-api-secret", }, tools: ["bash"], maxTurns: 3, runId: "sandbox-run", sessionId: "sandbox-session", prisma: { projectAgentLock: { update: async () => ({}) }, agentMessage: { create: async () => ({}) }, } as unknown as import("@prisma/client").PrismaClient, onStream: (event) => streamEvents.push(event), }); expect(result).toMatchObject({ status: "completed" }); expect(stub.requestCount()).toBeGreaterThanOrEqual(3); const toolResults = streamEvents.filter((event) => event.type === "tool-result"); expect(toolResults).toHaveLength(2); expect(toolResults[0]).toMatchObject({ isError: true }); const sandboxedResult = toolResults[1]; expect(sandboxedResult?.type).toBe("tool-result"); if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result"); expect(sandboxedResult.isError, sandboxedResult.result).toBe(false); await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(resultPath, "utf8")).resolves.toBe("sandbox-ok\n"); await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(join(canonicalSibling, "secret.txt"), "utf8")).resolves.toBe("sibling-secret\n"); await expect(readFile(canonicalServiceSecret, "utf8")).resolves.toBe("platform-secret\n"); } finally { await stub.close(); } }, 60_000); }); function setHostEnv(values: Readonly>): void { for (const [name, value] of Object.entries(values)) { if (!originalEnv.has(name)) originalEnv.set(name, process.env[name]); process.env[name] = value; } } async function startAnthropicStub(bypassCommand: string, sandboxedCommand: string): Promise<{ readonly baseUrl: string; readonly requestCount: () => number; readonly close: () => Promise; }> { let requests = 0; const server = createServer(async (request, response) => { try { if (request.method !== "POST" || request.url?.startsWith("/v1/messages") !== true) { response.writeHead(404).end(); return; } const body = JSON.parse(await requestBody(request)) as { readonly messages?: ReadonlyArray<{ readonly content?: unknown }>; }; requests++; const events = requests === 1 ? bashToolEvents(bypassCommand, requests, true) : requests === 2 ? bashToolEvents(sandboxedCommand, requests, false) : finalTextEvents(requests); writeAnthropicStream(response, events); } catch (error) { response.writeHead(500, { "content-type": "application/json" }); response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); } }); server.listen(0, "127.0.0.1"); await new Promise((resolve, reject) => { server.once("listening", resolve); server.once("error", reject); }); const address = server.address(); if (address === null || typeof address === "string") throw new Error("Anthropic stub did not bind an INET port"); return { baseUrl: `http://127.0.0.1:${address.port}`, requestCount: () => requests, close: () => new Promise((resolve, reject) => { server.close((error) => error === undefined ? resolve() : reject(error)); }), }; } function bashToolEvents(command: string, sequence: number, disableSandbox: boolean): AnthropicEvent[] { const messageId = `msg_tool_${sequence}`; const toolUseId = `toolu_sandbox_probe_${sequence}`; return [ messageStart(messageId), { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: toolUseId, name: "Bash", input: {} }, }, { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify({ command, ...(disableSandbox ? { dangerouslyDisableSandbox: true } : {}) }), }, }, { type: "content_block_stop", index: 0 }, messageDelta("tool_use"), { type: "message_stop" }, ]; } function finalTextEvents(sequence: number): AnthropicEvent[] { const messageId = `msg_final_${sequence}`; return [ messageStart(messageId), { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "sandbox probe complete" } }, { type: "content_block_stop", index: 0 }, messageDelta("end_turn"), { type: "message_stop" }, ]; } type AnthropicEvent = Record & { readonly type: string }; function messageStart(messageId: string): AnthropicEvent { return { type: "message_start", message: { id: messageId, type: "message", role: "assistant", model: "claude-sonnet-4-20250514", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 }, }, }; } function messageDelta(stopReason: "tool_use" | "end_turn"): AnthropicEvent { return { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: 1 }, }; } function writeAnthropicStream(response: ServerResponse, events: readonly AnthropicEvent[]): void { response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", }); for (const event of events) { response.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); } response.end(); } async function requestBody(request: IncomingMessage): Promise { const chunks: Buffer[] = []; for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); return Buffer.concat(chunks).toString("utf8"); } function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; }