import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { isCiRun } from "./build-environment.ts"; export const PROVIDER_SANDBOX_BINARY = "/usr/bin/bwrap"; /** * The isolation options `runProviderInSandbox` opens its bubblewrap argument * vector with. That function spreads this exact list, so the probe below can * never clear a weaker sandbox than the one the provider actually runs in — and * `tests/unit/provider-sandbox-probe.test.ts` fails if an isolation flag is * added to the run without being added here. */ export const PROVIDER_SANDBOX_ISOLATION_ARGUMENTS: readonly string[] = Object.freeze([ "--die-with-parent", "--new-session", "--as-pid-1", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-net", "--dev", "/dev", "--remount-ro", "/dev", "--proc", "/proc", "--remount-ro", "/proc", ]); /** * The read-only roots the real run binds before it can execute anything. The * probe binds the same set so `/bin/true` resolves the way a provider command * would. */ const PROVIDER_SANDBOX_PROBE_BINDINGS: readonly string[] = Object.freeze([ "/usr", "/bin", "/lib", "/lib64", ]); export const PROVIDER_SANDBOX_UNAVAILABLE = "the provider sandbox is unavailable"; export type ProviderSandboxProbe = | Readonly<{ usable: true }> | Readonly<{ usable: false; reason: string }>; export type ProviderSandboxDecision = | Readonly<{ outcome: "run" }> | Readonly<{ outcome: "skip"; reason: string }> | Readonly<{ outcome: "fail"; reason: string }>; export function providerSandboxProbeArguments(): string[] { return [ ...PROVIDER_SANDBOX_ISOLATION_ARGUMENTS, ...PROVIDER_SANDBOX_PROBE_BINDINGS.flatMap((source) => existsSync(source) ? ["--ro-bind", source, source] : []), "--", "/bin/true", ]; } /** * Runs a trivial command under the real isolation options and reports what * happened. Capability is never inferred from the binary being installed or * from a kernel/version string: on a host with * `kernel.apparmor_restrict_unprivileged_userns=1` the binary exists, reports a * version, and still cannot create the user namespace it needs. */ export function probeProviderSandbox( binary: string = PROVIDER_SANDBOX_BINARY, ): ProviderSandboxProbe { const result = spawnSync(binary, providerSandboxProbeArguments(), { encoding: "utf8", timeout: 20_000, stdio: ["ignore", "pipe", "pipe"], }); if (result.error) { const code = (result.error as NodeJS.ErrnoException).code; return { usable: false, reason: code === "ENOENT" ? `${binary} is not installed` : `${binary} could not be executed: ${result.error.message}`, }; } if (result.status === 0) return { usable: true }; return { usable: false, reason: sandboxFailureReason(binary, result) }; } function sandboxFailureReason( binary: string, result: Readonly<{ status: number | null; signal: NodeJS.Signals | null; stderr: string }>, ): string { const diagnostics = [...String(result.stderr ?? "") .matchAll(/^(?:bwrap|prlimit):\s.*$/gmu)].map(([line]) => line); const detail = diagnostics.length > 0 ? diagnostics.join("; ") : String(result.stderr ?? "").trim() || "no diagnostic output"; const exit = result.signal ? `signal=${result.signal}` : `exit=${result.status}`; return `${binary} could not start an isolated sandbox (${exit}): ${detail}`; } /** * Locally an unusable sandbox is an environment fact and the sandboxed suites * are skipped with the reason attached. In CI it is a regression — a security * gate that silently stopped running is exactly what those suites exist to * catch — so the same probe result fails the run instead. */ export function providerSandboxDecision( probe: ProviderSandboxProbe, environment: Readonly>, ): ProviderSandboxDecision { if (probe.usable) return { outcome: "run" }; const reason = `${PROVIDER_SANDBOX_UNAVAILABLE}: ${probe.reason}`; return isCiRun(environment) ? { outcome: "fail", reason } : { outcome: "skip", reason }; } export function assertProviderSandboxUsable(decision: ProviderSandboxDecision): void { if (decision.outcome === "fail") throw new Error(decision.reason); }