Files
curriculum-project-hub/hub/src/agent/security.ts
T

325 lines
12 KiB
TypeScript

import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js";
import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js";
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;
// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit.
const MAX_AGENT_TMP_PREFIX_BYTES = 56;
export interface AgentSecurityInput {
readonly runId: string;
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly skills?: readonly RoleSkillEntry[] | undefined;
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
readonly providerProxyEnv?: 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 denyWrite: 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 skillIds: readonly string[];
readonly skillPluginRoot?: string | undefined;
cleanup(): Promise<void>;
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 cphRoot = await ensureDirectoryTree(workspaceDir, [".cph"]);
const runtimeRoot = await ensureDirectoryTree(cphRoot, ["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(cphRoot, ["t"]);
assertShortAgentTemp(agentTmp);
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;
// All four variables must use the short path. Claude's sandbox bridge uses
// the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
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.providerProxyEnv ?? {})) {
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);
const selectedSkills = input.skills ?? [];
const skillPlugin = selectedSkills.length === 0
? null
: await prepareRunSkillPlugin({
storeRoot: readSkillStoreRoot(hostEnv),
runId: input.runId,
skills: selectedSkills,
});
return {
cwd: workspaceDir,
workspaceRoot,
env,
skillIds: skillPlugin?.skillIds ?? [],
...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}),
cleanup: skillPlugin?.cleanup ?? (async () => {}),
sandbox: {
enabled: true,
failIfUnavailable: true,
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [workspaceDir],
// Reject every write path by default, then re-open only the canonical
// workspace. This prevents bubblewrap's ordinary temp exceptions from
// turning an unauthorized path into a successful ephemeral write.
denyWrite: ["/"],
// 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, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths],
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
// These values are short-lived loopback capabilities, not Organization
// provider secrets. Denying them also strips Claude's own request auth.
envVars: [],
},
},
};
}
function assertShortAgentTemp(agentTmp: string): void {
const prefixBytes = Buffer.byteLength(agentTmp);
if (prefixBytes > MAX_AGENT_TMP_PREFIX_BYTES) {
throw new Error(
`Agent temp path is too long for sandbox bridge sockets (${prefixBytes} > ${MAX_AGENT_TMP_PREFIX_BYTES} bytes): ${agentTmp}`,
);
}
}
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",
"/sbin",
"/usr/bin",
"/usr/local/bin",
"/usr/sbin",
"/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 its configured root: ${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;
}