SPB Git

Repository Public

sandbox.test.ts 28.7 KB 528 lines typescript

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
Files main
Menu

Theme