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> | undefined; readonly hostEnv?: Readonly> | 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; readonly skillIds: readonly string[]; readonly skillPluginRoot?: string | undefined; cleanup(): Promise; 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 { 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 = {}; 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>): 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>): 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 { 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 { 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; }