forked from EduCraft/curriculum-project-hub
fix: confine Agent and MCP data access
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
const PROVIDER_ENV_KEYS = new Set([
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_API_KEY",
|
||||
]);
|
||||
|
||||
const SAFE_HOST_ENV_KEYS = [
|
||||
"PATH",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"TERM",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"SHELL",
|
||||
"CPH_BIN",
|
||||
] as const;
|
||||
|
||||
const PROVIDER_SECRET_ENV_KEYS = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
|
||||
|
||||
export interface AgentSecurityInput {
|
||||
readonly workspaceRoot: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly providerEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
|
||||
}
|
||||
|
||||
export interface AgentSandboxPolicy {
|
||||
readonly enabled: true;
|
||||
readonly failIfUnavailable: true;
|
||||
readonly autoAllowBashIfSandboxed: true;
|
||||
readonly allowUnsandboxedCommands: false;
|
||||
readonly filesystem: {
|
||||
readonly allowWrite: string[];
|
||||
readonly denyRead: string[];
|
||||
readonly allowRead: string[];
|
||||
};
|
||||
readonly credentials: {
|
||||
readonly files: Array<{ readonly path: string; readonly mode: "deny" }>;
|
||||
readonly envVars: Array<{ readonly name: string; readonly mode: "deny" }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentSecurityPolicy {
|
||||
readonly cwd: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly env: Record<string, string | undefined>;
|
||||
readonly sandbox: AgentSandboxPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete Claude subprocess boundary from trusted run context.
|
||||
* This function intentionally replaces, rather than extends, process.env.
|
||||
*/
|
||||
export async function createAgentSecurityPolicy(input: AgentSecurityInput): Promise<AgentSecurityPolicy> {
|
||||
const hostEnv = input.hostEnv ?? process.env;
|
||||
const { workspaceRoot, workspaceDir } = await resolveProjectWorkspace(input.workspaceRoot, input.workspaceDir);
|
||||
|
||||
const runtimeRoot = await ensureDirectoryTree(workspaceDir, [".cph", "agent-runtime"]);
|
||||
const agentHome = await ensureDirectoryTree(runtimeRoot, ["home"]);
|
||||
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
|
||||
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
|
||||
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
|
||||
const agentTmp = await ensureDirectoryTree(runtimeRoot, ["tmp"]);
|
||||
|
||||
const path = hostEnv["PATH"]?.trim();
|
||||
if (path === undefined || path === "") {
|
||||
throw new Error("PATH is required for the Agent subprocess");
|
||||
}
|
||||
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const name of SAFE_HOST_ENV_KEYS) {
|
||||
const value = hostEnv[name];
|
||||
if (value !== undefined) env[name] = value;
|
||||
}
|
||||
env.HOME = agentHome;
|
||||
env.XDG_CACHE_HOME = agentCache;
|
||||
env.XDG_CONFIG_HOME = agentConfig;
|
||||
env.XDG_STATE_HOME = agentState;
|
||||
env.TMPDIR = agentTmp;
|
||||
env.TMP = agentTmp;
|
||||
env.TEMP = agentTmp;
|
||||
env.CLAUDE_CONFIG_DIR = agentConfig;
|
||||
env.CLAUDE_CODE_TMPDIR = agentTmp;
|
||||
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
||||
env.DISABLE_TELEMETRY = "1";
|
||||
env.DO_NOT_TRACK = "1";
|
||||
env.CLAUDE_AGENT_SDK_CLIENT_APP = "cph-hub/0.0.0";
|
||||
|
||||
for (const [name, value] of Object.entries(input.providerEnv ?? {})) {
|
||||
if (!PROVIDER_ENV_KEYS.has(name)) {
|
||||
throw new Error(`unsupported provider environment variable: ${name}`);
|
||||
}
|
||||
env[name] = value;
|
||||
}
|
||||
|
||||
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
|
||||
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
|
||||
return {
|
||||
cwd: workspaceDir,
|
||||
workspaceRoot,
|
||||
env,
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem: {
|
||||
allowWrite: [workspaceDir],
|
||||
// Deny host reads by default, then re-open exactly this run's
|
||||
// workspace plus the named system runtime needed to execute tools.
|
||||
// SDK allowRead takes precedence over matching denyRead paths.
|
||||
denyRead: ["/"],
|
||||
allowRead: [workspaceDir, ...runtimeReadPaths],
|
||||
},
|
||||
credentials: {
|
||||
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
|
||||
envVars: PROVIDER_SECRET_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const platformPaths = process.platform === "darwin"
|
||||
? [
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
"/usr/lib",
|
||||
"/usr/share",
|
||||
"/System/Library",
|
||||
"/Library/Fonts",
|
||||
"/private/etc/hosts",
|
||||
"/private/etc/resolv.conf",
|
||||
"/private/etc/ssl",
|
||||
"/dev/null",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
]
|
||||
: [
|
||||
"/bin",
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/lib",
|
||||
"/lib64",
|
||||
"/usr/lib",
|
||||
"/usr/lib64",
|
||||
"/usr/share",
|
||||
"/etc/fonts",
|
||||
"/etc/hosts",
|
||||
"/etc/localtime",
|
||||
"/etc/nsswitch.conf",
|
||||
"/etc/resolv.conf",
|
||||
"/etc/ssl/certs",
|
||||
"/dev/null",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
];
|
||||
const cphBin = env["CPH_BIN"]?.trim();
|
||||
if (cphBin !== undefined && cphBin !== "") {
|
||||
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
|
||||
platformPaths.push(resolve(cphBin));
|
||||
}
|
||||
return [...new Set(platformPaths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
|
||||
const home = homedir();
|
||||
const paths = [
|
||||
join(home, ".ssh"),
|
||||
join(home, ".aws"),
|
||||
join(home, ".config", "gcloud"),
|
||||
join(home, ".gnupg"),
|
||||
];
|
||||
const extra = env["CPH_SANDBOX_EXTRA_DENY_READ"];
|
||||
if (extra !== undefined && extra !== "") {
|
||||
for (const path of extra.split(":")) {
|
||||
if (path === "") continue;
|
||||
if (!isAbsolute(path)) throw new Error("CPH_SANDBOX_EXTRA_DENY_READ entries must be absolute paths");
|
||||
paths.push(resolve(path));
|
||||
}
|
||||
}
|
||||
return [...new Set(paths.map((path) => resolve(path)))];
|
||||
}
|
||||
|
||||
async function requireDirectoryRealpath(path: string, label: string): Promise<string> {
|
||||
if (!isAbsolute(path)) throw new Error(`${label} must be an absolute path`);
|
||||
const configuredMetadata = await lstat(path);
|
||||
if (configuredMetadata.isSymbolicLink() || !configuredMetadata.isDirectory()) {
|
||||
throw new Error(`${label} is not a real directory: ${path}`);
|
||||
}
|
||||
const canonical = await realpath(path);
|
||||
const metadata = await lstat(canonical);
|
||||
if (!metadata.isDirectory()) throw new Error(`${label} is not a directory: ${path}`);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
async function resolveProjectWorkspace(
|
||||
configuredRoot: string,
|
||||
configuredWorkspace: string,
|
||||
): Promise<{ workspaceRoot: string; workspaceDir: string }> {
|
||||
if (!isAbsolute(configuredRoot) || !isAbsolute(configuredWorkspace)) {
|
||||
throw new Error("workspace root and project workspace must be absolute paths");
|
||||
}
|
||||
const rootPath = resolve(configuredRoot);
|
||||
const workspacePath = resolve(configuredWorkspace);
|
||||
const lexicalRelative = relative(rootPath, workspacePath);
|
||||
if (
|
||||
lexicalRelative === "" ||
|
||||
lexicalRelative === ".." ||
|
||||
lexicalRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
|
||||
isAbsolute(lexicalRelative)
|
||||
) {
|
||||
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
|
||||
}
|
||||
|
||||
const workspaceRoot = await requireDirectoryRealpath(rootPath, "workspace root");
|
||||
const components = lexicalRelative.split(process.platform === "win32" ? /[\\/]/ : "/");
|
||||
let current = rootPath;
|
||||
for (const component of components) {
|
||||
current = join(current, component);
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`project workspace contains a symlink or non-directory component: ${current}`);
|
||||
}
|
||||
}
|
||||
const workspaceDir = await realpath(workspacePath);
|
||||
const expectedWorkspace = join(workspaceRoot, ...components);
|
||||
if (workspaceDir !== expectedWorkspace || !isStrictDescendant(workspaceRoot, workspaceDir)) {
|
||||
throw new Error(`project workspace escapes configured workspace root: ${configuredWorkspace}`);
|
||||
}
|
||||
return { workspaceRoot, workspaceDir };
|
||||
}
|
||||
|
||||
async function ensureDirectoryTree(root: string, components: readonly string[]): Promise<string> {
|
||||
let current = root;
|
||||
for (const component of components) {
|
||||
if (component === "" || component === "." || component === ".." || component.includes("/") || component.includes("\\")) {
|
||||
throw new Error(`invalid Agent runtime directory component: ${component}`);
|
||||
}
|
||||
current = join(current, component);
|
||||
try {
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`Agent runtime path is not a real directory: ${current}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isErrno(error, "ENOENT")) throw error;
|
||||
try {
|
||||
await mkdir(current, { mode: 0o700 });
|
||||
} catch (mkdirError) {
|
||||
if (!isErrno(mkdirError, "EEXIST")) throw mkdirError;
|
||||
}
|
||||
const metadata = await lstat(current);
|
||||
if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
|
||||
throw new Error(`Agent runtime path is not a real directory: ${current}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const canonical = await realpath(current);
|
||||
if (canonical !== current || !isStrictDescendant(root, canonical)) {
|
||||
throw new Error(`Agent runtime path escapes project workspace: ${current}`);
|
||||
}
|
||||
await chmod(canonical, 0o700);
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, child: string): boolean {
|
||||
const candidate = relative(parent, child);
|
||||
return candidate !== "" && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(candidate);
|
||||
}
|
||||
|
||||
function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
Reference in New Issue
Block a user