SPB Git

Repository Public

sandbox.ts 19.4 KB 429 lines typescript

1/**2 * Tier-1 sandbox (macOS Seatbelt) for the `shell` tool — CONTRACT §4.3 *4 * `/usr/bin/sandbox-exec -p <generated SBPL> /bin/zsh -lc <command>` in its own process group, cwd = workspace,5 * minimal environment, per-run TMPDIR, timeout (TERM then KILL of the group), 1 MiB output cap.6 * FAIL CLOSED: missing launcher, rejected profile, or a profile that does not actually enforce (canary probe)7 * ⇒ SandboxUnavailableError; a command is never run unsandboxed.8 *9 * Design of the profile (see `buildSeatbeltProfile`):10 *  - `(deny default)`: everything not listed is refused (mach services, IOKit, network, writes…).11 *  - reads: allowed everywhere except secret locations (ssh/cloud/agent credentials, keychains, the AOS data12 *    dir, env files outside the workspace).13 *  - writes: only the workspace and the per-run temp dir, MINUS the paths a trusted unsandboxed component would14 *    later execute or obey: `.git/hooks`, `.git/config` (+ `config.worktree`, `commondir`), `.aos/`, root15 *    AGENTS.md / CLAUDE.md, shell startup files. `.git` itself can be neither renamed away nor created.16 *  - network 'none': `(deny network*)`. 'open': outbound IP only, loopback excluded (local Postgres, the AOS17 *    server and other local services are not reachable), inbound denied.18 */19import { spawn } from 'node:child_process';20import { createHash, randomBytes } from 'node:crypto';21import { constants as FS, existsSync } from 'node:fs';22import { access, lstat, mkdir, realpath, rm } from 'node:fs/promises';23import os from 'node:os';24import path from 'node:path';25import { SHELL_RC_FILES } from './paths.js';2627export const DEFAULT_SANDBOX_EXEC = '/usr/bin/sandbox-exec';28export const SHELL_OUTPUT_CAP_BYTES = 1024 * 1024;29const TASKPOLICY = '/usr/sbin/taskpolicy';30const KILL_GRACE_MS = 2000;3132export class SandboxUnavailableError extends Error {33  constructor(message: string) {34    super(message);35    this.name = 'SandboxUnavailableError';36  }37}3839export interface SeatbeltSpec {40  /** Real (symlink-free) absolute paths. */41  workspaceReal: string;42  tmpDir: string;43  home: string;44  dataDir: string;45  network: 'none' | 'open';46  extraDenyRead?: string[];47}4849// ---- SBPL helpers ----------------------------------------------------------------------------------------------5051function sbStr(p: string): string {52  // eslint-disable-next-line no-control-regex53  if (/[\u0000-\u001f\u007f]/.test(p)) throw new SandboxUnavailableError('refusing to build a sandbox profile for a path containing control characters');54  if (Buffer.byteLength(p) > 900) throw new SandboxUnavailableError('refusing to build a sandbox profile for a path longer than 900 bytes');55  return `"${p.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;56}57/** Regex-escape a literal path for an SBPL `(regex #"…")` filter. */58function reEsc(p: string): string {59  // eslint-disable-next-line no-control-regex60  if (/[\u0000-\u001f\u007f"]/.test(p)) throw new SandboxUnavailableError('refusing to build a sandbox profile for a path containing quotes or control characters');61  return p.replace(/[.*+?^${}()|[\]\\/]/g, (c) => (c === '/' ? '/' : `\\${c}`));62}63/** Case-insensitive regex fragment for a file name (APFS is case-insensitive by default; SBPL regexes are not). */64function ci(name: string): string {65  return [...name].map((ch) => (/[a-zA-Z]/.test(ch) ? `[${ch.toLowerCase()}${ch.toUpperCase()}]` : reEsc(ch))).join('');66}67const sub = (p: string) => `(subpath ${sbStr(p)})`;68const lit = (p: string) => `(literal ${sbStr(p)})`;69const rex = (r: string) => `(regex #"${r}")`;7071/** Secret locations under $HOME that the sandboxed shell can never read. */72export function secretReadDenies(home: string): { subpaths: string[]; literals: string[]; regexes: string[] } {73  const h = (p: string) => path.join(home, p);74  return {75    subpaths: [76      '.ssh', '.aws', '.config', '.gnupg', '.docker', '.kube', '.azure', '.zsh_sessions', '.password-store',77      'Library/Keychains', 'Library/Cookies', 'Library/Application Support/com.apple.TCC',78    ].map(h),79    literals: [80      // shell startup files: `zsh -l` would source them and import whatever they export (API keys are common there)81      '.zshenv', '.zprofile', '.zshrc', '.zlogin', '.profile', '.bash_profile', '.bashrc',82      '.netrc', '.npmrc', '.pypirc', '.pgpass', '.git-credentials', '.zsh_history', '.bash_history', '.psql_history', '.node_repl_history', '.python_history',83    ].map(h),84    // ~/.claude*, ~/.codex* (directories and sibling files such as ~/.claude.json)85    regexes: [`^${reEsc(home)}/\\.claude`, `^${reEsc(home)}/\\.codex`],86  };87}8889export function buildSeatbeltProfile(spec: SeatbeltSpec): string {90  const { workspaceReal: W, tmpDir: T, home: H, dataDir: D } = spec;91  for (const p of [W, T, H, D]) if (!path.isAbsolute(p)) throw new SandboxUnavailableError(`sandbox path is not absolute: ${p}`);92  const secrets = secretReadDenies(H);93  const Wre = reEsc(W);94  const L: string[] = [];95  L.push('(version 1)', '(deny default)');9697  // -- processes: run and signal only what lives in this sandbox98  L.push('(allow process-exec)', '(allow process-fork)');99  L.push('(allow process-info* (target same-sandbox))', '(allow signal (target same-sandbox))', '(allow mach-priv-task-port (target same-sandbox))');100101  // -- the minimum of system services local tooling needs (user/group lookup, logging, notify, per-user dirs)102  L.push('(allow user-preference-read)');103  L.push(104    '(allow mach-lookup ' +105      [106        'com.apple.system.opendirectoryd.libinfo',107        'com.apple.system.opendirectoryd.membership',108        'com.apple.system.notification_center',109        'com.apple.system.logger',110        'com.apple.logd',111        'com.apple.bsd.dirhelper',112      ]113        .map((n) => `(global-name "${n}")`)114        .join(' ') +115      ')',116  );117  L.push('(allow ipc-posix-shm)', '(allow ipc-posix-sem)', '(allow sysctl-read)');118  // setpriority(): zsh renices every background job (BG_NICE) and `nice` is common in build scripts; without119  // this each `cmd &` prints "nice(5) failed: operation not permitted". An unprivileged process can only lower itself.120  L.push('(allow system-sched)');121  L.push('(allow file-ioctl ' + ['/dev/null', '/dev/zero', '/dev/random', '/dev/urandom', '/dev/dtracehelper', '/dev/tty'].map(lit).join(' ') + ')');122123  // -- network124  if (spec.network === 'open') {125    L.push('(allow network-outbound (remote tcp "*:*") (remote udp "*:*"))');126    // name resolution and TLS trust evaluation go through system daemons127    L.push('(allow network-outbound (literal "/private/var/run/mDNSResponder"))');128    L.push('(allow system-socket)');129    L.push('(allow mach-lookup (global-name "com.apple.trustd.agent") (global-name "com.apple.SystemConfiguration.configd") (global-name "com.apple.SystemConfiguration.DNSConfiguration") (global-name "com.apple.dnssd.service") (global-name "com.apple.networkd"))');130    L.push('(deny network-inbound)', '(deny network-bind)');131    // Loopback is NOT the Internet: local Postgres, the AOS server and dev daemons must stay unreachable.132    // Observed on macOS 27: this deny only takes effect when it comes AFTER the other network rules (placed133    // right after the allow it was silently ineffective) — keep it last; a test connects to a live listener.134    L.push('(deny network-outbound (remote tcp "localhost:*") (remote udp "localhost:*"))');135  } else {136    L.push('(deny network*)');137  }138139  // -- reads: everything except secrets. Later rules win in SBPL.140  L.push('(allow file-read*)');141  L.push(`(deny file-read* ${[...secrets.subpaths.map(sub), ...secrets.literals.map(lit), ...secrets.regexes.map(rex)].join(' ')})`);142  // env files anywhere (…/.env, …/.env.local, …/dev.env); re-allowed inside the workspace just below143  L.push(`(deny file-read* ${rex('/\\.env(\\.[^/]*)?$')} ${rex('/[^/]*\\.env$')})`);144  for (const p of spec.extraDenyRead ?? []) {145    if (!path.isAbsolute(p)) throw new SandboxUnavailableError(`extraDenyRead must be absolute: ${p}`);146    L.push(`(deny file-read* ${sub(p)})`);147  }148  L.push(`(deny file-read* ${sub(D)})`);149  L.push(`(allow file-read* ${sub(W)} ${sub(T)})`);150  const dataInsideWorkspace = D === W || D.startsWith(W + path.sep);151  if (dataInsideWorkspace) L.push(`(deny file-read* ${sub(D)})`);152153  // -- writes: workspace + per-run temp only154  L.push(`(allow file-write* ${sub(W)} ${sub(T)})`);155  L.push(`(allow file-write-data ${['/dev/null', '/dev/tty', '/dev/dtracehelper', '/dev/stdout', '/dev/stderr'].map(lit).join(' ')} ${rex('^/dev/fd/[0-9]+$')})`);156  // …minus what a trusted, unsandboxed component would execute or obey later157  const gitDir = `^${Wre}/${ci('.git')}`;158  L.push(159    '(deny file-write* ' +160      [161        rex(`${gitDir}/${ci('hooks')}(/|$)`),162        rex(`${gitDir}/${ci('config')}$`),163        rex(`${gitDir}/${ci('config.worktree')}$`),164        rex(`${gitDir}/${ci('commondir')}$`),165        rex(`^${Wre}/${ci('.aos')}(/|$)`),166        rex(`^${Wre}/${ci('AGENTS.md')}$`),167        rex(`^${Wre}/${ci('CLAUDE.md')}$`),168        rex(`^${Wre}/(.*/)?(${SHELL_RC_FILES.map(ci).join('|')})$`),169      ].join(' ') +170      ')',171  );172  // `.git` itself cannot be renamed away, deleted, replaced or created (no `git init` inside the sandbox)173  L.push(`(deny file-write-create file-write-unlink ${rex(`${gitDir}$`)})`);174  if (dataInsideWorkspace) L.push(`(deny file-write* ${sub(D)})`);175  return L.join('\n') + '\n';176}177178// ---- execution --------------------------------------------------------------------------------------------------179180export interface SandboxRunOptions {181  command: string;182  workspaceAbs: string;183  network: 'none' | 'open';184  dataDir: string;185  runId: string;186  timeoutMs: number;187  signal?: AbortSignal;188  sandboxExecPath?: string;189  extraDenyRead?: string[];190  maxOutputBytes?: number;191}192export interface SandboxRunResult {193  exitCode: number | null;194  signal: string | null;195  /** stdout + stderr interleaved (the command runs with `2>&1`). */196  output: string;197  timedOut: boolean;198  aborted: boolean;199  outputTruncated: boolean;200  durationMs: number;201}202203/** Per-run temp directory (the only writable temp for the sandboxed shell). Created 0700 on first use. */204export async function runTmpDir(runId: string): Promise<string> {205  const root = await realpath(os.tmpdir());206  const safe = runId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'run';207  const dir = path.join(root, `aos-run-${safe}`);208  await mkdir(dir, { recursive: true, mode: 0o700 });209  const st = await lstat(dir);210  if (!st.isDirectory() || st.isSymbolicLink() || (typeof process.getuid === 'function' && st.uid !== process.getuid())) {211    throw new SandboxUnavailableError(`per-run temp dir ${dir} is not a directory owned by this user`);212  }213  return dir;214}215/**216 * Remove a run's temp directory and, when `dataDir` is given, its edit journal. The engine/worker calls this when217 * a run reaches a terminal status.218 */219export async function cleanupRunTmp(runId: string, dataDir?: string): Promise<void> {220  const root = await realpath(os.tmpdir());221  const safe = runId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'run';222  await rm(path.join(root, `aos-run-${safe}`), { recursive: true, force: true });223  if (dataDir) await rm(path.join(dataDir, 'edit-journal', runId.replace(/[^A-Za-z0-9_-]/g, '_')), { recursive: true, force: true });224}225226/** The ONLY environment a sandboxed command sees. Never derived from the worker's env beyond PATH and LANG. */227export function sandboxEnv(tmpDir: string): Record<string, string> {228  const user = os.userInfo().username;229  return {230    PATH: process.env.PATH && process.env.PATH.length > 0 ? process.env.PATH : '/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',231    HOME: os.homedir(),232    LANG: process.env.LANG && /^[A-Za-z0-9_.@-]+$/.test(process.env.LANG) ? process.env.LANG : 'en_US.UTF-8',233    TMPDIR: tmpDir,234    TERM: 'dumb',235    USER: user,236    LOGNAME: user,237    SHELL: '/bin/zsh',238    // non-secret redirections so common tooling works with a read-only $HOME:239    TMPPREFIX: path.join(tmpDir, 'zsh'), // zsh here-documents240    XDG_CACHE_HOME: path.join(tmpDir, 'cache'),241    XDG_CONFIG_HOME: path.join(tmpDir, 'config'), // ~/.config is unreadable; avoids warnings from git & co.242    npm_config_cache: path.join(tmpDir, 'npm-cache'),243    npm_config_userconfig: path.join(tmpDir, 'npmrc'), // ~/.npmrc (tokens) is unreadable244    npm_config_update_notifier: 'false',245    PIP_CACHE_DIR: path.join(tmpDir, 'pip-cache'),246    GIT_TERMINAL_PROMPT: '0',247  };248}249250const preflightOk = new Set<string>();251252function collect(cmd: string, args: string[], env: Record<string, string>, cwd: string, timeoutMs: number): Promise<{ code: number | null; out: string; spawnError: string | null }> {253  return new Promise((resolve) => {254    let out = '';255    let done = false;256    const child = spawn(cmd, args, { env, cwd, stdio: ['ignore', 'pipe', 'pipe'] });257    const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);258    const finish = (code: number | null, spawnError: string | null) => {259      if (done) return;260      done = true;261      clearTimeout(timer);262      resolve({ code, out, spawnError });263    };264    child.stdout.on('data', (d: Buffer) => (out += d.toString('utf8')));265    child.stderr.on('data', (d: Buffer) => (out += d.toString('utf8')));266    child.on('error', (e) => finish(null, e.message));267    child.on('close', (code) => finish(code, null));268  });269}270271/**272 * Prove — once per (launcher, profile) — that the launcher exists, accepts the profile and that the profile273 * really denies a write outside the allowed roots. Anything else ⇒ SandboxUnavailableError.274 */275async function preflight(sandboxExec: string, profile: string, env: Record<string, string>, cwd: string): Promise<void> {276  const key = createHash('sha256').update(sandboxExec).update('\0').update(profile).digest('hex');277  if (preflightOk.has(key)) return;278  try {279    await access(sandboxExec, FS.X_OK);280  } catch {281    throw new SandboxUnavailableError(`sandbox launcher not found or not executable: ${sandboxExec}`);282  }283  const canary = path.join(await realpath(os.tmpdir()), `aos-canary-${randomBytes(8).toString('hex')}`);284  try {285    const r = await collect(sandboxExec, ['-p', profile, '/bin/sh', '-c', '( : > "$1" ) 2>/dev/null; if [ -e "$1" ]; then echo AOS_SANDBOX_LEAK; else echo AOS_SANDBOX_OK; fi', 'sh', canary], env, cwd, 15_000);286    if (r.spawnError) throw new SandboxUnavailableError(`sandbox launcher failed to start: ${r.spawnError}`);287    if (existsSync(canary) || r.out.includes('AOS_SANDBOX_LEAK')) throw new SandboxUnavailableError('sandbox profile is not enforced (a write outside the workspace succeeded during the self-test)');288    if (r.code !== 0 || !r.out.includes('AOS_SANDBOX_OK')) {289      throw new SandboxUnavailableError(`sandbox profile was rejected or the self-test failed (exit ${r.code}): ${r.out.trim().slice(0, 300)}`);290    }291  } finally {292    await rm(canary, { force: true });293  }294  if (preflightOk.size > 2000) preflightOk.clear();295  preflightOk.add(key);296}297298function killGroup(pid: number, sig: NodeJS.Signals): void {299  try {300    process.kill(-pid, sig);301  } catch {302    /* group already gone */303  }304}305306export async function runSandboxed(opts: SandboxRunOptions): Promise<SandboxRunResult> {307  if (process.platform !== 'darwin') {308    throw new SandboxUnavailableError(`the shell tool is disabled on ${process.platform}: the Seatbelt sandbox exists only on macOS, and commands are never run unsandboxed`);309  }310  const sandboxExec = opts.sandboxExecPath ?? DEFAULT_SANDBOX_EXEC;311  let workspaceReal: string;312  try {313    workspaceReal = await realpath(opts.workspaceAbs);314  } catch {315    throw new SandboxUnavailableError('workspace does not exist');316  }317  const tmpDir = await runTmpDir(opts.runId);318  let dataDir = path.resolve(opts.dataDir);319  try {320    dataDir = await realpath(dataDir);321  } catch {322    /* not created yet: deny the lexical path */323  }324  const home = await realpath(os.homedir());325  const extraDenyRead: string[] = [];326  for (const p of opts.extraDenyRead ?? []) {327    extraDenyRead.push(p);328    try {329      const rp = await realpath(p);330      if (rp !== p) extraDenyRead.push(rp);331    } catch {332      /* keep lexical */333    }334  }335  const profile = buildSeatbeltProfile({ workspaceReal, tmpDir, home, dataDir, network: opts.network, extraDenyRead });336  const env = sandboxEnv(tmpDir);337  await preflight(sandboxExec, profile, env, workspaceReal);338339  // `exec 2>&1` on its own line: one ordered stream for stdout+stderr, the command text stays untouched.340  const sbArgs = ['-p', profile, '/bin/zsh', '-lc', `exec 2>&1\n${opts.command}`];341  const useTaskpolicy = existsSync(TASKPOLICY);342  const cmd = useTaskpolicy ? TASKPOLICY : sandboxExec;343  const args = useTaskpolicy ? ['-c', 'utility', sandboxExec, ...sbArgs] : sbArgs;344  const cap = opts.maxOutputBytes ?? SHELL_OUTPUT_CAP_BYTES;345  const started = Date.now();346347  return new Promise<SandboxRunResult>((resolve, reject) => {348    const child = spawn(cmd, args, { cwd: workspaceReal, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });349    const chunks: Buffer[] = [];350    let size = 0;351    let timedOut = false;352    let aborted = false;353    let outputTruncated = false;354    let settled = false;355    let exitInfo: { code: number | null; signal: string | null } | null = null;356    let killTimer: NodeJS.Timeout | null = null;357    let closeTimer: NodeJS.Timeout | null = null;358359    const terminate = () => {360      if (child.pid === undefined) return;361      killGroup(child.pid, 'SIGTERM');362      if (!killTimer) killTimer = setTimeout(() => child.pid !== undefined && killGroup(child.pid, 'SIGKILL'), KILL_GRACE_MS);363    };364    const timeout = setTimeout(() => {365      timedOut = true;366      terminate();367    }, opts.timeoutMs);368    const onAbort = () => {369      aborted = true;370      terminate();371    };372    if (opts.signal) {373      if (opts.signal.aborted) onAbort();374      else opts.signal.addEventListener('abort', onAbort, { once: true });375    }376    const onData = (d: Buffer) => {377      if (outputTruncated) return;378      if (size + d.length > cap) {379        chunks.push(d.subarray(0, Math.max(0, cap - size)));380        size = cap;381        outputTruncated = true;382        if (child.pid !== undefined) killGroup(child.pid, 'SIGKILL');383      } else {384        chunks.push(d);385        size += d.length;386      }387    };388    child.stdout.on('data', onData);389    child.stderr.on('data', onData);390391    const settle = () => {392      if (settled) return;393      settled = true;394      clearTimeout(timeout);395      if (killTimer) clearTimeout(killTimer);396      if (closeTimer) clearTimeout(closeTimer);397      opts.signal?.removeEventListener('abort', onAbort);398      // nothing of the group may outlive the call (background jobs, grandchildren)399      if (child.pid !== undefined) killGroup(child.pid, 'SIGKILL');400      child.stdout.destroy();401      child.stderr.destroy();402      resolve({403        exitCode: exitInfo?.code ?? null,404        signal: exitInfo?.signal ?? null,405        output: Buffer.concat(chunks).toString('utf8'),406        timedOut,407        aborted,408        outputTruncated,409        durationMs: Date.now() - started,410      });411    };412    child.on('error', (e) => {413      if (settled) return;414      settled = true;415      clearTimeout(timeout);416      if (killTimer) clearTimeout(killTimer);417      opts.signal?.removeEventListener('abort', onAbort);418      reject(new SandboxUnavailableError(`failed to start the sandbox: ${e.message}`));419    });420    child.on('exit', (code, signal) => {421      exitInfo = { code, signal };422      // the shell is gone: reap whatever it left in its group, then give the pipes a moment to drain423      if (child.pid !== undefined) killGroup(child.pid, 'SIGKILL');424      closeTimer = setTimeout(settle, 1000);425    });426    child.on('close', settle);427  });428}429
Files main
Menu

Theme