import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createAgentSecurityPolicy } from "../../src/agent/security.js"; describe("agent subprocess security policy", () => { const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); const policy = await createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: workspace, providerProxyEnv: { ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }, hostEnv: { PATH: "/usr/local/bin:/usr/bin:/bin", LANG: "C.UTF-8", CPH_BIN: "/usr/local/bin/cph", DATABASE_URL: "postgresql://platform-secret", FEISHU_APP_SECRET: "feishu-secret", HUB_SESSION_SECRET: "session-secret", }, }); const canonicalWorkspace = await realpath(workspace); expect(policy.cwd).toBe(canonicalWorkspace); expect(policy.env).toMatchObject({ PATH: "/usr/local/bin:/usr/bin:/bin", LANG: "C.UTF-8", CPH_BIN: "/usr/local/bin/cph", ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", }); expect(policy.env).not.toHaveProperty("DATABASE_URL"); expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET"); 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).toBe(policy.env.TMPDIR); expect(policy.env.TMP).toBe(policy.env.TMPDIR); expect(policy.env.TEMP).toBe(policy.env.TMPDIR); expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t")); expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56); expect(policy.skillIds).toEqual([]); expect(policy.skillPluginRoot).toBeUndefined(); expect(policy.sandbox).toMatchObject({ enabled: true, failIfUnavailable: true, autoAllowBashIfSandboxed: true, allowUnsandboxedCommands: false, filesystem: { allowWrite: [canonicalWorkspace], denyRead: ["/"], allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), }, credentials: { envVars: expect.arrayContaining([ { name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" }, { name: "ANTHROPIC_API_KEY", mode: "deny" }, ]), }, }); }); it("rejects provider environment keys outside the explicit protocol", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); await expect(createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: workspace, providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", DATABASE_URL: "must-not-cross-boundary", }, hostEnv: { PATH: "/usr/bin:/bin" }, })).rejects.toThrow("unsupported provider environment variable: DATABASE_URL"); }); it("keeps every SDK temp variable on a short path inside the project workspace", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); const policy = await createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: workspace, hostEnv: { PATH: "/usr/bin:/bin" }, }); const canonicalWorkspace = await realpath(workspace); const temp = policy.env.TMPDIR!; expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp); expect(policy.env.TMP).toBe(temp); expect(policy.env.TEMP).toBe(temp); expect(temp).toBe(join(canonicalWorkspace, ".cph", "t")); expect(Buffer.byteLength(temp)).toBeLessThanOrEqual(56); expect(policy.sandbox.filesystem.allowWrite).toEqual([canonicalWorkspace]); expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]); expect(policy.sandbox.filesystem.allowRead).toContain(canonicalWorkspace); }); it("fails before spawning Claude when the workspace makes bridge socket paths unsafe", async () => { const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "hub-agent-long-")); roots.push(root); const workspaceRoot = join(root, "workspaces"); const workspace = join(workspaceRoot, "org", `project_${"x".repeat(80)}`); await mkdir(workspace, { recursive: true }); await expect(createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: workspace, hostEnv: { PATH: "/usr/bin:/bin" }, })).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets"); }); it("rejects a project workspace whose real path escapes the configured workspace root", async () => { const { root, workspaceRoot } = await makeWorkspace(); const outside = join(root, "outside"); const linked = join(workspaceRoot, "o", "linked-project"); await mkdir(outside); await symlink(outside, linked); await expect(createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: linked, providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, hostEnv: { PATH: "/usr/bin:/bin" }, })).rejects.toThrow("project workspace contains a symlink"); }); it("rejects a project workspace symlink whose target is a sibling under the same root", async () => { const { workspaceRoot } = await makeWorkspace(); const sibling = join(workspaceRoot, "o", "sibling-project"); const linked = join(workspaceRoot, "o", "linked-project"); await mkdir(sibling); await symlink(sibling, linked); await expect(createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: linked, providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, hostEnv: { PATH: "/usr/bin:/bin" }, })).rejects.toThrow("symlink"); }); async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> { const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "h-")); roots.push(root); const workspaceRoot = join(root, "w"); const workspace = join(workspaceRoot, "o", "p"); await mkdir(workspace, { recursive: true }); return { root, workspaceRoot, workspace }; } }); function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }