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({ 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(join(".cph", "t")); 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({ 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 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"); const linked = join(workspaceRoot, "org", "linked-project"); await mkdir(outside); await symlink(outside, linked); await expect(createAgentSecurityPolicy({ 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, "org", "sibling-project"); const linked = join(workspaceRoot, "org", "linked-project"); await mkdir(sibling); await symlink(sibling, linked); await expect(createAgentSecurityPolicy({ 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(tmpdir(), "hub-agent-security-")); roots.push(root); const workspaceRoot = join(root, "workspaces"); const workspace = join(workspaceRoot, "org", "project"); await mkdir(workspace, { recursive: true }); return { root, workspaceRoot, workspace }; } }); function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }