forked from EduCraft/curriculum-project-hub
311 lines
14 KiB
TypeScript
311 lines
14 KiB
TypeScript
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<string, string | undefined>();
|
|
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 this runner-owned root below /var/lib before dropping into
|
|
// no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide
|
|
// temp exception cannot make a cross-project escape look contained.
|
|
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("/var/lib/")) {
|
|
throw new Error(`CPH_SANDBOX_TEST_ROOT must be below /var/lib: ${root}`);
|
|
}
|
|
const nonce = randomUUID().replaceAll("-", "");
|
|
const workspaceRoot = join(root, "workspaces");
|
|
const workspace = join(workspaceRoot, "org-a", `project_${nonce}`);
|
|
const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`);
|
|
const serviceSecret = join(root, `service-secret-${nonce}`);
|
|
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 effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt");
|
|
const denialLogPath = join(canonicalWorkspace, ".cph", "t", "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",
|
|
`if printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 31; fi`,
|
|
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
|
|
`if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`,
|
|
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 32; fi`,
|
|
`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}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
|
`test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
|
`test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
|
'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"',
|
|
`test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`,
|
|
`printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`,
|
|
`if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`,
|
|
`if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; 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 > ${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.",
|
|
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),
|
|
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(2);
|
|
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
|
|
expect(toolResults).toHaveLength(1);
|
|
const sandboxedResult = toolResults[0];
|
|
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 /);
|
|
await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n");
|
|
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<Record<string, string>>): 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<void>;
|
|
}> {
|
|
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
|
|
// Deliberately request the SDK's bypass flag. Production sets
|
|
// allowUnsandboxedCommands=false, so the flag must be ignored and the
|
|
// host-side escape sentinels must remain absent.
|
|
? bashToolEvents(bashCommand, requests, true)
|
|
: 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<void>((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<void>((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<string, unknown> & { 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<string> {
|
|
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("'", `'"'"'`)}'`;
|
|
}
|