Files
curriculum-project-hub/hub/test/unit/agent-security.test.ts
T

151 lines
6.0 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({
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).not.toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(64);
expect(policy.sandbox).toMatchObject({
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: expect.arrayContaining([canonicalWorkspace, policy.env.TMPDIR]),
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 every SDK temp variable on a short isolated path outside a long project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
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).not.toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(Buffer.byteLength(temp)).toBeLessThanOrEqual(64);
expect(policy.sandbox.filesystem.allowWrite).toEqual([canonicalWorkspace, temp]);
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
expect(policy.sandbox.filesystem.allowRead).toContain(temp);
});
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, "\\$&");
}