import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { lstat, mkdir, realpath, rm } from "node:fs/promises"; import { resolve } from "node:path"; import { config } from "../config.js"; import { resolveConversationWorkspace, createConversationWorkspace, SANDBOX_GID, SANDBOX_UID, setSandboxOwnership, } from "../runtime/conversationWorkspace.js"; export type SandboxProbe = { landlockAbi: number; seccomp: boolean; }; export type SandboxExecutionResult = { exitCode: number | null; outcome: "completed" | "output_limit" | "timeout" | "cancelled"; signal: NodeJS.Signals | null; stderr: string; stderrTruncated: boolean; stdout: string; }; const sandboxRunnerPath = resolve("scripts/landlock_sandbox.py"); const maxSandboxTimeoutSeconds = 30 * 60; const resolvePythonPath = () => { const virtualEnvironment = process.env.VIRTUAL_ENV?.trim(); const candidates = [ virtualEnvironment ? resolve(virtualEnvironment, "bin/python") : "", "/opt/venv/bin/python", "/usr/bin/python3", ]; return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "python3"; }; const collectProcess = async ( args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv; maxStderrBytes: number; maxStdoutBytes: number; timeoutMs: number; signal?: AbortSignal; }, ): Promise => { const child = spawn(resolvePythonPath(), [sandboxRunnerPath, ...args], { cwd: options.cwd, detached: true, env: options.env, stdio: ["ignore", "pipe", "pipe"], }); const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; let stdoutBytes = 0; let stderrBytes = 0; let stderrTruncated = false; let outcome: SandboxExecutionResult["outcome"] = "completed"; let terminationStarted = false; let forceKillTimer: ReturnType | undefined; const killGroup = (signal: NodeJS.Signals) => { if (child.pid) { try { process.kill(-child.pid, signal); return; } catch { // Fall back to the direct child when process groups are unavailable. } } child.kill(signal); }; // The foreground shell can exit before descendants close inherited pipes. // Reap its group on exit, rather than waiting for pipe close or the timeout. child.once("exit", () => killGroup("SIGKILL")); const terminate = (nextOutcome: "output_limit" | "timeout" | "cancelled") => { if (terminationStarted) return; terminationStarted = true; outcome = nextOutcome; killGroup("SIGTERM"); forceKillTimer = setTimeout(() => killGroup("SIGKILL"), 1500); }; const cancel = () => terminate("cancelled"); options.signal?.addEventListener("abort", cancel, { once: true }); if (options.signal?.aborted) cancel(); const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeoutMs); child.stdout.on("data", (chunk: Buffer) => { if (terminationStarted) return; if (stdoutBytes + chunk.length > options.maxStdoutBytes) { terminate("output_limit"); return; } stdoutChunks.push(chunk); stdoutBytes += chunk.length; }); child.stderr.on("data", (chunk: Buffer) => { if (stderrTruncated) return; const remaining = options.maxStderrBytes - stderrBytes; if (chunk.length > remaining) { if (remaining > 0) { stderrChunks.push(chunk.subarray(0, remaining)); stderrBytes += remaining; } stderrTruncated = true; return; } stderrChunks.push(chunk); stderrBytes += chunk.length; }); return await new Promise((resolveResult, reject) => { child.once("error", (error) => { options.signal?.removeEventListener("abort", cancel); clearTimeout(timeoutTimer); if (forceKillTimer) clearTimeout(forceKillTimer); reject(error); }); child.once("close", (exitCode, signal) => { options.signal?.removeEventListener("abort", cancel); clearTimeout(timeoutTimer); if (forceKillTimer) clearTimeout(forceKillTimer); resolveResult({ exitCode, outcome, signal, stderr: Buffer.concat(stderrChunks, stderrBytes).toString("utf8"), stderrTruncated, stdout: outcome === "output_limit" ? "" : Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8"), }); }); }); }; export const probeLandlockSandbox = async (): Promise => { const result = await collectProcess(["--probe"], { maxStderrBytes: 16 * 1024, maxStdoutBytes: 16 * 1024, timeoutMs: 5000, }); if (result.exitCode !== 0) { throw new Error(result.stderr || "Landlock sandbox probe failed"); } const payload = JSON.parse(result.stdout) as { landlock_abi?: unknown; seccomp?: unknown; }; if ( typeof payload.landlock_abi !== "number" || payload.landlock_abi < 4 || payload.seccomp !== true ) { throw new Error("Landlock ABI 4 and seccomp are required"); } const directory = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT); try { const execution = await executeSandboxCommand(directory, "python3 -c 'import socket;\ntry: socket.socket()\nexcept PermissionError: print(\"sandbox-probe-ok\")\nelse: raise RuntimeError(\"network filter not installed\")'", 5); if (execution.exitCode !== 0 || !execution.stdout.includes("sandbox-probe-ok")) { throw new Error(execution.stderr || "sandbox execution probe failed"); } } finally { await rm(directory, { recursive: true }); } return { landlockAbi: payload.landlock_abi, seccomp: true }; }; const buildSandboxEnvironment = (workspace: string): NodeJS.ProcessEnv => ({ HOME: workspace, LANG: process.env.LANG ?? "C.UTF-8", LC_ALL: process.env.LC_ALL ?? "C.UTF-8", MPLCONFIGDIR: resolve(workspace, ".cache/matplotlib"), PATH: "/opt/venv/bin:/usr/local/bin:/usr/bin:/bin", PYTHONDONTWRITEBYTECODE: "1", TMPDIR: resolve(workspace, "tmp"), TZ: process.env.TZ ?? "Asia/Shanghai", VIRTUAL_ENV: "/opt/venv", XDG_CACHE_HOME: resolve(workspace, ".cache"), }); export const executeSandboxCommand = async ( workspaceDirectory: string, command: string, timeoutSeconds: number, signal?: AbortSignal, ): Promise => { const workspace = await resolveConversationWorkspace( workspaceDirectory, config.CONVERSATION_WORKSPACE_ROOT, ); const normalizedTimeout = Math.min( maxSandboxTimeoutSeconds, Math.max(1, Math.trunc(timeoutSeconds)), ); for (const path of [resolve(workspace, "files"), resolve(workspace, "tmp"), resolve(workspace, ".cache")]) { await mkdir(path, { mode: 0o700, recursive: true }); if ((await lstat(path)).isSymbolicLink() || await realpath(path) !== path) { throw new Error("sandbox writable directory must not be a symbolic link"); } await setSandboxOwnership(path); } const readOnlyPaths = [ "/usr", "/bin", "/lib", "/lib64", "/opt/venv", "/etc/ld.so.cache", "/etc/localtime", "/etc/group", "/etc/nsswitch.conf", "/etc/passwd", resolve(config.OPENCODE_SKILLS_ROOT_DIR), ].filter(existsSync); const args = [ "--workspace", workspace, "--uid", String(SANDBOX_UID), "--gid", String(SANDBOX_GID), ...readOnlyPaths.flatMap((path) => ["--read-only", path]), ...["files", "tmp", ".cache"].flatMap((name) => ["--writable", resolve(workspace, name)]), "--command", command, ]; return collectProcess(args, { cwd: resolve(workspace, "files"), env: buildSandboxEnvironment(workspace), maxStderrBytes: 256 * 1024, maxStdoutBytes: config.SANDBOX_MAX_OUTPUT_BYTES, timeoutMs: normalizedTimeout * 1000, signal, }); };