Repository Public
1/**2 * Security tests of the Seatbelt sandbox. They REALLY execute commands on this machine (macOS only) and assert3 * on observable side effects, not on the profile text.4 */5import { execFileSync } from 'node:child_process';6import { randomBytes } from 'node:crypto';7import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';8import net from 'node:net';9import os from 'node:os';10import path from 'node:path';11import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';12import { buildSeatbeltProfile, runTmpDir, sandboxEnv, TOOLS } from './index.js';13import { git, makeFixture, type Fixture } from './testutil.js';1415const mac = process.platform === 'darwin';16const HOME = os.homedir();17const tag = randomBytes(4).toString('hex');18const SECRET_ENV = { ANTHROPIC_API_KEY: 'sk-ant-test-LEAK', OPENAI_API_KEY: 'sk-test-LEAK', DATABASE_URL: 'postgres://LEAK@localhost/aos', AOS_SECRET_KEY: 'LEAK', SSH_AUTH_SOCK: '/tmp/LEAK.sock' };19const savedEnv: Record<string, string | undefined> = {};20const strayFiles: string[] = [];2122let fx: Fixture;23beforeEach(() => {24 fx = makeFixture({ git: true });25 for (const [k, v] of Object.entries(SECRET_ENV)) {26 savedEnv[k] = process.env[k];27 process.env[k] = v;28 }29});30afterEach(async () => {31 for (const k of Object.keys(SECRET_ENV)) {32 if (savedEnv[k] === undefined) delete process.env[k];33 else process.env[k] = savedEnv[k];34 }35 await fx.cleanup();36});37afterAll(() => {38 for (const f of strayFiles) rmSync(f, { force: true, recursive: true });39});4041const sh = (command: string, over: Parameters<Fixture['call']>[2] = {}, timeoutSec: number | null = null) => fx.call(TOOLS.shell!, { command, timeoutSec }, over);42const pgrep = (pattern: string): string => {43 try {44 return execFileSync('/usr/bin/pgrep', ['-f', pattern], { encoding: 'utf8' }).trim();45 } catch {46 return ''; // exit 1 = no process47 }48};4950describe.runIf(mac)('shell: basic behaviour', () => {51 it('runs in the workspace, returns exit code and combined output in order', async () => {52 const res = await sh('pwd; echo out; echo err >&2; echo out2; exit 3');53 expect(res.ok).toBe(false);54 expect(res.output).toBe(`[exit code 3]\n${fx.ws}\nout\nerr\nout2`);55 const ok = await sh('echo hi > made.txt && cat made.txt');56 expect(ok).toMatchObject({ ok: true, output: '[exit code 0]\nhi' });57 expect(readFileSync(path.join(fx.ws, 'made.txt'), 'utf8')).toBe('hi\n');58 expect((await sh('true')).output).toBe('[exit code 0]\n(no output)');59 });6061 it('supports here-documents, pipes, multi-line scripts, /dev/null and /dev/stderr', async () => {62 const res = await sh('cat <<EOF > h.txt\nline1\nline2\nEOF\nwc -l < h.txt | tr -d " "\necho quiet > /dev/null\necho toerr > /dev/stderr\nfor i in 1 2; do echo "i=$i"; done');63 expect(res.output).toBe('[exit code 0]\n2\ntoerr\ni=1\ni=2');64 });6566 it('local tooling works offline: git commit, node, python3, an npm-test style project', async () => {67 const res = await sh(68 [69 'set -e',70 'echo "console.log(6*7)" > calc.js && node calc.js',71 'python3 -c "import tempfile,os; f=tempfile.NamedTemporaryFile(); print(os.path.dirname(f.name)==os.environ[\'TMPDIR\'].rstrip(\'/\'))"',72 'mkdir -p t && echo "{\\"name\\":\\"x\\",\\"version\\":\\"1.0.0\\",\\"scripts\\":{\\"test\\":\\"node --test\\"}}" > package.json',73 'echo "const test=require(\'node:test\');const assert=require(\'node:assert\');test(\'adds\',()=>assert.equal(1+1,2));" > t/a.test.js',74 'npm test 2>&1 | grep -E "^. (pass|fail) "',75 'git add -A && git commit -q -m "from sandbox" && git log --format=%s -1',76 'git status --porcelain | wc -l | tr -d " "',77 ].join('\n'),78 );79 expect(res.ok, res.output).toBe(true);80 expect(res.output).toBe('[exit code 0]\n42\nTrue\nℹ pass 1\nℹ fail 0\nfrom sandbox\n0');81 expect(res.output).not.toMatch(/warning|not permitted/i);82 expect(git(fx.ws, 'log', '-1', '--format=%s').trim()).toBe('from sandbox');83 });8485 it('per-run TMPDIR is writable, persists across calls of the run, and is private to it', async () => {86 const res = await sh('echo keep > "$TMPDIR/keep.txt"; echo "$TMPDIR"');87 expect(res.ok).toBe(true);88 const tmp = await runTmpDir(fx.runId);89 expect(res.output).toBe(`[exit code 0]\n${tmp}`);90 expect((await sh('cat "$TMPDIR/keep.txt"')).output).toBe('[exit code 0]\nkeep');91 expect(tmp.startsWith(realpathSync(os.tmpdir()))).toBe(true);92 });93});9495describe.runIf(mac)('shell: environment', () => {96 it('never contains the worker secrets, only the allowlist', async () => {97 const res = await sh('env');98 expect(res.ok).toBe(true);99 expect(res.output).not.toMatch(/LEAK/);100 for (const k of Object.keys(SECRET_ENV)) expect(res.output).not.toContain(`${k}=`);101 expect(res.output).toMatch(/^HOME=/m);102 expect(res.output).toMatch(/^TERM=dumb$/m);103 expect(res.output).toMatch(new RegExp(`^TMPDIR=.*aos-run-${fx.runId}$`, 'm'));104 const allowed = new Set([...Object.keys(sandboxEnv('/x')), 'PWD', 'OLDPWD', 'SHLVL', '_', 'LOGNAME', 'MANPATH', 'INFOPATH', 'FPATH', '__CF_USER_TEXT_ENCODING', 'XPC_SERVICE_NAME', 'XPC_FLAGS']);105 const names = res.output.split('\n').slice(1).map((l) => l.split('=')[0]!).filter((n) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(n));106 const unexpected = names.filter((n) => !allowed.has(n) && !n.startsWith('HOMEBREW_'));107 expect(unexpected).toEqual([]);108 });109 it('sandboxEnv is built from scratch (no key of the parent env leaks through)', () => {110 const env = sandboxEnv('/private/tmp/x');111 expect(Object.keys(env).sort()).toEqual(['GIT_TERMINAL_PROMPT', 'HOME', 'LANG', 'LOGNAME', 'PATH', 'PIP_CACHE_DIR', 'SHELL', 'TERM', 'TMPDIR', 'TMPPREFIX', 'USER', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'npm_config_cache', 'npm_config_update_notifier', 'npm_config_userconfig']);112 expect(JSON.stringify(env)).not.toMatch(/LEAK/);113 });114});115116describe.runIf(mac)('shell: writes are confined', () => {117 it('cannot write outside the workspace: home, /tmp, sibling temp dirs, the data dir', async () => {118 const homeTarget = path.join(HOME, `aos-escape-test-${tag}`);119 const tmpTarget = `/tmp/aos-outside-${tag}`;120 const sibling = path.join(realpathSync(os.tmpdir()), `aos-sibling-${tag}`);121 const rootSibling = path.join(fx.root, 'outside.txt');122 strayFiles.push(homeTarget, tmpTarget, sibling);123 const res = await sh(124 `touch ~/aos-escape-test-${tag}; echo "home=$?"; echo x > ${tmpTarget}; echo "tmp=$?"; echo x > "$TMPDIR/../aos-sibling-${tag}"; echo "sibling=$?"; ` +125 `echo x > '${rootSibling}'; echo "root=$?"; echo x > '${fx.dataDir}/x'; echo "data=$?"; mkdir -p ~/Library/LaunchAgents/aos-${tag}; echo "la=$?"; echo in > inside.txt; echo "ws=$?"`,126 );127 expect(res.output).toMatch(/home=1/);128 expect(res.output).toMatch(/tmp=1/);129 expect(res.output).toMatch(/sibling=1/);130 expect(res.output).toMatch(/root=1/);131 expect(res.output).toMatch(/data=1/);132 expect(res.output).toMatch(/la=1/);133 expect(res.output).toMatch(/ws=0/);134 expect(res.output).toMatch(/[Oo]peration not permitted/);135 for (const f of [homeTarget, tmpTarget, sibling, rootSibling, path.join(fx.dataDir, 'x'), path.join(HOME, 'Library/LaunchAgents', `aos-${tag}`)]) expect(existsSync(f), f).toBe(false);136 expect(existsSync(path.join(fx.ws, 'inside.txt'))).toBe(true);137 });138139 it('cannot write through a symlink or a hard link that points outside the workspace', async () => {140 const victim = path.join(fx.root, 'victim.txt');141 writeFileSync(victim, 'original\n');142 symlinkSync(victim, path.join(fx.ws, 'link'));143 symlinkSync(fx.root, path.join(fx.ws, 'dirlink'));144 const res = await sh(`echo pwned > link; echo "sym=$?"; echo pwned > dirlink/victim.txt; echo "dir=$?"; echo x > dirlink/new.txt; echo "new=$?"; ln '${victim}' hard; echo "hard=$?"; ln -s '${victim}' l2 && echo pwned >> l2; echo "l2=$?"`);145 expect(res.output).toMatch(/sym=1/);146 expect(res.output).toMatch(/dir=1/);147 expect(res.output).toMatch(/new=1/);148 expect(res.output).toMatch(/hard=1/);149 expect(res.output).toMatch(/l2=1/);150 expect(readFileSync(victim, 'utf8')).toBe('original\n');151 expect(existsSync(path.join(fx.root, 'new.txt'))).toBe(false);152 // and the file tools refuse the same link153 const w = await fx.call(TOOLS.write_file!, { path: 'link', content: 'pwned' });154 expect(w.ok).toBe(false);155 expect(w.output).toMatch(/symbolic link/);156 const w2 = await fx.call(TOOLS.write_file!, { path: 'dirlink/new2.txt', content: 'pwned' });157 expect(w2.ok).toBe(false);158 expect(readFileSync(victim, 'utf8')).toBe('original\n');159 expect(existsSync(path.join(fx.root, 'new2.txt'))).toBe(false);160 });161162 it('protected paths are read-only from the shell: .git/hooks, .git/config, .aos, AGENTS.md, CLAUDE.md, rc files', async () => {163 writeFileSync(path.join(fx.ws, 'AGENTS.md'), 'owner rules\n');164 const configBefore = readFileSync(path.join(fx.ws, '.git/config'), 'utf8');165 const res = await sh(166 [167 'mkdir -p .git/hooks; echo "#!/bin/sh" > .git/hooks/pre-commit; echo "hook=$?"',168 'echo "[core]" >> .git/config; echo "config=$?"',169 'git config core.fsmonitor "touch pwned"; echo "gitconfig=$?"',170 'git config alias.x "!touch pwned"; echo "alias=$?"',171 'echo x > .GIT/hooks/post-commit; echo "case=$?"',172 'cp .git/config c.tmp && echo evil >> c.tmp && mv c.tmp .git/config; echo "mvonto=$?"',173 'mv .git .git-moved; echo "mvgit=$?"',174 'rm -rf .git/hooks 2>/dev/null; ls -d .git/hooks >/dev/null; echo "hooksdir=$?"',175 'ln -s /tmp .git/hooks/l; echo "hooklink=$?"',176 'echo ../evil > .git/commondir; echo "commondir=$?"',177 'echo x > .git/config.worktree; echo "cfgwt=$?"',178 'mkdir .aos; echo "aosdir=$?"; echo x > .aos/x; echo "aos=$?"',179 'echo evil >> AGENTS.md; echo "agents=$?"; rm AGENTS.md; echo "rmagents=$?"; mv AGENTS.md A.bak; echo "mvagents=$?"',180 'echo evil > CLAUDE.md; echo "claude=$?"; echo evil > claude.md; echo "claudelc=$?"',181 'echo evil > .zshrc; echo "zshrc=$?"; mkdir -p sub && echo evil > sub/.bash_profile; echo "subrc=$?"',182 'echo fine > notes.zshrc.txt; echo "fine=$?"',183 ].join('\n'),184 );185 for (const k of ['hook', 'config', 'gitconfig', 'alias', 'case', 'mvonto', 'mvgit', 'hooklink', 'commondir', 'cfgwt', 'aosdir', 'aos', 'agents', 'rmagents', 'mvagents', 'claude', 'claudelc', 'zshrc', 'subrc']) {186 expect(res.output, k).toMatch(new RegExp(`^${k}=[1-9]\\d*$`, 'm'));187 }188 expect(res.output).toMatch(/^hooksdir=0$/m);189 expect(res.output).toMatch(/^fine=0$/m);190 expect(readFileSync(path.join(fx.ws, '.git/config'), 'utf8')).toBe(configBefore);191 expect(existsSync(path.join(fx.ws, '.git/hooks/pre-commit'))).toBe(false);192 expect(existsSync(path.join(fx.ws, '.git/hooks/post-commit'))).toBe(false);193 expect(existsSync(path.join(fx.ws, '.git/commondir'))).toBe(false);194 expect(existsSync(path.join(fx.ws, '.aos'))).toBe(false);195 expect(readFileSync(path.join(fx.ws, 'AGENTS.md'), 'utf8')).toBe('owner rules\n');196 for (const f of ['CLAUDE.md', 'claude.md', '.zshrc', 'sub/.bash_profile', 'pwned', '.git-moved']) expect(existsSync(path.join(fx.ws, f)), f).toBe(false);197 // …while ordinary git work still functions in the same repository198 const commit = await sh('echo more > more.txt && git add -A && git commit -q -m "still works" && git checkout -q -b feature && git branch --show-current && git stash list | wc -l | tr -d " "');199 expect(commit.output).toBe('[exit code 0]\nfeature\n0');200 });201202 it('protected paths are refused by the file tools too', async () => {203 for (const p of ['.git/config', '.git/hooks/pre-commit', 'AGENTS.md', '.aos/x']) {204 const res = await fx.call(TOOLS.write_file!, { path: p, content: 'evil' });205 expect(res.ok, p).toBe(false);206 expect(res.output, p).toMatch(/protected/);207 }208 await fx.call(TOOLS.read_file!, { path: '.git/config' });209 const e = await fx.call(TOOLS.edit_file!, { path: '.git/config', oldString: '[core]', newString: '[core]\n\tfsmonitor = evil' });210 expect(e.ok).toBe(false);211 expect(readFileSync(path.join(fx.ws, '.git/config'), 'utf8')).not.toMatch(/evil/);212 expect(existsSync(path.join(fx.ws, '.git/hooks/pre-commit'))).toBe(false);213 expect(existsSync(path.join(fx.ws, 'AGENTS.md'))).toBe(false);214 expect(existsSync(path.join(fx.ws, '.aos'))).toBe(false);215 });216217 it('a workspace without .git cannot grow one from the shell (no agent-authored git config)', async () => {218 const plain = makeFixture();219 try {220 const res = await plain.call(TOOLS.shell!, { command: 'git init -q . 2>&1 | head -1; mkdir .git; echo "mk=$?"; echo ok > f.txt; echo "f=$?"', timeoutSec: null });221 expect(res.output).toMatch(/^mk=1$/m);222 expect(res.output).toMatch(/^f=0$/m);223 expect(existsSync(path.join(plain.ws, '.git'))).toBe(false);224 } finally {225 await plain.cleanup();226 }227 });228});229230describe.runIf(mac)('shell: secrets are unreadable', () => {231 it('cannot read ~/.ssh, ~/.config/agent-os/dev.env, ~/.claude*, ~/.aws, keychains, shell startup files', async () => {232 const res = await sh(233 [234 'ls ~/.ssh; echo "ssh=$?"',235 'cat ~/.ssh/id_ed25519 ~/.ssh/config 2>&1 | head -2',236 'cat ~/.config/agent-os/dev.env; echo "devenv=$?"',237 'ls ~/.config; echo "config=$?"',238 'ls ~/.claude; echo "claude=$?"',239 'cat ~/.claude/CLAUDE.md; echo "claudemd=$?"',240 'cat ~/.claude.json > /dev/null; echo "claudejson=$?"',241 'ls ~/.codex; echo "codex=$?"',242 'ls ~/.aws; echo "aws=$?"',243 'ls ~/Library/Keychains; echo "keychains=$?"',244 'cat ~/.zshenv ~/.zprofile ~/.zshrc > /dev/null; echo "rc=$?"',245 'cat ~/.zsh_history > /dev/null; echo "hist=$?"',246 'cat ~/.netrc ~/.npmrc ~/.git-credentials > /dev/null; echo "tokens=$?"',247 ].join('\n'),248 );249 for (const k of ['ssh', 'devenv', 'config', 'claude', 'claudemd', 'claudejson', 'codex', 'aws', 'keychains', 'rc', 'hist', 'tokens']) {250 expect(res.output, k).toMatch(new RegExp(`^${k}=[1-9]\\d*$`, 'm'));251 }252 expect(res.output).not.toMatch(/BEGIN|PRIVATE KEY|sk-ant|ANTHROPIC_API_KEY=|OPENAI_API_KEY=/);253 // where the location exists on this machine, the refusal is the sandbox's (EPERM), not a missing file254 for (const p of ['.ssh', '.config/agent-os/dev.env', '.config', '.claude', '.claude/CLAUDE.md', '.claude.json', '.codex', 'Library/Keychains', '.zsh_history']) {255 if (existsSync(path.join(HOME, p))) expect(res.output, p).toContain(`${path.join(HOME, p)}: Operation not permitted`);256 }257 expect(existsSync(path.join(HOME, '.ssh'))).toBe(true);258 expect(existsSync(path.join(HOME, '.config/agent-os/dev.env'))).toBe(true);259 expect(existsSync(path.join(HOME, '.claude'))).toBe(true);260 });261262 it('cannot read the AOS data dir, env files outside the workspace, or extraDenyRead paths; workspace .env stays readable', async () => {263 writeFileSync(path.join(fx.dataDir, 'journal.txt'), 'DATA-SECRET\n');264 mkdirSync(path.join(fx.root, 'other-project'));265 writeFileSync(path.join(fx.root, 'other-project', '.env'), 'TOKEN=ENV-SECRET\n');266 writeFileSync(path.join(fx.root, 'other-project', '.env.production'), 'TOKEN=ENV-SECRET\n');267 writeFileSync(path.join(fx.root, 'other-project', 'dev.env'), 'TOKEN=ENV-SECRET\n');268 mkdirSync(path.join(fx.root, 'vault'));269 writeFileSync(path.join(fx.root, 'vault', 'k'), 'VAULT-SECRET\n');270 writeFileSync(path.join(fx.ws, '.env'), 'LOCAL=1\n');271 const o = path.join(fx.root, 'other-project');272 const res = await sh(273 `cat '${fx.dataDir}/journal.txt'; echo "data=$?"; ls '${fx.dataDir}'; echo "datals=$?"; cat '${o}/.env'; echo "env=$?"; cat '${o}/.env.production'; echo "envprod=$?"; cat '${o}/dev.env'; echo "devenv=$?"; cat '${fx.root}/vault/k'; echo "vault=$?"; cat .env; echo "wsenv=$?"`,274 { sandbox: { extraDenyRead: [path.join(fx.root, 'vault')] } },275 );276 for (const k of ['data', 'datals', 'env', 'envprod', 'devenv', 'vault']) expect(res.output, k).toMatch(new RegExp(`^${k}=1$`, 'm'));277 expect(res.output).toMatch(/^wsenv=0$/m);278 expect(res.output).toContain('LOCAL=1');279 expect(res.output).not.toMatch(/SECRET/);280 });281282 it('cannot reach privileged or out-of-sandbox execution paths', async () => {283 const marker = path.join(fx.root, `launchd-${tag}`);284 const res = await sh(285 [286 'sudo -n true; echo "sudo=$?"',287 `launchctl submit -l aos.test.${tag} -- /usr/bin/touch '${marker}'; echo "launchctl=$?"`,288 '(crontab -l >/dev/null 2>&1); echo "crontab=$?"',289 `osascript -e 'do shell script "touch ${marker}"' >/dev/null 2>&1; echo "osascript=$?"`,290 'security dump-keychain >/dev/null 2>&1; echo "keychain=$?"',291 'kill -9 1 2>/dev/null; echo "kill1=$?"',292 `kill -0 ${process.pid} 2>/dev/null; echo "killworker=$?"`,293 'sleep 1',294 ].join('\n'),295 );296 for (const k of ['sudo', 'launchctl', 'crontab', 'osascript', 'keychain', 'kill1', 'killworker']) expect(res.output, k).toMatch(new RegExp(`^${k}=[1-9]\\d*$`, 'm'));297 expect(existsSync(marker)).toBe(false);298 expect(pgrep(`aos.test.${tag}`)).toBe('');299 });300});301302describe.runIf(mac)('shell: network', () => {303 let server: net.Server;304 let port = 0;305 let hits = 0;306 beforeEach(async () => {307 hits = 0;308 server = net.createServer((s) => {309 hits++;310 s.end('hello\n');311 });312 await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));313 port = (server.address() as net.AddressInfo).port;314 });315 afterEach(async () => {316 await new Promise<void>((resolve) => server.close(() => resolve()));317 });318319 it("network 'none': the Internet, loopback, unix sockets and listening are all blocked", async () => {320 const res = await sh(321 [322 'curl -m 3 -sS https://example.com -o /dev/null; echo "curl=$?"',323 `nc -z -w 2 127.0.0.1 ${port}; echo "loopback=$?"`,324 `curl -m 2 -sS http://localhost:${port}/ ; echo "localhost=$?"`,325 'node -e "require(\'net\').createServer().on(\'error\',e=>{console.log(\'listen=\'+e.code)}).listen(0,()=>{console.log(\'listen=ok\');process.exit(0)})"',326 'node -e "require(\'net\').connect(\'/private/var/run/mDNSResponder\').on(\'error\',e=>console.log(\'unix=\'+e.code)).on(\'connect\',()=>{console.log(\'unix=ok\');process.exit(0)})"',327 'python3 -c "import socket;socket.create_connection((\'1.1.1.1\',443),timeout=3)" 2>&1 | tail -1',328 ].join('\n'),329 { network: 'none' },330 );331 expect(res.output).toMatch(/^curl=[1-9]\d*$/m);332 expect(res.output).toMatch(/^loopback=[1-9]\d*$/m);333 expect(res.output).toMatch(/^localhost=[1-9]\d*$/m);334 expect(res.output).toMatch(/^listen=EPERM$/m);335 expect(res.output).toMatch(/^unix=EPERM$/m);336 expect(res.output).toMatch(/PermissionError|Operation not permitted/);337 expect(hits).toBe(0);338 });339340 it("network 'open': outbound Internet works; loopback, unix sockets to local daemons and listening stay blocked", async (t) => {341 let online = true;342 try {343 await fetch('https://example.com', { signal: AbortSignal.timeout(8000) });344 } catch {345 online = false;346 }347 if (!online) return t.skip(); // this host has no Internet right now: nothing to assert about outbound348 const res = await sh(349 [350 'curl -m 15 -sS -o /dev/null -w "http=%{http_code}\\n" https://example.com',351 'node -e "fetch(\'https://example.com\').then(r=>console.log(\'node=\'+r.status)).catch(e=>console.log(\'node=ERR \'+(e.cause&&e.cause.code)))"',352 `nc -z -w 2 127.0.0.1 ${port}; echo "loopback=$?"`,353 `curl -m 2 -sS http://localhost:${port}/ ; echo "localhost=$?"`,354 `nc -z -w 2 ::1 ${port}; echo "loopback6=$?"`,355 'node -e "require(\'net\').createServer().on(\'error\',e=>{console.log(\'listen=\'+e.code)}).listen(0,()=>{console.log(\'listen=ok\');process.exit(0)})"',356 '(psql -h /tmp -l >/dev/null 2>&1); echo "pgsocket=$?"',357 'env | grep -c LEAK; true',358 ].join('\n'),359 { network: 'open' },360 40,361 );362 expect(res.output).toMatch(/^http=200$/m);363 expect(res.output).toMatch(/^node=200$/m);364 expect(res.output).toMatch(/^loopback=[1-9]\d*$/m);365 expect(res.output).toMatch(/^localhost=[1-9]\d*$/m);366 expect(res.output).toMatch(/^loopback6=[1-9]\d*$/m);367 expect(res.output).toMatch(/^listen=EPERM$/m);368 expect(res.output).toMatch(/^pgsocket=[1-9]\d*$/m);369 expect(res.output).toMatch(/^0$/m);370 expect(hits).toBe(0);371 });372373 it("with 'open' the filesystem rules are unchanged", async () => {374 const res = await sh(`touch ~/aos-escape-test-${tag}-open; echo "home=$?"; ls ~/.ssh; echo "ssh=$?"; echo x >> .git/config; echo "config=$?"`, { network: 'open' });375 strayFiles.push(path.join(HOME, `aos-escape-test-${tag}-open`));376 expect(res.output).toMatch(/^home=1$/m);377 expect(res.output).toMatch(/^ssh=1$/m);378 expect(res.output).toMatch(/^config=1$/m);379 expect(existsSync(path.join(HOME, `aos-escape-test-${tag}-open`))).toBe(false);380 });381});382383describe.runIf(mac)('shell: limits', () => {384 it('timeout kills the whole process tree (no orphans)', async () => {385 const a = `86${Math.floor(1000 + Math.random() * 8999)}`;386 const started = Date.now();387 const res = await sh(`sleep ${a} & (sleep ${a}1 &) ; sh -c "sleep ${a}2" & echo started; wait`, {}, 1);388 const elapsed = Date.now() - started;389 expect(res.ok).toBe(false);390 expect(res.output).toMatch(/^\[timed out after 1 s; the process tree was killed; killed by SIGTERM\]\nstarted$/);391 expect(elapsed).toBeLessThan(6000);392 await new Promise((r) => setTimeout(r, 300));393 expect(pgrep(`sleep ${a}`)).toBe('');394 });395396 it('a process that ignores SIGTERM is SIGKILLed', async () => {397 const a = `87${Math.floor(1000 + Math.random() * 8999)}`;398 const started = Date.now();399 const res = await sh(`trap "" TERM; sh -c 'trap "" TERM; sleep ${a}' & sleep ${a}3`, {}, 1);400 expect(res.ok).toBe(false);401 expect(res.output).toMatch(/timed out/);402 expect(Date.now() - started).toBeLessThan(8000);403 await new Promise((r) => setTimeout(r, 300));404 expect(pgrep(`sleep ${a}`)).toBe('');405 });406407 it('background jobs do not outlive the command', async () => {408 const a = `88${Math.floor(1000 + Math.random() * 8999)}`;409 const started = Date.now();410 const res = await sh(`nohup sleep ${a} >/dev/null 2>&1 &\n(sleep ${a}1 &)\necho returned`);411 expect(res).toMatchObject({ ok: true, output: '[exit code 0]\nreturned' });412 expect(Date.now() - started).toBeLessThan(5000);413 await new Promise((r) => setTimeout(r, 300));414 expect(pgrep(`sleep ${a}`)).toBe('');415 });416417 it('output is capped at 1 MiB and the command is killed', async () => {418 const started = Date.now();419 const res = await sh('yes aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', {}, 30);420 expect(res.ok).toBe(false);421 expect(res.output).toMatch(/^\[output exceeded 1048576 bytes; the command was killed and the output cut; killed by SIGKILL\]\n/);422 expect(Buffer.byteLength(res.output)).toBeLessThanOrEqual(1024 * 1024 + 200);423 expect(Buffer.byteLength(res.output)).toBeGreaterThan(1024 * 1024 - 200);424 expect(Date.now() - started).toBeLessThan(15_000);425 await new Promise((r) => setTimeout(r, 300));426 expect(pgrep('yes aaaaaaaaaaaaaaaaaaaaaaaa')).toBe('');427 });428429 it('cancellation through the abort signal kills the command', async () => {430 const a = `89${Math.floor(1000 + Math.random() * 8999)}`;431 const ac = new AbortController();432 setTimeout(() => ac.abort(), 500);433 const res = await sh(`echo begin; sleep ${a}`, { signal: ac.signal });434 expect(res.ok).toBe(false);435 expect(res.output).toMatch(/^\[cancelled; killed by SIGTERM\]\nbegin$/);436 await new Promise((r) => setTimeout(r, 300));437 expect(pgrep(`sleep ${a}`)).toBe('');438 });439440 it('input validation: timeout above 600 s and empty commands are rejected by the schema', () => {441 expect(TOOLS.shell!.input.safeParse({ command: 'true', timeoutSec: 601 }).success).toBe(false);442 expect(TOOLS.shell!.input.safeParse({ command: '', timeoutSec: null }).success).toBe(false);443 expect(TOOLS.shell!.input.safeParse({ command: 'true', timeoutSec: 600 }).success).toBe(true);444 });445});446447describe.runIf(mac)('shell: fails closed', () => {448 it('missing sandbox launcher ⇒ error, the command is NOT run', async () => {449 const res = await sh('echo ran > ran.txt', { sandbox: { sandboxExecPath: '/nonexistent/sandbox-exec' } });450 expect(res.ok).toBe(false);451 expect(res.output).toMatch(/Shell unavailable — the command was NOT run: sandbox launcher not found/);452 expect(existsSync(path.join(fx.ws, 'ran.txt'))).toBe(false);453 });454455 it('a launcher that does not enforce the profile is detected by the self-test ⇒ the command is NOT run', async () => {456 const fake = path.join(fx.root, 'fake-sandbox-exec');457 writeFileSync(fake, '#!/bin/sh\n# pretends to be sandbox-exec but applies nothing\nshift 2\nexec "$@"\n');458 chmodSync(fake, 0o755);459 const before = readdirSync(realpathSync(os.tmpdir())).filter((n) => n.startsWith('aos-canary-'));460 const res = await sh('echo ran > ran.txt', { sandbox: { sandboxExecPath: fake } });461 expect(res.ok).toBe(false);462 expect(res.output).toMatch(/NOT run: sandbox profile is not enforced/);463 expect(existsSync(path.join(fx.ws, 'ran.txt'))).toBe(false);464 expect(readdirSync(realpathSync(os.tmpdir())).filter((n) => n.startsWith('aos-canary-'))).toEqual(before); // canary cleaned up465 });466467 it('a launcher that rejects the profile ⇒ error, the command is NOT run', async () => {468 const fake = path.join(fx.root, 'rejecting-sandbox-exec');469 writeFileSync(fake, '#!/bin/sh\necho "sandbox-exec: profile rejected" >&2\nexit 65\n');470 chmodSync(fake, 0o755);471 const res = await sh('echo ran > ran.txt', { sandbox: { sandboxExecPath: fake } });472 expect(res.ok).toBe(false);473 expect(res.output).toMatch(/NOT run: sandbox profile was rejected or the self-test failed \(exit 65\)/);474 expect(existsSync(path.join(fx.ws, 'ran.txt'))).toBe(false);475 });476477 it('a workspace path that cannot be expressed safely in the profile is refused', async () => {478 const odd = path.join(fx.root, 'we"ird\nws');479 mkdirSync(odd);480 const res = await fx.call(TOOLS.shell!, { command: 'echo ran > ran.txt', timeoutSec: null }, { workspaceAbs: odd });481 expect(res.ok).toBe(false);482 expect(res.output).toMatch(/NOT run/);483 expect(existsSync(path.join(odd, 'ran.txt'))).toBe(false);484 });485486 it('non-macOS platforms: disabled with a clear error', async () => {487 const desc = Object.getOwnPropertyDescriptor(process, 'platform')!;488 Object.defineProperty(process, 'platform', { value: 'linux' });489 try {490 const res = await sh('echo ran > ran.txt');491 expect(res.ok).toBe(false);492 expect(res.output).toMatch(/NOT run: the shell tool is disabled on linux/);493 } finally {494 Object.defineProperty(process, 'platform', desc);495 }496 expect(existsSync(path.join(fx.ws, 'ran.txt'))).toBe(false);497 });498});499500describe('profile generation', () => {501 const spec = { workspaceReal: '/private/tmp/w s', tmpDir: '/private/tmp/t', home: '/Users/x', dataDir: '/private/tmp/data', network: 'none' as const };502 it('is deny-default, deterministic, and differs only by the network block', () => {503 const none = buildSeatbeltProfile(spec);504 expect(none).toBe(buildSeatbeltProfile(spec));505 expect(none.startsWith('(version 1)\n(deny default)\n')).toBe(true);506 expect(none).toContain('(deny network*)');507 expect(none).not.toContain('allow network');508 expect(none).not.toMatch(/\(allow default\)|\(allow file-write\*\)\n/);509 const open = buildSeatbeltProfile({ ...spec, network: 'open' });510 expect(open).not.toContain('(deny network*)');511 expect(open).toContain('(deny network-inbound)');512 expect(open.indexOf('(deny network-outbound (remote tcp "localhost:*")')).toBeGreaterThan(open.indexOf('(allow network-outbound (remote tcp "*:*")'));513 const strip = (p: string) => p.split('\n').filter((l) => !/network|system-socket|trustd/.test(l)).join('\n');514 expect(strip(open)).toBe(strip(none));515 });516 it('refuses paths it cannot quote safely and non-absolute paths', () => {517 expect(() => buildSeatbeltProfile({ ...spec, workspaceReal: '/tmp/a"b' })).toThrow(/quotes or control/);518 expect(() => buildSeatbeltProfile({ ...spec, workspaceReal: '/tmp/a\nb' })).toThrow(/control characters/);519 expect(() => buildSeatbeltProfile({ ...spec, workspaceReal: 'relative' })).toThrow(/not absolute/);520 expect(() => buildSeatbeltProfile({ ...spec, tmpDir: `/tmp/${'x'.repeat(1000)}` })).toThrow(/longer than 900/);521 expect(() => buildSeatbeltProfile({ ...spec, extraDenyRead: ['rel/path'] })).toThrow(/absolute/);522 });523 it('escapes regex metacharacters of the workspace path', () => {524 const p = buildSeatbeltProfile({ ...spec, workspaceReal: '/private/tmp/a.b+c (1)' });525 expect(p).toContain('^/private/tmp/a\\.b\\+c \\(1\\)/\\.[gG][iI][tT]/[cC][oO][nN][fF][iI][gG]$');526 });527});528