import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } 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"; import { importSkillDirectory } from "../../src/agent/skillStore.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", HTTP_PROXY: "http://127.0.0.1:7890", HTTPS_PROXY: "http://127.0.0.1:7890", ALL_PROXY: "socks5h://127.0.0.1:7890", NO_PROXY: "127.0.0.1,localhost,::1", NODE_USE_ENV_PROXY: "1", TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", 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", TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100", ANTHROPIC_BASE_URL: "http://127.0.0.1:43123", ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_API_KEY: "", HTTP_PROXY: "http://127.0.0.1:7890", HTTPS_PROXY: "http://127.0.0.1:7890", ALL_PROXY: "socks5h://127.0.0.1:7890", NO_PROXY: "127.0.0.1,localhost,::1", NODE_USE_ENV_PROXY: "1", }); 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: expect.arrayContaining([ canonicalWorkspace, "/srv/curriculum-project-hub/typst-packages/para-26071100", ]), denyRead: ["/"], allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), }, credentials: { envVars: expect.arrayContaining([ { name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" }, { name: "ANTHROPIC_API_KEY", mode: "deny" }, ]), }, }); }); it("passes configured Typst package roots and exposes them read-only to the sandbox", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); const packageRoot = "/srv/curriculum-project-hub/typst-packages/para-26071100"; const cacheRoot = "/var/cache/cph-hub/para-26071100/typst"; const policy = await createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: workspace, hostEnv: { PATH: "/usr/bin:/bin", TYPST_PACKAGE_PATH: packageRoot, TYPST_PACKAGE_CACHE_PATH: cacheRoot, }, }); const canonicalWorkspace = await realpath(workspace); expect(policy.env).toMatchObject({ TYPST_PACKAGE_PATH: packageRoot, TYPST_PACKAGE_CACHE_PATH: cacheRoot, }); expect(policy.sandbox.filesystem.allowRead).toEqual(expect.arrayContaining([packageRoot, cacheRoot])); expect(policy.sandbox.filesystem.allowWrite).toEqual(expect.arrayContaining([canonicalWorkspace, cacheRoot])); expect(policy.sandbox.filesystem.allowWrite).not.toContain(packageRoot); }); it("rejects a relative Typst package root instead of silently losing package access", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); await expect(createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: workspace, hostEnv: { PATH: "/usr/bin:/bin", TYPST_PACKAGE_PATH: "typst-packages", }, })).rejects.toThrow("TYPST_PACKAGE_PATH must be absolute"); }); it("rejects a Typst cache rooted at the filesystem root instead of widening writes", async () => { const { workspaceRoot, workspace } = await makeWorkspace(); await expect(createAgentSecurityPolicy({ runId: "run-test", workspaceRoot, workspaceDir: workspace, hostEnv: { PATH: "/usr/bin:/bin", TYPST_PACKAGE_CACHE_PATH: "/", }, })).rejects.toThrow("TYPST_PACKAGE_CACHE_PATH must not be the filesystem root"); }); 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("mirrors selected skills under .cph/runtime-skills and exposes CPH_RUNTIME_SKILLS_DIR", async () => { const { root, workspaceRoot, workspace } = await makeWorkspace(); const storeRoot = join(root, "skills-store"); const skillSource = join(root, "skill-src", "pdf-to-md"); await mkdir(skillSource, { recursive: true }); await writeFile( join(skillSource, "SKILL.md"), "---\nname: pdf-to-md\ndescription: convert\n---\n# pdf-to-md\nbatch items\n", ); const installed = await importSkillDirectory({ sourceDir: skillSource, storeRoot }); const policy = await createAgentSecurityPolicy({ runId: "run-skills", workspaceRoot, workspaceDir: workspace, skills: [{ name: "pdf-to-md", version: "1", contentDigest: installed.contentDigest, }], hostEnv: { PATH: "/usr/bin:/bin", HUB_SKILL_STORE_ROOT: storeRoot, }, }); const canonicalWorkspace = await realpath(workspace); const mirrored = join(canonicalWorkspace, ".cph", "runtime-skills", "pdf-to-md", "SKILL.md"); await expect(readFile(mirrored, "utf8")).resolves.toContain("batch items"); expect(policy.env.CPH_RUNTIME_SKILLS_DIR).toBe(join(canonicalWorkspace, ".cph", "runtime-skills")); expect(policy.env.CPH_RUNTIME_SKILLS_REL).toBe(join(".cph", "runtime-skills")); expect(policy.skillIds).toEqual(["cph-runtime:pdf-to-md"]); expect(policy.sandbox.filesystem.allowRead).toEqual( expect.arrayContaining([canonicalWorkspace, policy.skillPluginRoot]), ); await policy.cleanup(); }); 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, "\\$&"); }