Repository Public
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