SPB Git

Repository Public

research-sandbox.ts 5.4 KB 74 lines typescript

1// V7 research runtime — the macOS sandbox profile (SBPL, for /usr/bin/sandbox-exec) under which the coding agent of a research2// job runs, and under which the API runs the laboratory on a SUBMITTED (untrusted) strategy. Pure: builds a string.3//4// Shape: `(deny default)`, then only what a Python / Node toolchain needs.5//   files    read: the system's own libraries and binaries, the toolchain directories the caller lists, the job directory;6//            write: the job directory only. The user's home, every other project, /opt/homebrew/etc and var, /Volumes, /tmp7//            at large and every device except null/random are not readable — they are simply not in the list.8//   network  outbound TCP to the listed ports of the loopback interface only (the runner's model gateway). No other host, no9//            other local port (the wallet API, the signer, PostgreSQL), no Unix socket (PostgreSQL's /tmp/.s.PGSQL.*, ssh-agent,10//            Docker), no DNS (mDNSResponder is a Mach service: not allowed), no inbound.11//   IPC      no Mach service lookup at all: no keychain (securityd), no AppleEvents, no pasteboard, no launchd job control.12//   process  fork/exec of readable binaries; children inherit the sandbox and can never leave it. Signals only inside the sandbox.13// SBPL can only filter network destinations as `localhost:<port>` or `*:<port>`: a per-host allow-list does not exist at this14// layer, which is why the only door is a loopback port owned by the runner, and the runner decides what is behind it.1516export interface SandboxSpec {17  /** canonical absolute paths the process may read AND write (the job directory) */18  writable: string[];19  /** canonical absolute paths it may only read (the laboratory's virtualenv, the interpreter, the agent CLI) */20  readable: string[];21  /** loopback TCP ports it may connect to; empty = no network at all */22  localPorts: number[];23}2425/** read-only system locations every dynamically linked program needs; nothing here belongs to the user */26export const SYSTEM_READ = [27  '/usr/lib', '/usr/bin', '/usr/sbin', '/usr/share', '/usr/libexec', '/bin', '/sbin',28  '/System/Library', '/System/Cryptexes', '/System/Volumes/Preboot/Cryptexes', '/Library/Apple', '/Library/Preferences/Logging',29  '/private/var/db/timezone', '/private/var/db/dyld', '/private/var/select', '/private/etc/localtime', '/private/etc/ssl', '/private/etc/shells',30] as const;31/** Homebrew's programs and libraries (never its etc/ or var/, where services keep configuration and data) */32export const HOMEBREW_READ = ['/opt/homebrew/bin', '/opt/homebrew/Cellar', '/opt/homebrew/opt', '/opt/homebrew/lib', '/opt/homebrew/share',33  '/usr/local/bin', '/usr/local/lib', '/usr/local/Cellar', '/usr/local/opt'] as const;34const DEVICES_RW = ['/dev/null', '/dev/zero', '/dev/dtracehelper'] as const;35const DEVICES_R = ['/dev/urandom', '/dev/random'] as const;3637const q = (p: string): string => {38  if (!p.startsWith('/') || /["\\\n\r\0]/.test(p) || p.includes('/../') || p.endsWith('/..')) throw new Error(`sandbox: not a plain absolute path: ${JSON.stringify(p)}`);39  return `"${p.length > 1 ? p.replace(/\/+$/, '') : p}"`;40};41const ancestors = (p: string): string[] => { const out: string[] = []; let cur = p.replace(/\/+$/, ''); for (;;) { const i = cur.lastIndexOf('/'); if (i <= 0) break; cur = cur.slice(0, i); out.push(cur); } return out; };4243/** The profile text. Paths must already be canonical (realpath: /tmp is /private/tmp on macOS) — the kernel matches real paths. */44export function sandboxProfile(spec: SandboxSpec): string {45  for (const port of spec.localPorts) if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('sandbox: bad port');46  if (!spec.writable.length) throw new Error('sandbox: a job directory is required');47  const read = [...SYSTEM_READ, ...HOMEBREW_READ, ...spec.readable], all = [...read, ...spec.writable];48  // stat() of the directories on the way to an allowed path (realpath, getcwd): metadata only, never their content or listing49  const walk = [...new Set(['/', ...all.flatMap(ancestors)])];50  const lines = [51    '(version 1)',52    '(deny default)',53    '(allow process-fork)',54    '(allow process-exec*)',55    '(allow signal (target same-sandbox))',56    '(allow process-info* (target same-sandbox))',57    '(allow sysctl-read)',58    `(allow file-read-metadata ${walk.map((p) => `(literal ${q(p)})`).join(' ')})`,59    `(allow file-read* (literal "/") ${read.map((p) => `(subpath ${q(p)})`).join(' ')})`,60    // OpenSSL's configuration and the CA bundle of Homebrew builds of node / python (read at start-up)61    '(allow file-read* (regex #"^/opt/homebrew/etc/(openssl@[0-9.]+|ca-certificates)(/|$)") (regex #"^/usr/local/etc/(openssl@[0-9.]+|ca-certificates)(/|$)"))',62    `(allow file-read* ${[...DEVICES_R, ...DEVICES_RW].map((p) => `(literal ${q(p)})`).join(' ')} (subpath "/dev/fd"))`,63    `(allow file-write* ${DEVICES_RW.map((p) => `(literal ${q(p)})`).join(' ')})`,64    `(allow file-ioctl ${DEVICES_RW.map((p) => `(literal ${q(p)})`).join(' ')})`,65    `(allow file-read* file-write* ${spec.writable.map((p) => `(subpath ${q(p)})`).join(' ')})`,66    ...(spec.localPorts.length ? [`(allow network-outbound ${spec.localPorts.map((p) => `(remote ip "localhost:${p}")`).join(' ')})`] : []),67  ];68  return lines.join('\n') + '\n';69}7071export const SANDBOX_EXEC = '/usr/bin/sandbox-exec';72/** argv prefix that runs a command under the profile */73export const sandboxArgv = (profilePath: string, argv: string[]): string[] => [SANDBOX_EXEC, '-f', profilePath, ...argv];74
Files main
Menu

Theme