forked from EduCraft/curriculum-project-hub
173 lines
6.8 KiB
TypeScript
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, "\\$&");
|
|
}
|