Files
curriculum-project-hub/hub/test/unit/agent-security.test.ts
T
2026-07-11 15:06:48 +08:00

173 lines
6.8 KiB
TypeScript

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, "\\$&");
}