forked from EduCraft/curriculum-project-hub
fix: prove Linux Agent sandbox boundary
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
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";
|
||||
@@ -25,25 +26,36 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
// 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-a");
|
||||
const sibling = join(workspaceRoot, "org-b", "project-b");
|
||||
const serviceSecret = join(root, "service-secrets", "platform.env");
|
||||
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 }),
|
||||
mkdir(join(root, "service-secrets"), { recursive: true }),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(join(workspace, "allowed.txt"), "allowed\n"),
|
||||
@@ -57,8 +69,14 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
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(await realpath(root), "unsandboxed-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,
|
||||
@@ -70,21 +88,32 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
|
||||
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 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`,
|
||||
`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 | grep '^cph '",
|
||||
`cph --version > ${shellQuote(cphVersionPath)}`,
|
||||
`printf 'sandbox-ok\\n' > ${shellQuote(resultPath)}`,
|
||||
].join("\n");
|
||||
const bypassCommand = `printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)}`;
|
||||
const stub = await startAnthropicStub(bypassCommand, bashCommand);
|
||||
const stub = await startAnthropicStub(bashCommand);
|
||||
const streamEvents: StreamEvent[] = [];
|
||||
const sdkStderr: string[] = [];
|
||||
try {
|
||||
const result = await runAgent({
|
||||
prompt: "Run the supplied sandbox boundary probe.",
|
||||
@@ -110,20 +139,33 @@ describe("real Claude SDK sandbox boundary", () => {
|
||||
agentMessage: { create: async () => ({}) },
|
||||
} as unknown as import("@prisma/client").PrismaClient,
|
||||
onStream: (event) => streamEvents.push(event),
|
||||
onSdkStderr: (data) => sdkStderr.push(data),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: "completed" });
|
||||
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
|
||||
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(2);
|
||||
expect(toolResults[0]).toMatchObject({ isError: true });
|
||||
const sandboxedResult = toolResults[1];
|
||||
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).toBe(false);
|
||||
expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false);
|
||||
await expect(access(unsandboxedEscapePath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(readFile(resultPath, "utf8")).resolves.toBe("sandbox-ok\n");
|
||||
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 {
|
||||
@@ -139,7 +181,7 @@ function setHostEnv(values: Readonly<Record<string, string>>): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function startAnthropicStub(bypassCommand: string, sandboxedCommand: string): Promise<{
|
||||
async function startAnthropicStub(bashCommand: string): Promise<{
|
||||
readonly baseUrl: string;
|
||||
readonly requestCount: () => number;
|
||||
readonly close: () => Promise<void>;
|
||||
@@ -156,10 +198,11 @@ async function startAnthropicStub(bypassCommand: string, sandboxedCommand: strin
|
||||
};
|
||||
requests++;
|
||||
const events = requests === 1
|
||||
? bashToolEvents(bypassCommand, requests, true)
|
||||
: requests === 2
|
||||
? bashToolEvents(sandboxedCommand, requests, false)
|
||||
: finalTextEvents(requests);
|
||||
// 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" });
|
||||
|
||||
@@ -128,6 +128,7 @@ describe("deployment preflight CLI", () => {
|
||||
expect(probeLog).toContain(workspaceRoot);
|
||||
expect(probeLog).toContain("service-setpriv\n");
|
||||
expect(probeLog).toContain("service-bwrap\n");
|
||||
expect(probeLog).toContain("service-socat\n");
|
||||
expect(probeLog).toContain("service-prisma\n");
|
||||
expect(probeLog).toContain("service-database\n");
|
||||
});
|
||||
@@ -141,6 +142,7 @@ describe("deployment preflight CLI", () => {
|
||||
const binDir = join(root, "bin");
|
||||
const cphBin = join(binDir, "cph");
|
||||
const bwrapBin = join(binDir, "bwrap");
|
||||
const socatBin = join(binDir, "socat");
|
||||
const pgIsReadyBin = join(binDir, "pg_isready");
|
||||
const runuserBin = join(binDir, "runuser");
|
||||
const setprivBin = join(binDir, "setpriv");
|
||||
@@ -155,6 +157,7 @@ describe("deployment preflight CLI", () => {
|
||||
await Promise.all([
|
||||
executable(cphBin, `#!/bin/sh\nprintf 'cph\\n' >> '${options.marker}'\nprintf 'cph 0.0.2\\n'\n`),
|
||||
executable(bwrapBin, `#!/bin/sh\nprintf 'service-bwrap\\n' >> '${options.marker}'\nexit 0\n`),
|
||||
executable(socatBin, `#!/bin/sh\nprintf 'service-socat\\n' >> '${options.marker}'\nexit 0\n`),
|
||||
executable(
|
||||
runuserBin,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$*" >> '${options.marker}'\n[ "$1" = "-u" ] || exit 91\nshift 2\n[ "$1" = "--" ] || exit 92\nshift\nexec "$@"\n`,
|
||||
@@ -226,6 +229,8 @@ describe("deployment preflight CLI", () => {
|
||||
options.workspaceRoot,
|
||||
"--bwrap-bin",
|
||||
bwrapBin,
|
||||
"--socat-bin",
|
||||
socatBin,
|
||||
"--pg-isready-bin",
|
||||
pgIsReadyBin,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAgentSdkStderrSink } from "../../src/agent/diagnostics.js";
|
||||
|
||||
describe("agent SDK stderr diagnostics", () => {
|
||||
it("redacts a credential split across chunks and correlates the log", () => {
|
||||
const warn = vi.fn();
|
||||
const sink = createAgentSdkStderrSink({
|
||||
logger: { warn },
|
||||
runId: "run-1",
|
||||
projectId: "project-1",
|
||||
sensitiveValues: ["provider-secret"],
|
||||
});
|
||||
|
||||
sink.write("sandbox startup token=provider-");
|
||||
sink.write("secret\nremaining diagnostic");
|
||||
sink.flush();
|
||||
|
||||
const serialized = JSON.stringify(warn.mock.calls);
|
||||
expect(serialized).not.toContain("provider-secret");
|
||||
expect(serialized).toContain("[REDACTED]");
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: "run-1", projectId: "project-1" }),
|
||||
"agent SDK stderr",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds line length, line count, and an unterminated line", () => {
|
||||
const warn = vi.fn();
|
||||
const sink = createAgentSdkStderrSink({
|
||||
logger: { warn },
|
||||
runId: "run-2",
|
||||
projectId: "project-2",
|
||||
sensitiveValues: [],
|
||||
});
|
||||
|
||||
sink.write(`${"x".repeat(3_000)}\n`);
|
||||
sink.write(Array.from({ length: 30 }, (_, index) => `line-${index}`).join("\n"));
|
||||
sink.flush();
|
||||
|
||||
expect(warn.mock.calls.length).toBeLessThanOrEqual(21);
|
||||
const diagnostics = warn.mock.calls
|
||||
.map(([bindings]) => (bindings as { sdkStderr?: string }).sdkStderr)
|
||||
.filter((value): value is string => value !== undefined);
|
||||
expect(diagnostics.every((value) => value.length <= 2_000)).toBe(true);
|
||||
expect(warn.mock.calls.some(([, message]) => String(message).includes("limit reached"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe("agent subprocess security policy", () => {
|
||||
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
|
||||
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
expect(policy.env.CLAUDE_CODE_TMPDIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t"));
|
||||
|
||||
expect(policy.sandbox).toMatchObject({
|
||||
enabled: true,
|
||||
@@ -81,6 +81,24 @@ describe("agent subprocess security policy", () => {
|
||||
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
|
||||
});
|
||||
|
||||
it("keeps the short SDK socket directory inside the project workspace", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
});
|
||||
|
||||
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t"));
|
||||
const absoluteTemp = join(await realpath(workspace), ".cph", "t");
|
||||
expect(policy.env.TMPDIR).toBe(absoluteTemp);
|
||||
expect(policy.env.TMP).toBe(absoluteTemp);
|
||||
expect(policy.env.TEMP).toBe(absoluteTemp);
|
||||
expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]);
|
||||
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
|
||||
expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//));
|
||||
});
|
||||
|
||||
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
|
||||
const { root, workspaceRoot } = await makeWorkspace();
|
||||
const outside = join(root, "outside");
|
||||
|
||||
Reference in New Issue
Block a user