forked from bai/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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 provider and safe runtime variables and protects provider credentials from tools", async () => {
|
||||
const { workspaceRoot, workspace } = await makeWorkspace();
|
||||
const policy = await createAgentSecurityPolicy({
|
||||
workspaceRoot,
|
||||
workspaceDir: workspace,
|
||||
providerEnv: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
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: "https://openrouter.ai/api",
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
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).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
|
||||
|
||||
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,
|
||||
providerEnv: {
|
||||
ANTHROPIC_AUTH_TOKEN: "provider-secret",
|
||||
DATABASE_URL: "must-not-cross-boundary",
|
||||
},
|
||||
hostEnv: { PATH: "/usr/bin:/bin" },
|
||||
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
|
||||
});
|
||||
|
||||
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,
|
||||
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
|
||||
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,
|
||||
providerEnv: { ANTHROPIC_AUTH_TOKEN: "provider-secret" },
|
||||
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, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user