// Sandbox backends: wrap a command so the OS enforces a read-only / write-restricted // view of the *real* host filesystem — no container, no image. The host's own // binaries and environment are shared with the sandboxed process. // // Linux : bubblewrap (bwrap) — read-only bind of / plus rw binds for the // writable dirs; every other write fails at the syscall (EROFS). // macOS : sandbox-exec (Seatbelt) — an "(allow default) / (deny file-write*)" // profile that re-allows writes only under the writable dirs. // // Both share the host filesystem at identity paths, so there is no host<->guest // path translation: a path is the same inside and out. This is a real *write* // boundary, not a hardened sandbox — reads are not confined and (by default) // the network is left on. See README. import { existsSync, writeFileSync } from "node:fs"; import { realpathSync } from "node:fs"; import { join } from "node:path"; import type { Mode } from "./config.ts"; import { spawnCapture, spawnStream } from "./runtime.ts"; export type Backend = "bwrap" | "sandbox-exec"; export type SandboxMode = Exclude; export interface SandboxSpec { backend: Backend; mode: SandboxMode; cwd: string; // host cwd (identity path) allow: string[]; // extra rw host dirs (restricted mode) scratch: string; // per-session writable scratch dir (host path), used as TMPDIR } const SANDBOX_EXEC = "/usr/bin/sandbox-exec"; /** Best-effort realpath (canonical path); falls back to the input if it can't resolve. */ function realpath(p: string): string { try { return realpathSync(p); } catch { return p; } } /** * Detect an available backend for this OS. `preferred` may be "auto", "bwrap", * or "sandbox-exec". Returns undefined when none is usable (fail-closed upstream). */ export async function detectBackend(preferred: "auto" | Backend = "auto"): Promise { const wantBwrap = preferred === "auto" || preferred === "bwrap"; const wantSeatbelt = preferred === "auto" || preferred === "sandbox-exec"; if (process.platform === "darwin" && wantSeatbelt && existsSync(SANDBOX_EXEC)) return "sandbox-exec"; if (wantBwrap && (await hasBwrap())) return "bwrap"; // Explicit sandbox-exec request off macOS, or nothing found. return undefined; } async function hasBwrap(): Promise { try { const res = await spawnCapture("/bin/sh", ["-c", "command -v bwrap || true"], { timeout: 10 }); return res.code === 0 && res.stdout.toString("utf8").trim().length > 0; } catch { return false; } } /** SBPL string literal (double-quoted, backslash-escaped). */ function sbString(s: string): string { return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } /** Generate a Seatbelt profile: read/exec/network allowed, writes denied except writable dirs. */ function buildSeatbeltProfile(spec: SandboxSpec): string { const writable = [spec.scratch]; if (spec.mode === "restricted") { writable.push(spec.cwd); writable.push(...spec.allow); } const subpaths = writable.map((p) => ` (subpath ${sbString(realpath(p))})`).join("\n"); // (allow default) then (deny file-write*) then re-allow specific writes. // Seatbelt is last-match-wins, so the trailing allow wins for the listed paths. return `(version 1) (allow default) (deny file-write*) (allow file-write* ${subpaths} (literal "/dev/null") (literal "/dev/zero") (literal "/dev/tty") (literal "/dev/stdout") (literal "/dev/stderr") (regex #"^/dev/fd/") (regex #"^/dev/ptmx$") (regex #"^/dev/ttys[0-9]*$")) `; } /** bwrap args for a given exec cwd (identity paths; network left on). */ function buildBwrapArgs(spec: SandboxSpec, execCwd: string): string[] { const args = [ "--ro-bind", "/", "/", "--dev", "/dev", "--tmpfs", "/dev/shm", "--proc", "/proc", "--unshare-pid", "--tmpfs", "/tmp", "--tmpfs", "/var/tmp", "--tmpfs", "/run", "--die-with-parent", ]; // Writable binds go last so they win over the read-only root for their paths. const writable = [spec.scratch]; if (spec.mode === "restricted") { writable.push(spec.cwd); writable.push(...spec.allow); } for (const dir of writable) args.push("--bind", dir, dir); args.push("--chdir", execCwd); args.push("--"); return args; } export class Sandbox { private constructor( readonly spec: SandboxSpec, readonly shellPath: string, private readonly profilePath: string | undefined, // sandbox-exec only ) {} static async create(spec: SandboxSpec): Promise { let profilePath: string | undefined; if (spec.backend === "sandbox-exec") { profilePath = join(spec.scratch, "guard.sb"); writeFileSync(profilePath, buildSeatbeltProfile(spec), { mode: 0o600 }); } const shell = await Sandbox.probeShell(); return new Sandbox(spec, shell, profilePath); } private static async probeShell(): Promise { try { const res = await spawnCapture("/bin/sh", ["-c", "command -v bash || true"], { timeout: 10 }); const p = res.stdout.toString("utf8").trim(); return p || "/bin/sh"; } catch { return "/bin/sh"; } } /** Build the wrapper prefix (binary + args) and the child cwd for a given exec cwd. */ private wrap(execCwd: string): { bin: string; pre: string[]; cwd: string } { if (this.spec.backend === "bwrap") { // bwrap sets the child cwd via --chdir; the wrapper process cwd is irrelevant. return { bin: "bwrap", pre: buildBwrapArgs(this.spec, execCwd), cwd: this.spec.cwd }; } // sandbox-exec inherits the child cwd from the spawned process. return { bin: SANDBOX_EXEC, pre: ["-f", this.profilePath as string], cwd: execCwd }; } private env(extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { // Inherit the host env (tokens etc. propagate for free) and pin TMPDIR to // the persistent per-session scratch so temp files survive between calls. return { ...process.env, ...extra, TMPDIR: this.spec.scratch }; } /** Run a command in the sandbox, capturing stdout (binary-safe). */ exec(argv: string[], opts: { input?: Buffer | string; signal?: AbortSignal; timeout?: number; workdir?: string } = {}) { const { bin, pre, cwd } = this.wrap(opts.workdir ?? this.spec.cwd); return spawnCapture(bin, [...pre, ...argv], { input: opts.input, signal: opts.signal, timeout: opts.timeout, cwd, env: this.env(), }); } /** Run a shell command in the sandbox, streaming merged output to onData. */ execShellStream( command: string, workdir: string, opts: { onData: (b: Buffer) => void; signal?: AbortSignal; timeout?: number; env?: NodeJS.ProcessEnv }, ) { const { bin, pre, cwd } = this.wrap(workdir); return spawnStream(bin, [...pre, this.shellPath, "-lc", command], { onData: opts.onData, signal: opts.signal, timeout: opts.timeout, cwd, env: this.env(opts.env), }); } }