import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { access, mkdir, 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); await access("/usr/bin/socat", constants.X_OK); const noNewPrivilegesStatus = await readFile("/proc/self/status", "utf8"); expect(noNewPrivilegesStatus).toMatch(/^NoNewPrivs:\s+1$/m); expect(noNewPrivilegesStatus).toMatch(/^CapEff:\s+0+$/m); expect(process.getuid?.()).toBeGreaterThan(0); const { stdout: cphPathOutput } = await execFileAsync("/usr/bin/which", ["cph"]); const cphBin = cphPathOutput.trim(); await access(cphBin, constants.X_OK); // CI provisions a deliberately short runner-owned root before dropping // into no_new_privs. Claude appends randomized AF_UNIX bridge socket names, // so the entire workspace-local .cph/t prefix must stay within its budget. const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim(); if (configuredTestRoot === undefined || configuredTestRoot === "") { throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof"); } const root = await realpath(configuredTestRoot); if (!root.startsWith("/") || Buffer.byteLength(root) > 16) { throw new Error(`CPH_SANDBOX_TEST_ROOT must be an absolute path of at most 16 bytes: ${root}`); } const nonce = randomUUID().replaceAll("-", ""); const workspaceRoot = join(root, "w"); const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`); const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`); const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`); roots.push(workspace, sibling, serviceSecret); await Promise.all([ mkdir(workspace, { recursive: true }), mkdir(sibling, { 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 cphVersionPath = join(canonicalWorkspace, "cph-version.txt"); const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log"); const siblingEscapePath = join(canonicalSibling, "escape.txt"); const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`); const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`; const hostVarTmpEscapePath = `/var/tmp/cph-host-temp-${nonce}`; roots.push(unsandboxedEscapePath, hostTmpEscapePath, hostVarTmpEscapePath); 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", `printf 'ephemeral\\n' > ${shellQuote(unsandboxedEscapePath)}`, `test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`, `if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`, `printf 'ephemeral\\n' > ${shellQuote(siblingEscapePath)}`, `if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`, `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`, 'test "${TMPDIR-unset}" = "${TMP-unset}"', 'test "${TMPDIR-unset}" = "${TEMP-unset}"', 'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"', 'test "${#TMPDIR}" -le 56', `case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) :;; *) exit 35;; esac`, `printf 'sdk-temp\\n' > "$TMPDIR/effective-temp.txt"`, `printf 'ephemeral\\n' > ${shellQuote(hostTmpEscapePath)}`, `printf 'ephemeral\\n' > ${shellQuote(hostVarTmpEscapePath)}`, '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 > ${shellQuote(cphVersionPath)}`, `printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`, ].join("\n"); const stub = await startAnthropicStub(bashCommand); const streamEvents: StreamEvent[] = []; const sdkStderr: string[] = []; 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.", providerProxyEnv: { ANTHROPIC_BASE_URL: stub.baseUrl, ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }, 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), onSdkStderr: (data) => sdkStderr.push(data), }); expect( result.status, [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"), ).toBe("completed"); expect(stub.requestCount()).toBeGreaterThanOrEqual(3); const toolResults = streamEvents.filter((event) => event.type === "tool-result"); expect(toolResults).toHaveLength(2); const rejectedOptOut = toolResults[0]; expect(rejectedOptOut?.type).toBe("tool-result"); if (rejectedOptOut?.type !== "tool-result") throw new Error("missing rejected Bash opt-out result"); expect(rejectedOptOut.isError).toBe(true); expect(rejectedOptOut.result).toContain("requires every Bash command to remain sandboxed"); 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}\n${sdkStderr.join("")}`).toBe(false); await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); const resultContents = await readFile(resultPath, "utf8").catch((error: unknown) => { throw new Error(`sandbox result file missing after tool success:\n${sandboxedResult.result}`, { cause: error }); }); expect(resultContents).toBe("sandbox-ok\n"); await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /); const denialLog = await readFile(denialLogPath, "utf8"); expect(denialLog).not.toContain("sibling-secret"); expect(denialLog).not.toContain("platform-secret"); await expect(access(siblingEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(access(hostTmpEscapePath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(access(hostVarTmpEscapePath)).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(bashCommand: 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 // The host hook must reject the SDK's per-call sandbox bypass before // any command starts. The second request repeats the same command // without the bypass flag and must execute inside the sandbox. ? bashToolEvents(bashCommand, requests, true) : requests === 2 ? bashToolEvents(bashCommand, 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("'", `'"'"'`)}'`; }