import fs from "node:fs/promises"; import http from "node:http"; import net from "node:net"; import os from "node:os"; import path from "node:path"; export type LocalProcessSandboxAccess = "ro" | "rw"; export type LocalProcessNetworkScope = "deny" | "allowlist"; export interface LocalProcessSandboxPath { path: string; access: LocalProcessSandboxAccess; } export interface LocalProcessSandboxPathAlias { path: string; target: string; } export interface LocalProcessSandboxOptions { workspaceDir: string; filesystemScope?: "workspace" | null; managedPaths?: LocalProcessSandboxPath[]; extraPaths?: LocalProcessSandboxPath[]; pathAliases?: LocalProcessSandboxPathAlias[]; outboundRestorePaths?: string[]; homeDir?: string | null; networkScope?: LocalProcessNetworkScope | null; networkAllowlist?: string[]; networkTrustedUrls?: string[]; command?: string; } export interface LocalProcessSandboxSpawnTarget { command: string; args: string[]; cwd: string; env?: Record; cleanup?: () => Promise; } interface NetworkAllowlistRule { hostname: string; port: string | null; } interface NetworkAllowlistProxy { close: () => Promise; } const SYSTEM_READ_PATHS = [ "/bin", "/sbin", "/usr", "/lib", "/lib64", "/etc/ca-certificates", "/etc/ssl", "/etc/resolv.conf", "/etc/hosts", "/etc/nsswitch.conf", "/etc/passwd", "/etc/group", "/etc/localtime", "/etc/timezone", "/etc/gitconfig", ] as const; const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const; const SANDBOX_PROXY_PORT = 31_337; const UNIX_SOCKET_PATH_MAX_BYTES = 107; const NETWORK_PROXY_TEMP_PREFIX = "paperclip-network-sandbox-"; function normalizeAbsolutePath(candidate: string, label: string): string { const trimmed = candidate.trim(); if (!trimmed || !path.isAbsolute(trimmed)) { throw new Error(`${label} must be an absolute path.`); } return path.resolve(trimmed); } async function pathExists(candidate: string): Promise { return fs.lstat(candidate).then(() => true).catch(() => false); } function parentDirectories(candidate: string): string[] { const directories: string[] = []; let current = path.dirname(candidate); while (current !== path.dirname(current)) { directories.push(current); current = path.dirname(current); } return directories.reverse(); } function addParentDirectories(args: string[], created: Set, candidate: string): void { for (const directory of parentDirectories(candidate)) { if (created.has(directory)) continue; args.push("--dir", directory); created.add(directory); } } async function nearestPackageRoot(candidate: string): Promise { let current = path.dirname(candidate); while (current !== path.dirname(current)) { if (await pathExists(path.join(current, "package.json"))) return current; current = path.dirname(current); } return path.dirname(candidate); } async function executableReadPaths(command: string): Promise { const paths = new Set(); paths.add(path.dirname(command)); const realCommand = await fs.realpath(command).catch(() => command); paths.add(await nearestPackageRoot(realCommand)); return Array.from(paths); } function parseNetworkAllowlistEntry(entry: string, index: number): NetworkAllowlistRule { const trimmed = entry.trim(); if (!trimmed) throw new Error(`networkAllowlist[${index}] must not be empty.`); let hostname: string; let port: string | null; try { const parsed = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) { throw new Error("path"); } hostname = parsed.hostname.toLowerCase(); port = parsed.port || null; } catch { throw new Error(`networkAllowlist[${index}] must be a hostname, hostname:port, or origin URL.`); } if (!hostname || hostname === "*" || hostname.startsWith("*.")) { throw new Error(`networkAllowlist[${index}] must use an exact hostname; wildcards are not supported.`); } return { hostname, port }; } export function parseLocalProcessNetworkAllowlist(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((entry, index) => { if (typeof entry !== "string") throw new Error(`networkAllowlist[${index}] must be a string.`); const rule = parseNetworkAllowlistEntry(entry, index); return rule.port ? `${rule.hostname}:${rule.port}` : rule.hostname; }); } export function parseLocalProcessNetworkScope(value: unknown): LocalProcessNetworkScope | null { if (value == null || value === "") return null; if (value === "deny" || value === "allowlist") return value; throw new Error('networkScope must be "deny" or "allowlist".'); } export function parseLocalProcessFilesystemScope(value: unknown): "workspace" | null { if (value == null || value === "") return null; if (value === "workspace") return value; throw new Error('filesystemScope must be "workspace".'); } function isNetworkTargetAllowed(hostname: string, port: string, rules: NetworkAllowlistRule[]): boolean { const normalizedHostname = hostname.toLowerCase().replace(/^\[|\]$/g, ""); return rules.some((rule) => rule.hostname === normalizedHostname && (rule.port === null || rule.port === port)); } function assertUnixSocketPathLength(socketPath: string): void { const pathBytes = Buffer.byteLength(socketPath); if (pathBytes > UNIX_SOCKET_PATH_MAX_BYTES) { throw new Error( `Paperclip sandbox proxy socket path is ${pathBytes} bytes, exceeding the Linux limit of ${UNIX_SOCKET_PATH_MAX_BYTES}: ${socketPath}`, ); } } async function createNetworkProxyTempDir(): Promise { const candidates = Array.from(new Set(["/tmp", os.tmpdir()])); let lastError: unknown; for (const baseDir of candidates) { try { const tempDir = await fs.mkdtemp(path.join(baseDir, NETWORK_PROXY_TEMP_PREFIX)); try { assertUnixSocketPathLength(path.join(tempDir, "proxy.sock")); return tempDir; } catch (error) { await fs.rm(tempDir, { recursive: true, force: true }); lastError = error; } } catch (error) { lastError = error; } } throw new Error("Unable to create a Linux-safe Paperclip sandbox proxy socket directory.", { cause: lastError }); } function parseTrustedNetworkUrl(value: string): NetworkAllowlistRule | null { try { const parsed = new URL(value); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; return { hostname: parsed.hostname.toLowerCase(), port: parsed.port || (parsed.protocol === "https:" ? "443" : "80"), }; } catch { return null; } } function writeProxyError(response: http.ServerResponse, status: number, code: string, message: string): void { const body = `${JSON.stringify({ error: { code, message } })}\n`; response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body), }).end(body); } function connectProxyError(code: string, message: string): string { const body = `${JSON.stringify({ error: { code, message } })}\n`; return [ "HTTP/1.1 403 Forbidden", "Connection: close", "Content-Type: application/json; charset=utf-8", `Content-Length: ${Buffer.byteLength(body)}`, "", body, ].join("\r\n"); } async function startNetworkAllowlistProxy( allowlist: string[], trustedUrls: string[], socketPath: string, ): Promise { assertUnixSocketPathLength(socketPath); const rules = [ ...allowlist.map(parseNetworkAllowlistEntry), ...trustedUrls.map(parseTrustedNetworkUrl).filter((rule): rule is NetworkAllowlistRule => rule !== null), ]; if (rules.length === 0) { throw new Error( 'networkScope="allowlist" requires at least one valid networkAllowlist hostname or HTTP(S) networkTrustedUrl.', ); } const server = http.createServer((request, response) => { let target: URL; try { target = new URL(request.url ?? ""); } catch { writeProxyError(response, 400, "invalid_request_url", "Paperclip sandbox proxy requires an absolute request URL."); return; } const port = target.port || (target.protocol === "https:" ? "443" : "80"); if (target.protocol !== "http:") { writeProxyError(response, 400, "https_requires_connect", "HTTPS targets must use CONNECT through the Paperclip sandbox proxy."); return; } if (!isNetworkTargetAllowed(target.hostname, port, rules)) { writeProxyError(response, 403, "network_target_denied", "Network target denied by Paperclip sandbox policy."); return; } const upstream = http.request(target, { method: request.method, headers: { ...request.headers, host: target.host }, }, (upstreamResponse) => { response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers); upstreamResponse.pipe(response); }); upstream.on("error", (error) => response.destroy(error)); request.pipe(upstream); }); server.on("connect", (request, clientSocket, head) => { const separator = request.url?.lastIndexOf(":") ?? -1; const hostname = separator > 0 ? request.url!.slice(0, separator).replace(/^\[|\]$/g, "") : ""; const port = separator > 0 ? request.url!.slice(separator + 1) : "443"; if (!hostname || !/^\d+$/.test(port) || !isNetworkTargetAllowed(hostname, port, rules)) { clientSocket.end(connectProxyError( "network_target_denied", "Network target denied by Paperclip sandbox policy.", )); return; } const upstream = net.connect(Number(port), hostname, () => { clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n"); if (head.length > 0) upstream.write(head); upstream.pipe(clientSocket); clientSocket.pipe(upstream); }); upstream.on("error", () => clientSocket.destroy()); clientSocket.on("close", () => upstream.destroy()); }); const sockets = new Set(); server.on("connection", (socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, () => { server.off("error", reject); resolve(); }); }); return { close: async () => { for (const socket of sockets) socket.destroy(); await new Promise((resolve) => server.close(() => resolve())); }, }; } async function createNetworkProxyBridge(): Promise { const source = ` const net = require("node:net"); const { spawn } = require("node:child_process"); const socketPath = process.argv[2]; const executable = process.argv[3]; const args = process.argv.slice(4); const server = net.createServer((client) => { const upstream = net.connect(socketPath); client.pipe(upstream); upstream.pipe(client); const close = () => { client.destroy(); upstream.destroy(); }; client.on("error", close); upstream.on("error", close); }); server.listen(${SANDBOX_PROXY_PORT}, "127.0.0.1", () => { const child = spawn(executable, args, { stdio: "inherit", env: process.env }); const forward = (signal) => { if (!child.killed) child.kill(signal); }; process.on("SIGTERM", () => forward("SIGTERM")); process.on("SIGINT", () => forward("SIGINT")); child.on("exit", (code, signal) => server.close(() => { if (signal) process.kill(process.pid, signal); else process.exit(code == null ? 1 : code); })); }); `; return source.trimStart(); } export async function buildLocalProcessSandboxSpawnTarget(input: { executable: string; args: string[]; cwd: string; options: LocalProcessSandboxOptions; }): Promise { if (process.platform !== "linux") { throw new Error("Local process filesystem and network scopes are currently supported only on Linux."); } const filesystemScope = input.options.filesystemScope ?? null; const networkScope = input.options.networkScope ?? null; if (!filesystemScope && !networkScope) throw new Error("Local process sandbox requires a filesystem or network scope."); const workspaceDir = normalizeAbsolutePath(input.options.workspaceDir, "Sandbox workspaceDir"); const cwd = normalizeAbsolutePath(input.cwd, "Sandbox cwd"); if (filesystemScope === "workspace") { const relativeCwd = path.relative(workspaceDir, cwd); if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) { throw new Error(`Sandbox cwd "${cwd}" must be inside workspaceDir "${workspaceDir}".`); } const outboundRestorePaths = (input.options.outboundRestorePaths ?? []).map((candidate, index) => normalizeAbsolutePath(candidate, `Sandbox outboundRestorePaths[${index}]`)); for (const [index, extraPath] of (input.options.extraPaths ?? []).entries()) { if (extraPath.access !== "rw") continue; const normalizedExtraPath = normalizeAbsolutePath(extraPath.path, `Sandbox extraPaths[${index}].path`); const relativeToWorkspace = path.relative(workspaceDir, normalizedExtraPath); const synchronized = !relativeToWorkspace.startsWith("..") && !path.isAbsolute(relativeToWorkspace); const restored = outboundRestorePaths.some((restorePath) => { const relative = path.relative(restorePath, normalizedExtraPath); return !relative.startsWith("..") && !path.isAbsolute(relative); }); if (!synchronized && !restored) { throw new Error( `Writable sandbox path "${normalizedExtraPath}" is outside synchronized workspace "${workspaceDir}" and has no outbound restore mapping.`, ); } } } const bwrapCommand = input.options.command?.trim() || "bwrap"; const args = ["--die-with-parent", "--new-session", "--unshare-pid", "--unshare-ipc", "--unshare-uts"]; const env: Record = {}; let cleanup: (() => Promise) | undefined; let executable = input.executable; let executableArgs = input.args; if (filesystemScope === "workspace") { args.push("--tmpfs", "/", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp"); args.push( "--symlink", "usr/bin", "/bin", "--symlink", "usr/sbin", "/sbin", "--symlink", "usr/lib", "/lib", "--symlink", "usr/lib64", "/lib64", ); const created = new Set(["/", "/proc", "/dev", "/tmp"]); const mounted = new Set(); const mount = async (source: string, access: LocalProcessSandboxAccess) => { const normalized = normalizeAbsolutePath(source, "Sandbox path"); if (mounted.has(normalized) || !(await pathExists(normalized))) return; addParentDirectories(args, created, normalized); args.push(access === "rw" ? "--bind" : "--ro-bind", normalized, normalized); mounted.add(normalized); created.add(normalized); }; for (const systemPath of SYSTEM_READ_PATHS) await mount(systemPath, "ro"); for (const executablePath of await executableReadPaths(input.executable)) await mount(executablePath, "ro"); if (networkScope === "allowlist") { for (const nodePath of await executableReadPaths(process.execPath)) await mount(nodePath, "ro"); } for (const managedPath of input.options.managedPaths ?? []) await mount(managedPath.path, managedPath.access); for (const extraPath of input.options.extraPaths ?? []) await mount(extraPath.path, extraPath.access); await mount(workspaceDir, "rw"); for (const [index, alias] of (input.options.pathAliases ?? []).entries()) { const aliasPath = normalizeAbsolutePath(alias.path, `Sandbox pathAliases[${index}].path`); const aliasTarget = normalizeAbsolutePath(alias.target, `Sandbox pathAliases[${index}].target`); const relativeTarget = path.relative(workspaceDir, aliasTarget); if (relativeTarget.startsWith("..") || path.isAbsolute(relativeTarget)) { throw new Error( `Sandbox path alias "${aliasPath}" must target the synchronized workspace "${workspaceDir}".`, ); } if (!(await pathExists(aliasTarget))) { throw new Error(`Sandbox path alias target "${aliasTarget}" does not exist.`); } addParentDirectories(args, created, aliasPath); args.push("--bind", aliasTarget, aliasPath); created.add(aliasPath); } if (networkScope === "allowlist") { const tempDir = await createNetworkProxyTempDir(); const socketPath = path.join(tempDir, "proxy.sock"); const bridgePath = path.join(tempDir, "bridge.cjs"); await fs.writeFile(bridgePath, await createNetworkProxyBridge(), { mode: 0o500 }); const proxy = await startNetworkAllowlistProxy( input.options.networkAllowlist ?? [], input.options.networkTrustedUrls ?? [], socketPath, ).catch(async (error) => { await fs.rm(tempDir, { recursive: true, force: true }); throw error; }); await mount(tempDir, "rw"); executable = process.execPath; executableArgs = [bridgePath, socketPath, input.executable, ...input.args]; cleanup = async () => { await proxy.close(); await fs.rm(tempDir, { recursive: true, force: true }); }; } } else { args.push("--bind", "/", "/"); if (networkScope === "allowlist") { const tempDir = await createNetworkProxyTempDir(); const socketPath = path.join(tempDir, "proxy.sock"); const bridgePath = path.join(tempDir, "bridge.cjs"); await fs.writeFile(bridgePath, await createNetworkProxyBridge(), { mode: 0o500 }); const proxy = await startNetworkAllowlistProxy( input.options.networkAllowlist ?? [], input.options.networkTrustedUrls ?? [], socketPath, ).catch(async (error) => { await fs.rm(tempDir, { recursive: true, force: true }); throw error; }); executable = process.execPath; executableArgs = [bridgePath, socketPath, input.executable, ...input.args]; cleanup = async () => { await proxy.close(); await fs.rm(tempDir, { recursive: true, force: true }); }; } } if (networkScope) { args.push("--unshare-net"); for (const key of PROXY_ENV_KEYS) env[key] = undefined; env.NO_PROXY = ""; env.no_proxy = ""; } if (networkScope === "allowlist") { const proxyUrl = `http://127.0.0.1:${SANDBOX_PROXY_PORT}`; env.HTTP_PROXY = proxyUrl; env.HTTPS_PROXY = proxyUrl; env.http_proxy = proxyUrl; env.https_proxy = proxyUrl; } args.push("--chdir", cwd, "--", executable, ...executableArgs); return { command: bwrapCommand, args, cwd: "/", env, cleanup }; } export function parseLocalProcessSandboxExtraPaths(value: unknown): LocalProcessSandboxPath[] { if (!Array.isArray(value)) return []; return value.map((entry, index) => { if (typeof entry === "string") { return { path: normalizeAbsolutePath(entry, `filesystemExtraPaths[${index}]`), access: "ro" }; } if (!entry || typeof entry !== "object" || Array.isArray(entry)) { throw new Error(`filesystemExtraPaths[${index}] must be an absolute path or { path, access } object.`); } const raw = entry as Record; const access = raw.access === "rw" ? "rw" : raw.access === "ro" || raw.access == null ? "ro" : null; if (!access || typeof raw.path !== "string") { throw new Error(`filesystemExtraPaths[${index}] must use access "ro" or "rw" and an absolute path.`); } return { path: normalizeAbsolutePath(raw.path, `filesystemExtraPaths[${index}].path`), access }; }); }