diff --git a/scripts/sandbox-cli-functional-probe.mjs b/scripts/sandbox-cli-functional-probe.mjs deleted file mode 100644 index e9b6e5657..000000000 --- a/scripts/sandbox-cli-functional-probe.mjs +++ /dev/null @@ -1,194 +0,0 @@ -// FUNCTIONAL probe: actually RUN the real CLIs (codex, claude-code, seed) inside -// the overlay bwrap sandbox built by the compiled prepareSandbox. -// -// For each CLI it does TWO bwrap runs sharing one sandbox plan: -// (1) RECON via /bin/sh: verify config/auth readability, project edit lands in -// proj-upper, the `botmux` relay shim loads (node prints help, no -// "Cannot find module"), and the proxy env is present in the sandbox env. -// (2) REAL CLI start: run the actual CLI binary non-interactively (`--version`) -// to confirm the process STARTS and does not crash / Cannot-find-module. -// -// Mirrors worker.ts wiring: resolved binary + real adapter args + adapter spawnEnv -// (seed's CLAUDE_CONFIG_DIR) + sbx.env merged into the child env. -// -// Run: node scripts/sandbox-cli-functional-probe.mjs -import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs'; -import { tmpdir, homedir } from 'node:os'; -import { join } from 'node:path'; -import { prepareSandbox } from '../dist/adapters/backend/sandbox.js'; -import { createCliAdapterSync } from '../dist/adapters/cli/registry.js'; - -const SRC = '/root/sandbox-demo'; -const HOME = homedir(); -const CLAUDE_CRED = join(HOME, '.claude', '.credentials.json'); -const CLAUDE_SETTINGS = join(HOME, '.claude', 'settings.json'); -const CODEX_AUTH = join(HOME, '.codex', 'auth.json'); - -function line() { console.log('─'.repeat(72)); } - -// Build the per-CLI sandbox using the SAME inputs worker.ts feeds prepareSandbox. -function buildSbx(cliId, sessionId, dataDir, cliBin, cliArgs) { - return prepareSandbox({ - enabled: true, - cliId, - sessionId, - sourceWorkingDir: SRC, - dataDir, - cliBin, - cliArgs, - hidePaths: [], - }); -} - -// Mirror worker.ts childEnv assembly: process.env + adapter.spawnEnv + sbx.env. -function childEnv(adapter, sbx) { - const e = { ...process.env }; - if (adapter.spawnEnv) Object.assign(e, adapter.spawnEnv); - Object.assign(e, sbx.env); - return e; -} - -const reconScript = (authCheck, configCheck) => ` - set +e - echo "RECON: whoami=$(id -un) uid=$(id -u)" - echo "RECON: cwd=$(pwd)" - echo "RECON: project README line1=$(head -1 README.md 2>&1)" - echo "RECON: ${authCheck}" - echo "RECON: ${configCheck}" - echo "RECON: proxy_in_env https_proxy=\${https_proxy:-MISSING} HTTPS_PROXY=\${HTTPS_PROXY:-MISSING}" - echo "RECON: PATH_head=$(echo \$PATH | cut -d: -f1)" - echo "RECON: HOME=\$HOME" - echo "RECON: BOTMUX_SEND_RELAY=\${BOTMUX_SEND_RELAY:-MISSING}" - # EDIT a project file (must land in proj-upper, NOT in real /root/sandbox-demo) - echo "edit-by-$(id -un)-$$" > sandbox_edit_marker.txt - echo "# appended in sandbox" >> app.py - echo "RECON: wrote project files" - # relay shim: 'botmux' on PATH must exec node ; help must print w/o module error - BOUT=$(botmux --help 2>&1) - if echo "\$BOUT" | grep -qi "Cannot find module"; then - echo "RECON: relay_shim=CANNOT_FIND_MODULE" - elif echo "\$BOUT" | grep -qiE "botmux|usage|command|send|daemon|schedule"; then - echo "RECON: relay_shim=OK (botmux help loaded)" - else - echo "RECON: relay_shim=UNKNOWN_OUTPUT" - fi - echo "RECON: relay_shim_first_line=$(echo \"\$BOUT\" | head -1)" -`; - -const cases = [ - { - cliId: 'codex', - binPath: '/root/.local/share/fnm/node-versions/v22.21.1/installation/bin/codex', - label: 'codex (codex-cli)', - authCheck: 'codex_auth_readable=$( [ -r "$HOME/.codex/auth.json" ] && echo YES || echo NO )', - configCheck: 'codex_config_readable=$( [ -r "$HOME/.codex/config.toml" ] && echo YES || echo NO )', - versionArgs: ['--version'], - }, - { - cliId: 'claude-code', - binPath: '/root/.local/bin/claude', - label: 'claude-code (claude)', - authCheck: 'claude_creds_readable=$( [ -r "$HOME/.claude/.credentials.json" ] && echo YES || echo NO )', - configCheck: 'claude_settings_readable=$( [ -r "$HOME/.claude/settings.json" ] && echo YES || echo NO )', - versionArgs: ['--version'], - }, - { - cliId: 'seed', - binPath: '/root/.local/share/fnm/node-versions/v22.21.1/installation/bin/seed', - label: 'seed (Seed fork; CLAUDE_CONFIG_DIR=.claude-runtime)', - // Seed reads CLAUDE_CONFIG_DIR (set via adapter.spawnEnv) — assert that dir's settings. - authCheck: 'seed_config_dir=${CLAUDE_CONFIG_DIR:-UNSET}', - configCheck: 'seed_settings_readable=$( [ -r "$CLAUDE_CONFIG_DIR/settings.json" ] && echo YES || echo NO )', - versionArgs: ['--version'], - }, -]; - -const results = []; - -for (const c of cases) { - line(); - console.log(`### ${c.label}`); - const adapter = createCliAdapterSync(c.cliId, c.binPath); - const bin = adapter.resolvedBin; - console.log(`resolvedBin = ${bin}`); - console.log(`spawnEnv = ${JSON.stringify(adapter.spawnEnv ?? {})}`); - - const res = { cliId: c.cliId, started: false, auth: '?', config: '?', edit: false, relay: '?', proxy: false, versionExit: null, notes: [] }; - - // ── (1) RECON run (sh inside the sandbox) ────────────────────────────────── - const reconData = mkdtempSync(join(tmpdir(), `sbx-recon-${c.cliId}-`)); - const reconSid = `recon-${c.cliId}-${Date.now()}`; - const sbxRecon = buildSbx(c.cliId, reconSid, reconData, '/bin/sh', ['-c', reconScript(c.authCheck, c.configCheck)]); - if (!sbxRecon) { - console.log('RECON: prepareSandbox returned null — FAIL (mount failed/unsupported)'); - res.notes.push('prepareSandbox null on recon'); - results.push(res); - try { rmSync(reconData, { recursive: true, force: true }); } catch {} - continue; - } - const reconEnv = childEnv(adapter, sbxRecon); - const rr = spawnSync(sbxRecon.bin, sbxRecon.args, { env: reconEnv, encoding: 'utf8' }); - console.log('--- recon stdout ---'); - console.log((rr.stdout || '(none)').trimEnd()); - if (rr.stderr && rr.stderr.trim()) { console.log('--- recon stderr ---'); console.log(rr.stderr.trimEnd()); } - - const out = rr.stdout || ''; - res.auth = (out.match(/(?:credentials_readable|auth_readable|config_dir)=(\S+)/) || [])[1] ?? '?'; - res.config = (out.match(/settings_readable=(\S+)/) || [])[1] ?? '?'; - res.relay = /relay_shim=OK/.test(out) ? 'OK' : /CANNOT_FIND_MODULE/.test(out) ? 'CANNOT_FIND_MODULE' : 'UNKNOWN'; - res.proxy = /HTTPS_PROXY=http/.test(out); - - // EDIT landed in proj-upper, real project untouched - const upperMarker = join(sbxRecon.workDir, 'sandbox_edit_marker.txt'); - const upperApp = join(sbxRecon.workDir, 'app.py'); - const realMarker = join(SRC, 'sandbox_edit_marker.txt'); - const editLanded = existsSync(upperMarker) && existsSync(upperApp); - const realUntouched = !existsSync(realMarker) && !readFileSync(join(SRC, 'app.py'), 'utf8').includes('# appended in sandbox'); - res.edit = editLanded && realUntouched; - console.log(`VERIFY: edit landed in proj-upper=${editLanded} realUntouched=${realUntouched}`); - if (editLanded) console.log(`VERIFY: upper marker content=${JSON.stringify(readFileSync(upperMarker, 'utf8').trim())}`); - - sbxRecon.cleanup(); - try { rmSync(reconData, { recursive: true, force: true }); } catch {} - - // ── (2) REAL CLI start (run the actual binary, non-interactive) ──────────── - const verData = mkdtempSync(join(tmpdir(), `sbx-ver-${c.cliId}-`)); - const verSid = `ver-${c.cliId}-${Date.now()}`; - const sbxVer = buildSbx(c.cliId, verSid, verData, bin, c.versionArgs); - if (!sbxVer) { - console.log('VERSION: prepareSandbox returned null — FAIL'); - res.notes.push('prepareSandbox null on version run'); - } else { - const verEnv = childEnv(adapter, sbxVer); - const vr = spawnSync(sbxVer.bin, sbxVer.args, { env: verEnv, encoding: 'utf8', timeout: 60000 }); - res.versionExit = vr.status; - const vout = (vr.stdout || '').trim(); - const verr = (vr.stderr || '').trim(); - console.log(`--- real \`${c.cliId} ${c.versionArgs.join(' ')}\` inside sandbox ---`); - console.log(`exit=${vr.status} signal=${vr.signal ?? ''}`); - if (vout) console.log(`stdout: ${vout.split('\n')[0]}`); - if (verr) console.log(`stderr: ${verr.split('\n').slice(0, 5).join(' | ')}`); - const moduleErr = /Cannot find module/i.test(vout + verr); - res.started = vr.status === 0 && !moduleErr; - if (moduleErr) res.notes.push('Cannot find module'); - sbxVer.cleanup(); - } - try { rmSync(verData, { recursive: true, force: true }); } catch {} - - results.push(res); -} - -line(); -console.log('=== SUMMARY ==='); -for (const r of results) { - console.log( - `${r.cliId.padEnd(12)} | started=${r.started} | versionExit=${r.versionExit} | ` + - `auth/configDir=${r.auth} | settings=${r.config} | editLands=${r.edit} | ` + - `relayShim=${r.relay} | proxyEnv=${r.proxy}` + - (r.notes.length ? ` | notes=${r.notes.join(',')}` : '') - ); -} -const allGood = results.every(r => r.started && r.edit && r.relay === 'OK' && r.proxy); -console.log('RESULT:', allGood ? 'ALL PASS' : 'SEE SUMMARY (some checks failed)'); -process.exit(allGood ? 0 : 1); diff --git a/scripts/sandbox-cli-live-turn-probe.mjs b/scripts/sandbox-cli-live-turn-probe.mjs deleted file mode 100644 index f51de9f95..000000000 --- a/scripts/sandbox-cli-live-turn-probe.mjs +++ /dev/null @@ -1,66 +0,0 @@ -// LIVE-TURN probe: run a real non-interactive AI turn for each CLI INSIDE the -// overlay bwrap sandbox. This exercises the full path: process start → read real -// config/auth → use proxy env → reach the API → produce output. Bounded timeouts. -// -// Run: node scripts/sandbox-cli-live-turn-probe.mjs -import { spawnSync } from 'node:child_process'; -import { rmSync, mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { prepareSandbox } from '../dist/adapters/backend/sandbox.js'; -import { createCliAdapterSync } from '../dist/adapters/cli/registry.js'; - -const SRC = '/root/sandbox-demo'; -const PROMPT = 'Reply with exactly the single word: PONG'; - -function childEnv(adapter, sbx) { - const e = { ...process.env }; - if (adapter.spawnEnv) Object.assign(e, adapter.spawnEnv); - Object.assign(e, sbx.env); - return e; -} - -const cases = [ - { - cliId: 'claude-code', - binPath: '/root/.local/bin/claude', - args: ['--print', '--dangerously-skip-permissions', PROMPT], - needle: /PONG/i, - }, - { - cliId: 'seed', - binPath: '/root/.local/share/fnm/node-versions/v22.21.1/installation/bin/seed', - args: ['--print', '--dangerously-skip-permissions', PROMPT], - needle: /PONG/i, - }, - { - cliId: 'codex', - binPath: '/root/.local/share/fnm/node-versions/v22.21.1/installation/bin/codex', - args: ['exec', '--dangerously-bypass-approvals-and-sandbox', PROMPT], - needle: /PONG/i, - }, -]; - -for (const c of cases) { - console.log('─'.repeat(72)); - console.log(`### ${c.cliId} live turn: \`${c.cliId} ${c.args.join(' ')}\``); - const adapter = createCliAdapterSync(c.cliId, c.binPath); - const data = mkdtempSync(join(tmpdir(), `sbx-live-${c.cliId}-`)); - const sid = `live-${c.cliId}-${Date.now()}`; - const sbx = prepareSandbox({ - enabled: true, cliId: c.cliId, sessionId: sid, sourceWorkingDir: SRC, - dataDir: data, cliBin: adapter.resolvedBin, cliArgs: c.args, hidePaths: [], - }); - if (!sbx) { console.log('prepareSandbox null — FAIL'); try { rmSync(data, { recursive: true, force: true }); } catch {} continue; } - const env = childEnv(adapter, sbx); - const r = spawnSync(sbx.bin, sbx.args, { env, encoding: 'utf8', timeout: 120000 }); - const out = (r.stdout || '').trim(); - const err = (r.stderr || '').trim(); - console.log(`exit=${r.status} signal=${r.signal ?? ''} timedOut=${r.error?.code === 'ETIMEDOUT' || r.signal === 'SIGTERM'}`); - if (out) console.log('--- stdout (last 800 chars) ---\n' + out.slice(-800)); - if (err) console.log('--- stderr (last 800 chars) ---\n' + err.slice(-800)); - const got = c.needle.test(out); - console.log(`VERDICT ${c.cliId}: live turn ${got ? 'PRODUCED EXPECTED OUTPUT (auth+proxy+API all worked)' : 'did NOT produce expected output'}`); - sbx.cleanup(); - try { rmSync(data, { recursive: true, force: true }); } catch {} -} diff --git a/scripts/sandbox-overlay-probe.mjs b/scripts/sandbox-overlay-probe.mjs deleted file mode 100644 index 6cbc0005b..000000000 --- a/scripts/sandbox-overlay-probe.mjs +++ /dev/null @@ -1,92 +0,0 @@ -// Self-probe for the overlay sandbox: build prepareSandbox for codex against -// /root/sandbox-demo, run bwrap with a sh -c that exercises read/write isolation, -// then clean up. Run with: node scripts/sandbox-overlay-probe.mjs -import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, rmSync, writeFileSync, mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { prepareSandbox } from '../dist/adapters/backend/sandbox.js'; - -const SRC = '/root/sandbox-demo'; -const LEAK = '/root/iserver/botmux/SBXLEAK.txt'; -const dataDir = mkdtempSync(join(tmpdir(), 'sbx-probe-')); -const sid = 'probe-' + Date.now(); - -// pre-clean leak target so a stale file from a prior run doesn't false-positive -try { rmSync(LEAK, { force: true }); } catch {} - -const inSandboxScript = ` - set +e - echo "PROBE: hostname read = $(cat /etc/hostname 2>&1)" - echo "PROBE: project README first line = $(head -1 README.md 2>&1)" - echo "PROBE: credentials readable = $( [ -r "$HOME/.claude/.credentials.json" ] && echo YES || echo NO )" - # write a NEW file inside the project (should land in proj-upper, real untouched) - echo "edited-by-sandbox" > sandbox_probe_new.txt - echo "appended-by-sandbox" >> app.py - echo "PROBE: wrote project files" - # try to write a real file OUTSIDE the project (must be ephemeral, not landed) - echo "LEAKED" > ${LEAK} 2>&1 && echo "PROBE: wrote leak file (will check host)" || echo "PROBE: leak write FAILED (good)" - echo "PROBE: leak file visible inside sandbox = $( [ -f ${LEAK} ] && echo YES || echo NO )" -`; - -const sbx = prepareSandbox({ - enabled: true, - cliId: 'codex', - sessionId: sid, - sourceWorkingDir: SRC, - dataDir, - cliBin: '/bin/sh', - cliArgs: ['-c', inSandboxScript], - hidePaths: [], -}); - -if (!sbx) { - console.log('RESULT: prepareSandbox returned null (mount failed / unsupported) — FAIL'); - try { rmSync(dataDir, { recursive: true, force: true }); } catch {} - process.exit(2); -} - -console.log('PROBE: bwrap bin =', sbx.bin); -console.log('PROBE: upper (changeset) dir =', sbx.workDir); - -const env = { ...process.env, ...sbx.env }; -const r = spawnSync(sbx.bin, sbx.args, { env, encoding: 'utf8' }); -console.log('--- sandbox stdout ---'); -console.log(r.stdout || '(none)'); -if (r.stderr && r.stderr.trim()) { - console.log('--- sandbox stderr ---'); - console.log(r.stderr); -} -console.log('PROBE: bwrap exit status =', r.status); - -// HOST-side verification -const leakOnHost = existsSync(LEAK); -console.log('VERIFY: leak file present on REAL host =', leakOnHost, leakOnHost ? '(BAD — escape!)' : '(good — isolated)'); - -const upperNew = join(sbx.workDir, 'sandbox_probe_new.txt'); -const upperHasNew = existsSync(upperNew); -console.log('VERIFY: new project file present in proj-upper =', upperHasNew, upperHasNew ? '(good)' : '(BAD)'); -if (upperHasNew) console.log('VERIFY: upper new file content =', JSON.stringify(readFileSync(upperNew, 'utf8').trim())); - -const upperApp = join(sbx.workDir, 'app.py'); -const upperHasApp = existsSync(upperApp); -console.log('VERIFY: modified app.py copied-up into proj-upper =', upperHasApp, upperHasApp ? '(good)' : '(BAD)'); - -// real project must be UNTOUCHED -const realNew = existsSync(join(SRC, 'sandbox_probe_new.txt')); -const realApp = readFileSync(join(SRC, 'app.py'), 'utf8'); -console.log('VERIFY: real project got the NEW file (should be false until /land) =', realNew); -console.log('VERIFY: real app.py contains sandbox append (should be false) =', realApp.includes('appended-by-sandbox')); - -// cleanup (unmount overlays + rm trees) -sbx.cleanup(); -try { rmSync(dataDir, { recursive: true, force: true }); } catch {} -try { rmSync(LEAK, { force: true }); } catch {} - -// final mountpoint sanity -const mp1 = spawnSync('mountpoint', ['-q', join(dataDir, 'sandboxes', sid, 'proj-merged')], { stdio: 'ignore' }); -console.log('VERIFY: proj-merged still mounted after cleanup =', mp1.status === 0, '(should be false)'); - -const pass = !leakOnHost && upperHasNew && upperHasApp && !realNew && !realApp.includes('appended-by-sandbox'); -console.log('RESULT:', pass ? 'PASS' : 'FAIL'); -process.exit(pass ? 0 : 1); diff --git a/scripts/sandbox-probe.mjs b/scripts/sandbox-probe.mjs new file mode 100755 index 000000000..20f4b57a8 --- /dev/null +++ b/scripts/sandbox-probe.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +/** + * sandbox-probe — verify the fs-policy file sandbox on THIS machine. + * + * Builds the SAME FsPolicy the worker would for a given bot, compiles it to a + * real Seatbelt profile (macOS) — or bwrap argv (Linux) — and launches real + * processes inside it, asserting the three access tiers actually hold at the + * kernel level. No daemon / no Feishu round-trip required. + * + * Usage: + * pnpm build # dist must be current + * node scripts/sandbox-probe.mjs # auto-detect a bot + * node scripts/sandbox-probe.mjs --app cli_xxx # a specific bot + * node scripts/sandbox-probe.mjs --tools # also probe python/perl/etc. + * + * Exit code 0 = all expectations met, 1 = at least one mismatch. + */ +import { buildFsPolicy, compileToSeatbelt, compileToBwrap } from '../dist/adapters/cli/fs-policy.js'; +import { realpathSync, existsSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, readdirSync, statSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const args = process.argv.slice(2); +const wantTools = args.includes('--tools'); +const appArg = (() => { const i = args.indexOf('--app'); return i >= 0 ? args[i + 1] : undefined; })(); +const c = (p) => { try { return realpathSync(p); } catch { return p; } }; +const home = c(homedir()); + +function detectApp() { + if (appArg) return appArg; + if (process.env.BOTMUX_LARK_APP_ID) return process.env.BOTMUX_LARK_APP_ID; + try { + const bots = readdirSync(join(home, '.botmux', 'bots')).filter((n) => /^cli_/.test(n)); + if (bots[0]) return bots[0]; + } catch { /* */ } + return 'cli_probe'; // synthetic — sibling-isolation checks still meaningful +} +const APP = detectApp(); +const botmuxHome = c(join(home, '.botmux')); +const sessionDataDir = c(join(botmuxHome, 'data')); +const botHome = join(botmuxHome, 'bots', APP); + +// A throwaway project with the three tiers materialized. Canonicalize it: the +// worker canonicalizes every policy path (Seatbelt matches canonical paths), so +// the probe must too — else a deny/readOnly rule written with the /var/folders +// form won't match the canonical /private/var/folders access and the baseline's +// TMPDIR-root rw grant would wrongly win (this bit the probe's first draft). +const proj = c(mkdtempSync(join(tmpdir(), 'sbx-probe-'))); +mkdirSync(join(proj, 'src'), { recursive: true }); +mkdirSync(join(proj, 'secrets'), { recursive: true }); +mkdirSync(join(proj, 'ref'), { recursive: true }); +writeFileSync(join(proj, 'src', 'app.txt'), 'project source'); +writeFileSync(join(proj, 'secrets', 'key.txt'), 'PROJECT SECRET'); +writeFileSync(join(proj, 'ref', 'doc.md'), 'reference material'); + +// Executable dirs the worker would expose (claude/node/lark-cli + fnm farms). +const execCandidates = ['claude', 'node', 'lark-cli'] + .map((b) => { const r = spawnSync('sh', ['-c', `command -v ${b}`], { encoding: 'utf8' }); return r.status === 0 ? c(r.stdout.trim()) : null; }) + .filter(Boolean); +const execDirs = [...new Set([process.execPath, ...execCandidates].map((p) => dirname(c(p))))]; +const ccPkg = execCandidates.find((p) => p.includes('claude-code')) ? dirname(dirname(execCandidates.find((p) => p.includes('claude-code')))) : undefined; + +// The botmux install/checkout root (this script lives at /scripts/). +const botmuxInstallRoot = c(dirname(dirname(fileURLToPath(import.meta.url)))); + +const policy = buildFsPolicy({ + platform: process.platform === 'darwin' ? 'darwin' : 'linux', + homeDir: home, botmuxHome, sessionDataDir, + workingDir: c(proj), currentAppId: APP, botHome: c(botHome), + redirectedCliData: true, + execPaths: execDirs, + readonlyRoots: ccPkg ? [ccPkg] : [], + botmuxInstallRoot, + extraWritePaths: process.env.TMPDIR ? [c(process.env.TMPDIR)] : [], + userPaths: { readOnly: [join(proj, 'ref')], deny: [join(proj, 'secrets')] }, + net: true, writeRegexes: [], +}); +policy.rules = policy.rules.filter((r) => r.access === 'deny' || existsSync(r.path)); + +if (process.platform !== 'darwin') { + console.error('[sandbox-probe] Live Seatbelt probing is macOS-only. On Linux the daemon'); + console.error(' wraps the CLI in bwrap with the SAME policy; verify via a real bot session'); + console.error(' (set sandbox:true, message it, confirm it cannot read ~/.ssh). Policy rules:'); + console.error(compileToBwrap(policy, { emptiesDir: '/tmp', chdir: c(proj) }).args.join(' ')); + process.exit(0); +} + +if (spawnSync('sh', ['-c', 'command -v sandbox-exec'], { stdio: 'ignore' }).status !== 0) { + console.error('[sandbox-probe] sandbox-exec not found — cannot probe.'); + process.exit(1); +} +const profile = join(proj, 'profile.sb'); +writeFileSync(profile, compileToSeatbelt(policy)); + +// A probe runs `argv` inside the sandbox with cwd = the project (as a real +// session does — the CLI launches chdir'd into workingDir). +function inside(argv) { + return spawnSync('sandbox-exec', ['-f', profile, ...argv], { cwd: proj, env: { ...process.env, HOME: home }, encoding: 'utf8' }); +} +let fails = 0; +function check(desc, want, argv, extraEnv) { + const r = spawnSync('sandbox-exec', ['-f', profile, ...argv], { cwd: proj, env: { ...process.env, HOME: home, ...extraEnv }, encoding: 'utf8' }); + const got = r.status === 0 ? 'ALLOWED' : 'DENIED'; + const ok = got === want; + if (!ok) fails++; + console.log(` ${ok ? '✓' : '✗ FAIL'} [${got.padEnd(7)} want ${want.padEnd(7)}] ${desc}`); +} + +console.log(`\nsandbox-probe — bot=${APP} project=${proj} rules=${policy.rules.length}\n`); +console.log('三档权限:'); +check('读 readWrite 项目文件', 'ALLOWED', ['/bin/cat', join(proj, 'src', 'app.txt')]); +check('写 readWrite 项目文件', 'ALLOWED', ['/usr/bin/touch', join(proj, 'src', 'new.txt')]); +check('读 deny 凿洞 (project/secrets)', 'DENIED', ['/bin/cat', join(proj, 'secrets', 'key.txt')]); +check('读 readOnly 参考目录', 'ALLOWED', ['/bin/cat', join(proj, 'ref', 'doc.md')]); +check('写 readOnly 参考目录', 'DENIED', ['/usr/bin/touch', join(proj, 'ref', 'hack')]); + +console.log('\n密钥 / 跨-bot 隔离:'); +check('读 ~/.ssh', 'DENIED', ['/bin/ls', join(home, '.ssh')]); +check('读 ~/.aws', 'DENIED', ['/bin/ls', join(home, '.aws')]); +check('读 ~/Library/Keychains', 'DENIED', ['/bin/ls', join(home, 'Library', 'Keychains')]); +check('读 lark-cli 密钥库根 (兄弟密文所在)', 'DENIED', ['/bin/ls', join(home, 'Library', 'Application Support', 'lark-cli')]); +const ownSecret = join(home, 'Library', 'Application Support', 'lark-cli', `appsecret_${APP}.enc`); +if (existsSync(ownSecret)) check('读 自己的 appsecret (carve-out)', 'ALLOWED', ['/bin/cat', ownSecret]); +const sibling = (() => { try { return readdirSync(join(home, 'Library', 'Application Support', 'lark-cli')).find((n) => /^appsecret_/.test(n) && !n.includes(APP)); } catch { return undefined; } })(); +if (sibling) check('读 兄弟 bot 的 appsecret', 'DENIED', ['/bin/cat', join(home, 'Library', 'Application Support', 'lark-cli', sibling)]); + +console.log('\n未覆盖路径 (deny-by-default):'); +check('读未列出的敏感目录 ~/Documents', 'DENIED', ['/bin/ls', join(home, 'Documents')]); + +console.log('\nbotmux CLI 运行时面 (allow-list,非 umbrella):'); +// Install dir + the allow-listed ~/.botmux reads the CLI/hooks need. +check('读 botmux 安装目录 dist/cli.js', 'ALLOWED', ['/bin/cat', join(botmuxInstallRoot, 'dist', 'cli.js')]); +check('读 allow-list: data/dashboard-daemons (daemon 发现)', 'ALLOWED', ['/bin/ls', join(sessionDataDir, 'dashboard-daemons')]); +check('读 allow-list: data/bots-info.json', 'ALLOWED', ['/bin/cat', join(sessionDataDir, 'bots-info.json')]); +check('读 allow-list: .dashboard-port', 'ALLOWED', ['/bin/cat', join(botmuxHome, '.dashboard-port')]); +// codex 泄漏点回归守卫:这些含真凭证/跨-bot 内容,必须 DENIED +check('读 config.json (voice 凭证, codex#1)', 'DENIED', ['/bin/cat', join(botmuxHome, 'config.json')]); +check('读 .env (daemon 配置, codex#1)', 'DENIED', ['/bin/cat', join(botmuxHome, '.env')]); +check('读 data/webhook-master.key (AES 主密钥, codex#1)', 'DENIED', ['/bin/cat', join(sessionDataDir, 'webhook-master.key')]); +check('读写 data/schedules.json (RMW 定时任务, owner 接受泄漏)', 'ALLOWED', ['/bin/cat', join(sessionDataDir, 'schedules.json')]); +check('读 ~/.botmux/bots.json (敏感)', 'DENIED', ['/bin/cat', join(botmuxHome, 'bots.json')]); +check('读 ~/.botmux/logs (跨-bot)', 'DENIED', ['/bin/ls', join(botmuxHome, 'logs')]); +// End-to-end: actually run the botmux CLI inside the sandbox (loads cli.js + reads +// ~/.botmux). `botmux --help` exercises the load path without side effects. +const bmxCli = join(botmuxInstallRoot, 'dist', 'cli.js'); +const nodeBin = execCandidates.find((p) => /\/node$/.test(p)) || process.execPath; +if (existsSync(bmxCli)) check('沙盒内跑 botmux CLI (node dist/cli.js --help)', 'ALLOWED', [nodeBin, bmxCli, '--help']); +// The claude SessionStart / AskUserQuestion hooks exec this same shape. +if (existsSync(bmxCli)) check('沙盒内跑 hook 形态 (node dist/cli.js session-ready --help)', 'ALLOWED', [nodeBin, bmxCli, 'session-ready', '--help']); +// Footer role-name fix: with the worker-injected BOTMUX_BRAND_LABEL, resolveBrandLabel +// must return it WITHOUT reading the (denied) bots.json → `botmux send` renders the +// role footer. Assert the env-first path resolves in-sandbox. +const registryJs = join(botmuxInstallRoot, 'dist', 'bot-registry.js'); +if (existsSync(registryJs)) { + check('沙盒内 resolveBrandLabel 从 env 拿到 brandLabel (不碰 bots.json)', 'ALLOWED', + [nodeBin, '--input-type=module', '-e', + `import{resolveBrandLabel as r}from ${JSON.stringify(registryJs)};process.exit(r(process.env.BOTMUX_LARK_APP_ID)==='[probe-role](u)'?0:1)`], + { BOTMUX_LARK_APP_ID: APP, BOTMUX_BRAND_LABEL: '[probe-role](u)' }); +} + +if (wantTools) { + console.log('\n工具链 (需 --tools):'); + check('python3 运行', 'ALLOWED', ['/usr/bin/python3', '-c', 'print(1+1)']); + check('perl 运行', 'ALLOWED', ['/usr/bin/perl', '-e', 'print "ok"']); + check('bash 运行', 'ALLOWED', ['/bin/bash', '-c', 'echo ok']); + const claude = execCandidates.find((p) => p.includes('claude')); + if (claude) check('claude --version', 'ALLOWED', ['env', `CLAUDE_CONFIG_DIR=${join(botHome, 'claude')}`, claude, '--version']); +} + +rmSync(proj, { recursive: true, force: true }); +console.log(`\n${fails === 0 ? '✓ 全部符合预期' : `✗ ${fails} 项不符预期`}\n`); +process.exit(fails === 0 ? 0 : 1); diff --git a/src/adapters/backend/sandbox.ts b/src/adapters/backend/sandbox.ts index 125a7e797..560d67b1f 100644 --- a/src/adapters/backend/sandbox.ts +++ b/src/adapters/backend/sandbox.ts @@ -1,101 +1,45 @@ /** - * File-isolation sandbox (bubblewrap + overlayfs) for oncall bots. + * Linux file sandbox: bwrap DIRECT mode (fs-policy three-tier whitelist). * - * Model (OVERLAYFS read-all / write-isolated, per product decision 2026-06-10): - * the sandboxed agent READS the entire real filesystem natively — the real CLI - * config/auth/env/project, NO scrub, the CLI just works. WRITES are isolated via - * an overlayfs mount: the real lower layer is NEVER modified, only changed files - * copy-up into a per-session UPPER layer (zero-copy reads, only the delta uses - * disk, NO git clone). Landing copies that UPPER changeset back to the real - * project. Privacy masking is per-bot opt-in with NO defaults. + * Model (2026-07-16 refactor, design doc "botmux 文件沙盒重构方案"): the + * sandboxed CLI writes the PROJECT DIRECTLY (same behaviour as an unsandboxed + * run inside the policy's readWrite zones) and sees NOTHING outside the + * policy's rules — a fresh tmpfs root, only the rule paths bound in. This + * replaced the overlayfs+landing model: no mounts to leak, no landing step, + * no bridge redirect (the CLI's data dir is a REAL host path). * - * Mechanism (empirically verified — runs as root on this 5.15 kernel): - * mount -t overlay overlay -o lowerdir=REAL,upperdir=UPPER,workdir=WORK MERGED - * bwrap 0.8.0 has NO --overlay, so we mount the overlay ON THE HOST then bind the - * merged dir into bwrap. overlayfs forbids upper/work INSIDE lower, so the HOME - * overlay (lower=/root) puts upper/work OUTSIDE /root (under /var/tmp/...). The - * PROJECT overlay upper/work under /sandboxes// is fine. + * The policy is built by the worker (adapters/cli/fs-policy.ts — the single + * source of truth for BOTH platforms) and compiled to bwrap argv here. macOS + * enforces the SAME policy via Seatbelt (compileToSeatbelt) at the worker's + * spawn site — nothing in this module runs on darwin. * - * Linux-only (overlayfs + bwrap depend on Linux). macOS reuses Anthropic's - * sandbox-exec approach and is handled elsewhere. + * `botmux send` relay: unchanged from the previous model. The sandboxed CLI's + * `botmux send` writes a validated request into a per-session outbox; the + * daemon-side watcher re-executes the send OUTSIDE the sandbox with real + * credentials. No Feishu credential ever enters the sandbox. */ import { homedir } from 'node:os'; -import { mkdirSync, existsSync, writeFileSync, chmodSync, readdirSync, readFileSync, rmSync, statSync, realpathSync, openSync, fstatSync, readSync, writeSync, closeSync, constants as fsConstants } from 'node:fs'; +import { mkdirSync, existsSync, writeFileSync, chmodSync, readdirSync, readFileSync, rmSync, statSync, lstatSync, readlinkSync, realpathSync, openSync, fstatSync, readSync, writeSync, closeSync, constants as fsConstants } from 'node:fs'; import { atomicWriteFileSync } from '../../utils/atomic-write.js'; -import { join, dirname, relative, resolve } from 'node:path'; +import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn, spawnSync } from 'node:child_process'; - -/** Host root for the HOME overlay's upper/work — MUST be OUTSIDE the home lower - * (overlayfs forbids upper/work inside lower). */ -const VARTMP_ROOT = '/var/tmp/botmux-sbx'; - -// ───────────────────────────── overlay primitives ──────────────────────────── - -/** - * Mount an overlayfs: reads fall through to `lower` (real, zero copy); writes - * copy-up into `upper` (the landable changeset). `work` is overlayfs scratch on - * the same fs as `upper`. Returns true iff `mount` exited 0. - */ -export function mountOverlay(opts: { lower: string; upper: string; work: string; merged: string }): boolean { - for (const d of [opts.upper, opts.work, opts.merged]) { - try { mkdirSync(d, { recursive: true }); } catch { /* */ } - } - const optStr = `lowerdir=${opts.lower},upperdir=${opts.upper},workdir=${opts.work}`; - // A privileged (root) daemon uses the kernel overlayfs driver — fastest. A - // non-root daemon CANNOT mount kernel overlayfs even inside bwrap's userns (the - // hardened mount env rejects it on this kernel), so it falls back to - // fuse-overlayfs — a userspace overlay needing no root, only /dev/fuse. Same - // lowerdir/upperdir/workdir semantics → landing, the bridge redirect, and the - // privacy masks all work identically; only the mount mechanism differs. - // BOTMUX_SANDBOX_FUSE=1 forces the userspace path even as root (escape hatch + - // lets a root daemon exercise exactly what unprivileged users hit). - const forceFuse = process.env.BOTMUX_SANDBOX_FUSE === '1'; - if (!forceFuse && process.getuid?.() === 0) { - const r = spawnSync('mount', ['-t', 'overlay', 'overlay', '-o', optStr, opts.merged], { stdio: 'pipe' }); - if (r.status === 0) return true; - // root but kernel mount failed (rare) → fall through to fuse-overlayfs - } - const f = spawnSync('fuse-overlayfs', ['-o', optStr, opts.merged], { stdio: 'pipe' }); - return f.status === 0; -} - -/** True iff `path` is currently a mountpoint (host-side overlay still mounted). */ -export function isMounted(path: string): boolean { - return spawnSync('mountpoint', ['-q', path], { stdio: 'ignore' }).status === 0; -} - -/** Unmount an overlay merged dir. Best-effort: lazy-umount (`-l`) if a normal - * umount fails (busy fd from a still-draining child). No-op if not a mount. */ -export function unmountOverlay(merged: string): void { - if (!isMounted(merged)) return; // not a mountpoint - // kernel overlay → `umount`; fuse-overlayfs → `fusermount -u` (a non-root - // daemon can't `umount` its own fuse mount); lazy `-l` as a last resort for a - // busy fd from a still-draining child. - if (spawnSync('umount', [merged], { stdio: 'ignore' }).status === 0) return; - if (spawnSync('fusermount', ['-u', merged], { stdio: 'ignore' }).status === 0) return; - spawnSync('umount', ['-l', merged], { stdio: 'ignore' }); -} - -/** Verify (and best-effort auto-install) the sandbox runtime deps so the user - * needn't pre-install: `bubblewrap` always, `fuse-overlayfs` when the userspace - * overlay path is used (non-root daemon, or BOTMUX_SANDBOX_FUSE=1). Installs via - * the system package manager when the daemon can (root, or passwordless sudo); - * otherwise logs a one-line manual-install hint and returns false so the caller - * fails the spawn (never a silent unsandboxed run). Returns true if all present. */ -function ensureSandboxDeps(needFuse: boolean): boolean { +import { compileToBwrap, type FsPolicy } from '../cli/fs-policy.js'; + +/** Verify (and best-effort auto-install) bubblewrap so the user needn't + * pre-install. Installs via the system package manager when the daemon can + * (root, or passwordless sudo); otherwise logs a one-line manual-install hint + * and returns false so the caller fails the spawn (never a silent + * unsandboxed run). */ +function ensureSandboxDeps(): boolean { const has = (cmd: string) => spawnSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }).status === 0; - const missing: string[] = []; - if (!has('bwrap')) missing.push('bubblewrap'); - if (needFuse && !has('fuse-overlayfs')) missing.push('fuse-overlayfs'); - if (!missing.length) return true; - + if (has('bwrap')) return true; const pm = - has('apt-get') ? ['apt-get', 'install', '-y', ...missing] : - has('dnf') ? ['dnf', 'install', '-y', ...missing] : - has('yum') ? ['yum', 'install', '-y', ...missing] : - has('apk') ? ['apk', 'add', ...missing] : - has('pacman') ? ['pacman', '-S', '--noconfirm', ...missing] : + has('apt-get') ? ['apt-get', 'install', '-y', 'bubblewrap'] : + has('dnf') ? ['dnf', 'install', '-y', 'bubblewrap'] : + has('yum') ? ['yum', 'install', '-y', 'bubblewrap'] : + has('apk') ? ['apk', 'add', 'bubblewrap'] : + has('pacman') ? ['pacman', '-S', '--noconfirm', 'bubblewrap'] : null; const isRoot = process.getuid?.() === 0; if (pm) { @@ -103,217 +47,50 @@ function ensureSandboxDeps(needFuse: boolean): boolean { // (never blocks on an interactive prompt). const argv = isRoot ? pm : ['sudo', '-n', ...pm]; const r = spawnSync(argv[0], argv.slice(1), { stdio: 'ignore', timeout: 180_000 }); - if (r.status === 0 && !missing.some(m => !has(m === 'bubblewrap' ? 'bwrap' : m))) return true; + if (r.status === 0 && has('bwrap')) return true; } - const guide = pm ? `${isRoot ? '' : 'sudo '}${pm.join(' ')}` : `install: ${missing.join(', ')}`; - console.error(`[sandbox] missing deps (${missing.join(', ')}); auto-install unavailable — install manually then retry: ${guide}`); + const guide = pm ? `${isRoot ? '' : 'sudo '}${pm.join(' ')}` : 'install bubblewrap'; + console.error(`[sandbox] bwrap missing; auto-install unavailable — install manually then retry: ${guide}`); return false; } -// ───────────────────────────── argv builder ────────────────────────────────── - -export interface SandboxPlan { - /** In-sandbox path the project is bound at — equals the original workingDir the - * CLI was given, so the CLI's existing path args resolve. Also the child's chdir. */ - projectMount: string; - /** Host path of the merged project overlay (reads=real lower, writes=upper). */ - projectMerged: string; - /** In-sandbox path the home overlay is bound at — equals the real home path so - * every CLI's hardcoded `~/.` resolves there. */ - home: string; - /** Host path of the merged home overlay. */ - homeMerged: string; - /** Daemon-mediated `botmux send` outbox — bound LAST so it wins over any mask. */ - outbox: string; - /** Per-bot privacy masks: directories blanked with an empty tmpfs. */ - hideDirs: string[]; - /** Per-bot privacy masks: files blanked with a read-only empty placeholder. */ - hideFiles: { path: string; empty: string }[]; - /** CLI auth/login paths kept REAL + writable (bound rw over the isolated home so - * the CLI's token refresh / login persists — unlike project edits which are - * isolated). Resolved + existence-filtered by prepareSandbox. */ - authReal?: string[]; - /** Runtime-generated roots that the CLI must see but must not mutate. Trusted - * (daemon-produced, e.g. skill plugin dirs) — bound AFTER the privacy masks so - * a broad hideDir can't break skill delivery. */ - readonlyRoots?: string[]; - /** User-configured read-only inputs (per-bot sandboxReadonlyPaths). Bound BEFORE - * the privacy masks so hidePaths always win over them — an entry overlapping a - * masked path must never re-expose the real content. */ - userReadonlyRoots?: string[]; - /** Keep network egress. File-only scope ⇒ default true (npm/pip/git work). */ - net?: boolean; +/** Canonicalize if possible (Seatbelt/bwrap both resolve symlinks). */ +function canonical(p: string): string { + try { return realpathSync(p); } catch { return p; } } -/** - * Build the bwrap argv prefix. Final spawn becomes: - * bwrap -- - * - * Mount order matters (later mounts win): the whole real fs read-only first, then - * the home + project merged overlays bind over it (so writes there are isolated), - * then user readonly inputs, then privacy masks blank specific paths (masks bind - * after user readonly roots so an overlapping readonly entry can never re-expose - * masked content), then trusted runtime roots, then the outbox binds LAST so it - * stays writable even if a mask covers a parent dir. - */ -export function buildSandboxArgs(plan: SandboxPlan): string[] { - const a: string[] = []; - // Read the entire real fs (zero scrub — the CLI's config/auth/env just work). - a.push('--ro-bind', '/', '/'); - // Fresh kernel/runtime dirs (the ro-bind of / would otherwise carry host /tmp etc.). - a.push('--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', '--tmpfs', '/run', '--tmpfs', '/dev/shm'); - // Write-isolated home + project (overlay merged: reads=real lower, writes=upper). - a.push('--bind', plan.homeMerged, plan.home); - a.push('--bind', plan.projectMerged, plan.projectMount); - // CLI auth/login dirs kept REAL + writable (bind over the isolated home) so token - // refresh / login persists. Narrow (auth only) keeps session history isolated; - // some CLIs widen to their whole state dir for SQLite locks (CliAdapter.authPaths). - for (const p of plan.authReal ?? []) a.push('--bind', p, p); - // User-configured read-only inputs — BEFORE the masks so hidePaths always win - // over an overlapping (e.g. ancestor) readonly entry. - for (const root of plan.userReadonlyRoots ?? []) a.push('--ro-bind', root, root); - // Per-bot privacy masks (opt-in, no defaults). - for (const dir of plan.hideDirs) a.push('--tmpfs', dir); - for (const f of plan.hideFiles) a.push('--ro-bind', f.empty, f.path); - // Session-scoped TRUSTED runtime inputs, e.g. generated skill/plugin dirs — - // after the masks so a broad hideDir can't blank skill delivery. - for (const root of plan.readonlyRoots ?? []) a.push('--ro-bind', root, root); - // Outbox LAST so it wins even if a mask covers a parent dir. - a.push('--bind', plan.outbox, plan.outbox); - // Isolate namespaces (keep net unless explicitly disabled). - a.push('--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup-try'); - if (plan.net === false) a.push('--unshare-net'); - a.push('--die-with-parent', '--new-session', '--chdir', plan.projectMount); - return a; -} - -/** - * After `buildSandboxArgs` masks `/run` with a fresh tmpfs, any executable whose - * resolved path lives UNDER `/run` (the common case: fnm/nvm/volta expose the - * active toolchain's bin dir as a per-session symlink farm under - * `/run/user//fnm_multishells//bin`, and `which codex` / the daemon's - * own `process.execPath` for node land there) would VANISH inside the sandbox → - * bwrap `execvp` fails → the CLI exits instantly → Botmux's crash-loop guard - * trips after 4 retries. This re-exposes each such bin dir read-only at its real - * path so the binary (and the node interpreter its `#!/usr/bin/env node` shebang - * needs, which lives in the same fnm bin dir) survive the tmpfs. - * - * The caller feeds in EVERY path that will be exec'd inside the sandbox, not just - * the direct bwrap target: the cliBin, the daemon's own node (process.execPath), - * AND each adapter-declared SECOND-STAGE executable (CliAdapter.sandboxExtraExecPaths) - * — e.g. the codex-app adapter's resolved `codex` (its resolvedBin is the daemon - * node running the runner, which spawns the real codex later for the app-server, - * so without this the codex path would still be masked). We deliberately do NOT - * scan raw cliArgs: a path arg like `--cwd /run/user//proj` would re-bind its - * PARENT `/run/user/`, shadowing the project overlay mounted there and - * exposing sibling files / IPC sockets — re-exposing must be limited to declared - * executables. - * - * Pure: returns the `--ro-bind-try ` args (deduped, `/run/`-subpaths - * only — NEVER `/run` itself, which would clobber the tmpfs and the relay shim - * mounted at /run/sbxbin). `-try` so a stale/racing path can't fail the spawn. - * Returns [] for binaries already outside /run (system node, npm/pnpm globals) — - * non-fnm users are unaffected. - */ -export function reexposeRunBinArgs(binPaths: (string | undefined)[]): string[] { - const dirs = new Set(); - for (const p of binPaths) { - if (!p || typeof p !== 'string') continue; - const d = dirname(p); - if (d.startsWith('/run/')) dirs.add(d); // startsWith('/run/') excludes '/run' itself - } - const out: string[] = []; - for (const d of dirs) out.push('--ro-bind-try', d, d); - return out; -} - -/** Expand a leading `~` (bare `~` or `~/…` only — never `~user`) to `home`. */ -function expandTilde(raw: string, home: string): string { - return raw.replace(/^~(?=\/|$)/, home); -} - -/** Tilde-expand each entry and keep only paths that exist on the host. */ -function resolveExistingPaths(paths: readonly string[] | undefined, home: string): string[] { - const out: string[] = []; - for (const raw of paths ?? []) { - if (!raw || typeof raw !== 'string') continue; - const p = expandTilde(raw, home); - try { if (existsSync(p)) out.push(p); } catch { /* */ } - } - return out; -} - -/** Does readonly-binding `p` swallow the overlay root `root` (p === root or an - * ancestor of it)? Later binds win in bwrap, so such a bind would replace the - * whole write-isolated overlay with the real read-only tree. Both args must be - * canonicalized first — a raw `/repo/`, `/repo/../repo`, or a symlink to the - * project would slip past a plain string-prefix check yet still shadow the - * overlay once bwrap normalizes/resolves the mount. */ -function coversRoot(p: string, root: string): boolean { - if (p === root) return true; - const prefix = p.endsWith('/') ? p : `${p}/`; // '/' stays '/', '/a' → '/a/' - return root.startsWith(prefix); -} - -/** Canonicalize an existing path: resolve symlinks + `.`/`..`/trailing slash. - * Falls back to a lexical resolve if realpath fails (e.g. a racing unlink). */ -function canonicalize(p: string): string { - try { return realpathSync(p); } catch { return resolve(p); } -} - -/** bwrap cannot bind-mount over a symlink mount destination. Some hosts expose - * $HOME through a symlink, so bind overlays — and set the child HOME env — at - * canonical targets so the mount point always exists and $HOME resolves even - * when the symlink's parent is masked inside the sandbox. */ -export function resolveSandboxMountPath(p: string): string { - return canonicalize(p); -} - -/** - * Resolve user-configured sandboxReadonlyPaths: tilde-expand, drop non-existent - * entries, and REJECT entries that (after resolving symlinks + normalizing) are - * equal to or an ancestor of an overlay root (home / projectMount) — those would - * shadow the entire write-isolated overlay with the real read-only tree, - * silently breaking write isolation. The overlap check runs on the CANONICAL - * path so a symlink (`/tmp/ref -> /repo`) or a non-normalized string (`/repo/`, - * `/repo/../repo`) can't alias past the guard. Entries strictly UNDER an overlay - * root stay allowed: that's the documented "reference material, read-only, - * excluded from /land" use case. Returns the tilde-expanded original paths (the - * docs promise "mounted at the same path"); bwrap resolves any symlink source. - * Exported for tests. - */ -export function resolveUserReadonlyRoots( - paths: readonly string[] | undefined, home: string, projectMount: string, -): string[] { - const homeReal = canonicalize(home); - const projReal = canonicalize(projectMount); - const out: string[] = []; - for (const p of resolveExistingPaths(paths, home)) { - const real = canonicalize(p); - if (coversRoot(real, homeReal) || coversRoot(real, projReal)) { - console.error(`[sandbox] sandboxReadonlyPaths entry ignored (resolves to an overlay root, would shadow write isolation): ${p}`); - continue; - } - out.push(p); - } - return out; -} - -// ───────────────────────────── orchestration ───────────────────────────────── - /** Absolute path to this build's compiled cli.js (dist/cli.js), derived from * this module's own location (dist/adapters/backend/sandbox.js → ../../cli.js). */ function distCliJs(): string { return fileURLToPath(new URL('../../cli.js', import.meta.url)); } -/** Is file-sandbox enabled for this session? Spike gate = env; the real - * per-bot BotConfig.sandbox flag is decided by the caller. */ +/** Is the file sandbox globally forced for this daemon? The real per-bot + * BotConfig.sandbox flag is decided by the caller. */ export function sandboxEnabled(): boolean { return process.env.BOTMUX_SANDBOX === '1'; } -export interface SandboxSpawn { +/** + * Whether a LOCAL sandbox engine applies to this backend at all. riff has NO + * local CLI process to wrap (execution happens in riff's own remote sandbox); + * without this bypass the worker's fail-safe "backend not sandboxable" hard + * error would brick every sandbox-enabled bot the moment it switches to riff. + * Platform is no longer a factor — fs-policy sandboxes darwin AND linux. + */ +export function localSandboxApplies(backendType: string): boolean { + return backendType !== 'riff'; +} + +/** Proxy env vars forwarded into the sandbox so the CLI reaches the API even on + * the tmux backend (which otherwise only forwards a fixed whitelist). */ +const PROXY_ENV_KEYS = ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'all_proxy', 'ALL_PROXY'] as const; + +/** Top-level dirs that are symlinks on usrmerge distros (/bin → usr/bin …) — + * replicated inside the tmpfs root so `#!/bin/sh` etc. resolve. */ +const USRMERGE_CANDIDATES = ['/bin', '/sbin', '/lib', '/lib64', '/lib32', '/libx32'] as const; + +export interface DirectSandboxSpawn { /** Replace the CLI binary with this (always 'bwrap'). */ bin: string; /** bwrap args + '--' + original (bin, ...args). */ @@ -322,219 +99,77 @@ export interface SandboxSpawn { env: Record; /** Outbox dir the daemon watcher must service. */ outbox: string; - /** Project overlay UPPER dir — THE LANDABLE CHANGESET (used by sandbox-land). */ - workDir: string; - /** HOME overlay UPPER dir (/var/tmp/botmux-sbx//home-upper). The sandboxed - * CLI's $HOME writes — INCLUDING its session jsonl under CLAUDE_CONFIG_DIR — - * land here (invisible at the real path). The worker redirects its bridge/idle - * watch into this via sandboxedClaudeDataDir() so it sees the CLI's turns. */ - homeUpper: string; - /** Unmount the overlays + remove the per-session sandbox tree. */ + /** Remove the per-session sandbox tree (plain rm — no mounts exist). */ cleanup: () => void; } -/** The path where a sandboxed session's CLI actually writes a $HOME-relative - * data dir (e.g. CLAUDE_CONFIG_DIR / `.claude-runtime`): the HOME overlay's - * ephemeral UPPER copy. The worker redirects its jsonl/bridge watch here so it - * sees the sandboxed CLI's writes (which are invisible at the real host path). - * Mirrors prepareSandbox's homeUpper layout — keep in sync. - * - * The home overlay is bound (and $HOME set) at the CANONICAL home, so copy-ups - * land relative to that root. Compute the in-home relative path robustly whether - * realDataDir arrives in symlink or canonical form: adapters build it from the - * raw homedir() (so the raw base cancels cleanly in the common case), but a - * canonicalized dataDir under a symlink home would escape home-upper via `..` — - * fall back to the canonical base so it can't. */ -export function sandboxedClaudeDataDir(sessionId: string, realDataDir: string): string { - const raw = relative(homedir(), realDataDir); - const rel = raw.startsWith('..') ? relative(resolveSandboxMountPath(homedir()), realDataDir) : raw; - return join(VARTMP_ROOT, sessionId, 'home-upper', rel); -} - -/** Proxy env vars forwarded into the sandbox so the CLI reaches the API even on - * the tmux backend (which otherwise only forwards a fixed whitelist). */ -const PROXY_ENV_KEYS = ['http_proxy', 'https_proxy', 'HTTP_PROXY', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY', 'all_proxy', 'ALL_PROXY'] as const; - -/** - * Whether the LOCAL bwrap file sandbox applies to this spawn at all. - * - macOS enforces `sandbox: true` via the Seatbelt write-sandbox instead. - * - riff has NO local CLI process to wrap (execution happens in riff's own - * remote sandbox); without this bypass the worker's fail-safe "backend not - * sandboxable" hard error would brick every sandbox-enabled bot the moment - * it switches to riff (the dashboard agent switch does not clear `sandbox`). - */ -export function localSandboxApplies(platform: NodeJS.Platform, backendType: string): boolean { - return platform !== 'darwin' && backendType !== 'riff'; -} - /** - * Build the sandboxed spawn for a CLI session, or return null when sandboxing - * is off / unsupported / a required overlay mount fails (fail-safe = the worker - * treats null as a hard error and does NOT silently run unsandboxed). + * Build the bwrap DIRECT-mode spawn for a CLI session, or return null when the + * runtime deps are unavailable / setup fails (fail-safe: the worker treats + * null as a hard error and never silently runs unsandboxed). * - * Layout under /sandboxes//: outbox, shimbin, proj-upper - * (the landable changeset), proj-work, proj-merged, home-merged. The HOME - * overlay's upper/work live under /var/tmp/botmux-sbx// because - * overlayfs forbids upper/work inside the lower (= the real home). + * Layout under /sandboxes//: outbox, shimbin, empties. + * No overlays, no upper/work dirs — writes inside readWrite zones hit the + * real filesystem directly. */ -export function prepareSandbox(opts: { - /** Whether the sandbox is on for THIS session (per-bot BotConfig.sandbox OR - * the BOTMUX_SANDBOX env force). Decided by the caller — prepareSandbox does - * NOT re-read the env, so the dashboard per-bot toggle actually takes effect. */ - enabled: boolean; - cliId: string; +export function prepareDirectSandbox(opts: { sessionId: string; - sourceWorkingDir: string; dataDir: string; + /** Compile-ready policy (canonical + existence-filtered by the worker). */ + policy: FsPolicy; + /** Child chdir (the canonical project working dir). */ + chdir: string; + /** Canonical $HOME to set for the child. */ + home: string; cliBin: string; cliArgs: string[]; - /** Per-bot privacy masks (opt-in, no defaults). Paths existing as dirs are - * blanked with a tmpfs; files with an empty read-only placeholder. */ - hidePaths?: string[]; - /** This CLI's auth/login paths (CliAdapter.authPaths) to keep real+writable so - * token refresh / login persists. `~` expanded; missing paths skipped. */ - authPaths?: readonly string[]; - /** Adapter-declared SECOND-STAGE executables (CliAdapter.sandboxExtraExecPaths) - * spawned inside the sandbox beyond cliBin — re-exposed if under /run. ONLY - * executable paths (never cwd/path args). undefined → none. */ - extraExecPaths?: readonly string[]; - /** Runtime-generated roots that should be visible read-only inside bwrap. - * Trusted (daemon-produced) — bound after the privacy masks. */ - readonlyRoots?: readonly string[]; - /** User-configured extra read-only inputs (per-bot sandboxReadonlyPaths). - * Bound before the privacy masks (masks win) and rejected when they would - * shadow the home/project overlay roots. */ - userReadonlyPaths?: readonly string[]; - /** Keep network egress. Defaults to true for backwards compatibility. */ - net?: boolean; -}): SandboxSpawn | null { - if (!opts.enabled) return null; - if (process.platform !== 'linux') return null; // overlayfs + bwrap are Linux-only - - // Auto-provision deps so the user needn't pre-install (bwrap; + fuse-overlayfs - // for the rootless/userspace overlay path). Fail the spawn if unavailable. - const needFuse = process.env.BOTMUX_SANDBOX_FUSE === '1' || process.getuid?.() !== 0; - if (!ensureSandboxDeps(needFuse)) return null; - - const dataDir = resolveSandboxMountPath(opts.dataDir); - const sessionRoot = join(dataDir, 'sandboxes', opts.sessionId); +}): DirectSandboxSpawn | null { + if (process.platform !== 'linux') return null; + if (!ensureSandboxDeps()) return null; + + const sessionRoot = join(canonical(opts.dataDir), 'sandboxes', opts.sessionId); const outbox = join(sessionRoot, 'outbox'); const shimBin = join(sessionRoot, 'shimbin'); const empties = join(sessionRoot, 'empties'); - const projUpper = join(sessionRoot, 'proj-upper'); // THE LANDABLE CHANGESET - const projWork = join(sessionRoot, 'proj-work'); - const projMerged = join(sessionRoot, 'proj-merged'); - const homeMerged = join(sessionRoot, 'home-merged'); // merged may live under sessionRoot - // HOME overlay upper/work MUST be OUTSIDE the home lower (overlayfs constraint). - const vartmp = join(VARTMP_ROOT, opts.sessionId); - const homeUpper = join(vartmp, 'home-upper'); - const homeWork = join(vartmp, 'home-work'); for (const d of [outbox, shimBin, empties]) mkdirSync(d, { recursive: true }); - const home = resolveSandboxMountPath(homedir()); - // BOTMUX_SANDBOX_SRC overrides the LOWER project source for spike testing only. - const projectSource = resolveSandboxMountPath(process.env.BOTMUX_SANDBOX_SRC || opts.sourceWorkingDir); - const projectMount = resolveSandboxMountPath(opts.sourceWorkingDir); - - // A same-session re-spawn (e.g. in-pane /clear) re-enters here; unmount any - // stale merged overlays first so we don't stack a second mount on the same dir. - unmountOverlay(projMerged); - unmountOverlay(homeMerged); - - // Mount the HOME overlay (lower=real home → reads pass through, writes isolate). - const homeOk = mountOverlay({ lower: home, upper: homeUpper, work: homeWork, merged: homeMerged }); - if (!homeOk) { - return null; // fail-safe: no silent unsandboxed run - } - // Mount the PROJECT overlay. proj-upper = the landable changeset. - const projOk = mountOverlay({ lower: projectSource, upper: projUpper, work: projWork, merged: projMerged }); - if (!projOk) { - unmountOverlay(homeMerged); - return null; // fail-safe - } - - // Record the project LOWER source so landing can tell a wholesale-REPLACED dir - // (existed in the lower at create time) from a purely-NEW dir (overlayfs marks - // BOTH opaque, so the lower is the only reliable discriminator — and the live - // landing target may have drifted, so we must check the lower-at-create, not it). - try { writeFileSync(join(sessionRoot, 'meta.json'), JSON.stringify({ projectLower: projectSource })); } catch { /* */ } - - // `botmux` shim → THIS build's cli.js (readable natively via --ro-bind / /), so - // in-sandbox `botmux send` hits relay mode (and never the host bots.json). + // `botmux` shim → THIS build's cli.js so in-sandbox `botmux send` hits relay + // mode (and never needs bots.json, which the policy doesn't expose). const shim = join(shimBin, 'botmux'); writeFileSync(shim, `#!/bin/sh\nexec node ${JSON.stringify(distCliJs())} "$@"\n`); chmodSync(shim, 0o755); - // Per-bot privacy masks: existing dirs → tmpfs blank; everything else → empty - // read-only placeholder file. No defaults (caller passes hidePaths explicitly). - // `~` resolves like the docs' examples (`~/.ssh`) — an unexpanded tilde would - // fail existsSync and mask a literal `~/...` path, leaving the real one readable. - const hideDirs: string[] = []; - const hideFiles: { path: string; empty: string }[] = []; - let emptyIdx = 0; - for (const raw of opts.hidePaths ?? []) { - if (!raw || typeof raw !== 'string') continue; - const p = expandTilde(raw, home); - let isDir = false; - try { isDir = existsSync(p) && statSync(p).isDirectory(); } catch { /* */ } - if (isDir) { - hideDirs.push(p); - } else { - const empty = join(empties, `mask-${emptyIdx++}`); - try { writeFileSync(empty, ''); } catch { /* */ } - hideFiles.push({ path: p, empty }); - } + // usrmerge symlinks to replicate; deny rules that are FILES on the host + // (dir denies mask with tmpfs, file denies need an empty ro-bind source). + const symlinks: { path: string; target: string }[] = []; + for (const p of USRMERGE_CANDIDATES) { + try { + if (lstatSync(p).isSymbolicLink()) symlinks.push({ path: p, target: readlinkSync(p) }); + } catch { /* absent on this distro */ } + } + const filePaths = new Set(); + for (const r of opts.policy.rules) { + if (r.access !== 'deny') continue; + try { if (statSync(r.path).isFile()) filePaths.add(r.path); } catch { /* */ } } - // CLI auth/login paths kept real+writable (token refresh / login must persist, - // unlike isolated project edits). Resolve `~` and bind only existing paths — a - // missing auth file isn't a valid mountpoint (the CLI must be logged in on the - // host; login-from-scratch inside the sandbox isn't supported). - const authReal = resolveExistingPaths(opts.authPaths, home); - const readonlyRoots = resolveExistingPaths(opts.readonlyRoots, home); - const userReadonlyRoots = resolveUserReadonlyRoots(opts.userReadonlyPaths, home, projectMount); - - const plan: SandboxPlan = { - projectMount, - projectMerged: projMerged, - home, - homeMerged, - outbox, - hideDirs, - hideFiles, - authReal, - readonlyRoots, - userReadonlyRoots, - net: opts.net !== false, - }; - const args = buildSandboxArgs(plan); - // Shim bin at a fixed path UNDER the /run tmpfs — the whole real fs is bound - // read-only (`--ro-bind / /`), so bwrap can't mkdir a new mountpoint at the - // root (/sbxbin) → it must live under a writable tmpfs (/run). PATH points here. + const compiled = compileToBwrap(opts.policy, { symlinks, emptiesDir: empties, filePaths, chdir: opts.chdir }); + for (const f of compiled.emptyFiles) { + try { writeFileSync(f.path, ''); } catch { /* */ } + } + + const args = [...compiled.args]; + // Shim bin at a fixed path under the fresh /run tmpfs — appended after the + // rule mounts (later mount wins over the tmpfs). PATH points here first. args.push('--ro-bind', shimBin, '/run/sbxbin'); - // botmux skill/plugin dir (claude `--plugin-dir` points here; carries the - // botmux-send etc. skills, no secrets). Re-exposed read-only at its real path. - const pluginDir = join(home, '.botmux', 'claude-plugin'); - args.push('--ro-bind-try', pluginDir, pluginDir); - // Re-expose any bin dir living under /run (fnm/nvm/volta symlink farms) that the - // `--tmpfs /run` above just masked — else the resolved cliBin / the node its - // shebang needs / an adapter's declared second-stage binary vanish in-sandbox - // and the CLI crash-loops on spawn. ONLY executable paths (never cwd/path args): - // - opts.cliBin: the direct bwrap target - // - process.execPath: the daemon's own node (under /run too when fnm-managed) - // - opts.extraExecPaths: adapter-declared second-stage execs, e.g. codex-app's - // real codex (its resolvedBin is the daemon node, so cliBin alone misses it). - args.push(...reexposeRunBinArgs([opts.cliBin, process.execPath, ...(opts.extraExecPaths ?? [])])); - - // Authoritative child env via bwrap --setenv (works on pty AND tmux — the tmux - // backend only forwards a fixed whitelist, which excludes HOME/PATH/relay). + + // Authoritative child env via bwrap --setenv (works on pty AND tmux — the + // tmux backend only forwards a fixed whitelist). const env: Record = { - HOME: home, // MUST match where the overlay is bound (canonical); - // a symlink-form HOME dangles when its parent is masked (e.g. tmpfs /tmp) - BOTMUX_SEND_RELAY: outbox, // routes `botmux send` to the daemon outbox watcher - PATH: `/run/sbxbin:${process.env.PATH ?? ''}`, // /run/sbxbin first so `botmux` = the relay shim + HOME: opts.home, + BOTMUX_SEND_RELAY: outbox, + PATH: `/run/sbxbin:${process.env.PATH ?? ''}`, }; - // Forward proxy vars so the CLI reaches the API on the tmux backend too. for (const k of PROXY_ENV_KEYS) { const v = process.env[k]; if (typeof v === 'string' && v) env[k] = v; @@ -547,72 +182,44 @@ export function prepareSandbox(opts: { args, env, outbox, - workDir: projUpper, - homeUpper, cleanup: () => { - unmountOverlay(projMerged); - unmountOverlay(homeMerged); try { rmSync(sessionRoot, { recursive: true, force: true }); } catch { /* */ } - try { rmSync(vartmp, { recursive: true, force: true }); } catch { /* */ } }, }; } /** - * Re-attach the daemon/worker side to an ALREADY-spawned sandbox session WITHOUT - * touching the overlays. Used on daemon-restart reattach to a persistent - * (tmux/herdr/zellij) pane whose bwrap'd CLI is still alive: the CLI is bound to - * its own namespace-pinned overlay, so we must NOT unmount/remount (that would - * leave a duplicate host-side mount the CLI isn't using). We only need the outbox - * path back so the watcher can keep servicing the live CLI's `botmux send`, plus - * the workDir (upper changeset for landing) and a cleanup that tears the residue - * down at close/exit. Returns null if the session has no sandbox tree on disk - * (never sandboxed). Linux-only, mirrors prepareSandbox's layout. + * Re-attach the daemon/worker side to an ALREADY-spawned sandbox session (a + * live bwrap'd CLI surviving in a tmux/herdr/zellij pane across a daemon + * restart). Only the outbox path is needed back so the watcher keeps servicing + * the live CLI's `botmux send`, plus a cleanup that removes the tree at + * close/exit. Returns null if the session has no sandbox tree on disk (never + * sandboxed). Linux-only, mirrors prepareDirectSandbox's layout. */ -export function attachSandboxOutbox(opts: { sessionId: string; dataDir: string }): { outbox: string; workDir: string; cleanup: () => void } | null { +export function attachSandboxOutbox(opts: { sessionId: string; dataDir: string }): { outbox: string; cleanup: () => void } | null { if (process.platform !== 'linux') return null; - const dataDir = resolveSandboxMountPath(opts.dataDir); - const sessionRoot = join(dataDir, 'sandboxes', opts.sessionId); + const sessionRoot = join(canonical(opts.dataDir), 'sandboxes', opts.sessionId); + if (!existsSync(sessionRoot)) return null; // never sandboxed const outbox = join(sessionRoot, 'outbox'); - const projUpper = join(sessionRoot, 'proj-upper'); - if (!existsSync(outbox) && !existsSync(projUpper)) return null; // never sandboxed - // Ensure the outbox exists (the watcher reads it); never (re)mount here. try { mkdirSync(outbox, { recursive: true }); } catch { /* */ } - const projMerged = join(sessionRoot, 'proj-merged'); - const homeMerged = join(sessionRoot, 'home-merged'); - const vartmp = join(VARTMP_ROOT, opts.sessionId); return { outbox, - workDir: projUpper, cleanup: () => { - unmountOverlay(projMerged); - unmountOverlay(homeMerged); try { rmSync(sessionRoot, { recursive: true, force: true }); } catch { /* */ } - try { rmSync(vartmp, { recursive: true, force: true }); } catch { /* */ } }, }; } -/** Reclaim one session's overlay residue: unmount both merged overlays + rm the - * per-session tree (incl. the /var/tmp home scratch). Idempotent / best-effort. */ -function reclaimSandbox(dataDir: string, sid: string): void { - const sessionRoot = join(resolveSandboxMountPath(dataDir), 'sandboxes', sid); - unmountOverlay(join(sessionRoot, 'proj-merged')); - unmountOverlay(join(sessionRoot, 'home-merged')); - try { rmSync(sessionRoot, { recursive: true, force: true }); } catch { /* */ } - try { rmSync(join(VARTMP_ROOT, sid), { recursive: true, force: true }); } catch { /* */ } -} - /** Scan the process table for sandbox session-ids referenced by any running - * process's argv. A live bwrap's bind/overlay paths contain `sandboxes/` - * and `botmux-sbx/`, so this physically detects which sandbox dirs are - * still in use — by overlay sessions AND old clone-model sessions alike. Used as - * a hard guard so the sweep never deletes a dir out from under a live CLI. */ + * process's argv (a live bwrap's bind paths contain `sandboxes/`). Hard + * guard so the sweep never deletes an outbox out from under a live CLI whose + * session record was lost — the outbox is bind-mounted INTO the live sandbox, + * so removing the host-side source would break its relay. */ function liveSandboxSids(): Set { const live = new Set(); let pids: string[]; try { pids = readdirSync('/proc'); } catch { return live; } - const re = /(?:sandboxes|botmux-sbx)\/([^/\0]+)/g; + const re = /sandboxes\/([^/\0]+)/g; for (const pid of pids) { if (!/^\d+$/.test(pid)) continue; let cmd: string; @@ -625,64 +232,36 @@ function liveSandboxSids(): Set { } /** - * Reclaim leaked sandbox residue. - * - * Two classes of leak are reclaimed: - * 1. NON-ACTIVE orphans — sid not in `activeSessionIds`: the session is gone, so - * any leftover mount/dir is pure residue (the original startup-sweep case, - * guarding against a daemon crash/kill that skipped killCli()). - * 2. ACTIVE-but-DEAD — sid IS in `activeSessionIds`, yet NEITHER of its merged - * overlays is still mounted. This closes the blind spot where a sandboxed - * worker was SIGKILL'd (straggler reaper) or crashed: the session stays - * status='active' on disk, so the old "skip if active" rule would let the - * leaked upper/work dirs survive across restarts indefinitely. We only GC an - * active sid when its mounts are ALREADY gone — we NEVER tear down a live - * mount (a CLI persisting in a tmux/herdr/zellij pane is still bound to it), - * so a genuinely-live persistent session keeps its changeset. - * - * Safe to call repeatedly: wire once at daemon bootstrap AND on a periodic timer - * (the SIGKILL/straggler path can't run worker-side killCli(), so a startup-only - * sweep would let a crashed-active session's mount survive for the whole next - * daemon lifetime — one daemon per bot can run for days). + * Reclaim leaked per-session sandbox trees (outbox/shim/empties of sessions + * that no longer exist) — plain directory residue in the direct model, no + * mounts. Guards: never touch an ACTIVE session's tree (it may be suspended, + * intending to resume — its outbox must survive) and never touch a tree + * referenced by a live process (a reattached pane whose session record was + * lost). Safe to call repeatedly: wired at daemon bootstrap AND on a periodic + * timer. */ export function sweepOrphanSandboxes(dataDir: string, activeSessionIds: Set): void { - const sandboxDataDir = resolveSandboxMountPath(dataDir); - const root = join(sandboxDataDir, 'sandboxes'); + const root = join(canonical(dataDir), 'sandboxes'); let sids: string[] = []; try { sids = readdirSync(root); } catch { return; } // no sandboxes dir yet - // Grace before reclaiming an ACTIVE-but-unmounted sandbox: a worker that just - // (re)spawned creates the outbox/shimbin dirs a few syscalls BEFORE it mounts - // the overlay. Without this, a sweep firing in that tiny window would nuke an - // in-progress session's outbox. Non-active orphans are reclaimed immediately - // (no live worker can be mid-spawn for a session that isn't active). - const ACTIVE_DEAD_GRACE_MS = 60_000; + // Grace so a worker mid-spawn (dirs created a few syscalls before the CLI + // process appears in /proc) can't have its outbox swept. + const GRACE_MS = 60_000; const now = Date.now(); - // Hard physical guard: NEVER reclaim a session whose dir is referenced by a - // live process. A running bwrap binds/overlays paths containing the sid, so a - // process-table scan catches BOTH overlay sessions (merged mounts) AND old - // clone-model sessions re-attached after a daemon restart (which have NO - // overlay mount, so the isMounted check below would wrongly deem them dead and - // delete their bind-source dirs out from under the live CLI). This is the root - // cause of the 2026-06-10 incident — keep it as the FIRST gate. const live = liveSandboxSids(); for (const sid of sids) { + if (live.has(sid)) continue; // a running process holds this tree + if (activeSessionIds.has(sid)) continue; // active (possibly suspended) session const sessionRoot = join(root, sid); - if (live.has(sid)) continue; // a running process holds this sandbox — leave it - if (activeSessionIds.has(sid)) { - // Active session: keep it while a host-side overlay is still mounted (= a - // live CLI may be bound to the changeset). If BOTH merged overlays are gone - // AND the tree is older than the spawn grace, the worker/CLI is dead → - // reclaim the dead residue. We NEVER tear down a live mount, so a genuinely - // live persistent (tmux/herdr/zellij) session keeps its changeset. - if (isMounted(join(sessionRoot, 'proj-merged')) || isMounted(join(sessionRoot, 'home-merged'))) continue; - let ageOk = false; - try { ageOk = now - statSync(sessionRoot).mtimeMs > ACTIVE_DEAD_GRACE_MS; } catch { ageOk = false; } - if (!ageOk) continue; // too fresh — could be a worker mid-spawn - } - reclaimSandbox(sandboxDataDir, sid); + let ageOk = false; + try { ageOk = now - statSync(sessionRoot).mtimeMs > GRACE_MS; } catch { ageOk = false; } + if (!ageOk) continue; + try { rmSync(sessionRoot, { recursive: true, force: true }); } catch { /* */ } } } +// ─────────────────────── botmux send relay (unchanged) ─────────────────────── + // Relay request schema (written by cli.ts relaySend, validated here). The // watcher NEVER executes sandbox-supplied argv — it rebuilds the command from // these validated fields. This is the security boundary: a malicious agent can diff --git a/src/adapters/backend/tmux-backend.ts b/src/adapters/backend/tmux-backend.ts index 918d6fe8f..8e9280ca9 100644 --- a/src/adapters/backend/tmux-backend.ts +++ b/src/adapters/backend/tmux-backend.ts @@ -586,6 +586,9 @@ const BOTMUX_INJECTED_ENV_KEYS = [ 'BOTMUX_LARK_APP_ID', 'BOTMUX_ROOT_MESSAGE_ID', 'BOTMUX_TURN_ID', + // This bot's resolved brandLabel template — injected so a SANDBOXED + // `botmux send` renders the role footer without reading bots.json (denied). + 'BOTMUX_BRAND_LABEL', // Experimental Lark chat bot discovery. The daemon injects a canonical // true/false value so `botmux bots list` inside long-lived panes matches the // daemon's `` behavior instead of reading stale rcfile/tmux env. diff --git a/src/adapters/cli/fs-policy.ts b/src/adapters/cli/fs-policy.ts new file mode 100644 index 000000000..f5ef84d93 --- /dev/null +++ b/src/adapters/cli/fs-policy.ts @@ -0,0 +1,551 @@ +/** + * Unified file-sandbox policy (FsPolicy) — the single source of truth for BOTH + * platform sandbox engines. + * + * Model (three-tier whitelist, per product decision 2026-07-16, design doc + * "botmux 文件沙盒重构方案"): every path gets one of three access levels — + * readWrite / readOnly / deny — and EVERYTHING NOT COVERED BY A RULE IS + * INACCESSIBLE (deny-by-default). Nested black/white lists are supported: the + * DEEPEST matching rule wins (longest-prefix), so `readOnly ~/Library` + + * `deny ~/Library/Keychains` and `deny bots/` + `readWrite bots/` both + * work. This replaces the previous "read-everything + enumerated blocklist" + * model whose failure mode was silent secret exposure; here a missing baseline + * entry fails loud (CLI error), never silent. + * + * Architecture: this module is PURE (no fs / no spawn — fully unit-testable). + * buildFsPolicy(ctx) merges baseline preset + botmux-internal + adapter + * + user rules into one ordered rule list + * compileToSeatbelt(...) → macOS sandbox-exec profile text + * compileToBwrap(...) → Linux bwrap argv prefix + * Both engines resolve conflicts by "last emitted wins" (Seatbelt last-match / + * bwrap mount order), so emitting rules sorted shallow→deep yields + * longest-prefix-wins on BOTH platforms by construction — cross-platform + * parity needs no hand-synced rule lists. + * + * The worker resolves every impure input up front (realpath, existence + * filtering, sibling-free by design) and passes canonical absolute paths. + */ + +export type FsAccess = 'readWrite' | 'readOnly' | 'deny'; +export type FsRuleSource = 'baseline' | 'adapter' | 'internal' | 'user'; + +export interface FsRule { + /** Canonical absolute path (no trailing slash). */ + path: string; + access: FsAccess; + /** Where the rule came from — for the dashboard policy viewer / path tester. */ + source: FsRuleSource; +} + +export interface FsPolicy { + /** Deduped rules sorted shallow→deep (emission order = precedence order). */ + rules: FsRule[]; + /** Keep network egress (bwrap-only knob; Seatbelt does not confine net here). */ + net: boolean; + /** Extra Seatbelt write-allow regexes (e.g. ~/.claude.json.tmp.* atomic-save + * siblings when the CLI data dir is NOT redirected into BOT_HOME). */ + writeRegexes: string[]; +} + +export interface FsPolicyUserPaths { + readWrite?: readonly string[]; + readOnly?: readonly string[]; + deny?: readonly string[]; +} + +export interface FsPolicyContext { + platform: 'darwin' | 'linux'; + /** All paths below must be CANONICAL (realpath'd by the worker). */ + homeDir: string; + botmuxHome: string; + sessionDataDir: string; + workingDir: string; + currentAppId: string; + /** This bot's BOT_HOME (`/bots/`) — always readWrite. */ + botHome: string; + /** True when the CLI's data root is redirected into BOT_HOME + * (CLAUDE_CONFIG_DIR / CODEX_HOME). False → cliDataPaths are exposed rw. */ + redirectedCliData: boolean; + /** The CLI's REAL data paths (e.g. ~/.claude, ~/.claude.json, ~/.codex) to + * keep readWrite when NOT redirected. Ignored when redirectedCliData. */ + cliDataPaths?: readonly string[]; + /** Adapter auth/login paths kept readWrite (token refresh must persist). */ + authPaths?: readonly string[]; + /** Directories of every executable spawned inside the sandbox (cliBin dir, + * node dir, adapter second-stage bins) — exposed readOnly. */ + execPaths?: readonly string[]; + /** Trusted runtime read-only roots (skill/plugin dirs, botmux dist). */ + readonlyRoots?: readonly string[]; + /** The botmux install/checkout root (dir containing dist/ + node_modules). + * Exposed readOnly so the agent's `botmux` CLI and the claude hooks (which + * exec `node /dist/cli.js …`) can load — without this a sandboxed + * `botmux send` / SessionStart+AskUserQuestion hooks EPERM on cli.js. */ + botmuxInstallRoot?: string; + /** Daemon-mediated relay outbox (Linux) — readWrite. */ + outbox?: string; + /** Extra writable roots (resolved TMPDIR, admin extras). */ + extraWritePaths?: readonly string[]; + /** Per-bot user config (bots.json sandboxPaths) — highest precedence. */ + userPaths?: FsPolicyUserPaths; + net?: boolean; + /** Seatbelt write-allow regex passthrough (see FsPolicy.writeRegexes). */ + writeRegexes?: readonly string[]; +} + +/** Normalize: require absolute, strip trailing slashes, reject `..` segments. + * Returns null for anything unusable (silently-dropped relative paths are a + * fail-open trap — callers log dropped entries). */ +export function normalizeFsPath(p: string): string | null { + if (!p || typeof p !== 'string') return null; + const t = p.replace(/\/+$/, '') || '/'; + if (!t.startsWith('/')) return null; + if (t.split('/').includes('..')) return null; + return t; +} + +/** Path depth for emission ordering ('/' = 0, '/a' = 1, '/a/b' = 2 …). */ +function pathDepth(p: string): number { + if (p === '/') return 0; + return p.split('/').length - 1; +} + +/** Is `p` equal to or an ancestor of `child`? (both normalized) */ +export function coversPath(p: string, child: string): boolean { + if (p === child) return true; + const prefix = p === '/' ? '/' : `${p}/`; + return child.startsWith(prefix); +} + +const SOURCE_RANK: Record = { baseline: 0, adapter: 1, internal: 2, user: 3 }; +const RESTRICTIVENESS: Record = { readWrite: 0, readOnly: 1, deny: 2 }; + +/** + * Merge candidate rules into the final ordered list: + * - normalize paths, drop unusable entries + * - dedupe same-path conflicts: higher source rank wins (user > internal > + * adapter > baseline); tie → the MORE RESTRICTIVE access wins (deny > + * readOnly > readWrite) so a duplicated entry can never widen access + * - sort shallow→deep (stable within a depth) — the emission order both + * compilers rely on for longest-prefix-wins + */ +export function mergeFsRules(candidates: readonly FsRule[]): FsRule[] { + const byPath = new Map(); + for (const c of candidates) { + const path = normalizeFsPath(c.path); + if (!path) continue; + const rule: FsRule = { path, access: c.access, source: c.source }; + const prev = byPath.get(path); + if (!prev) { byPath.set(path, rule); continue; } + const rankNew = SOURCE_RANK[rule.source], rankOld = SOURCE_RANK[prev.source]; + if (rankNew > rankOld) { byPath.set(path, rule); continue; } + if (rankNew === rankOld && RESTRICTIVENESS[rule.access] > RESTRICTIVENESS[prev.access]) { + byPath.set(path, rule); + } + } + return [...byPath.values()].sort((a, b) => pathDepth(a.path) - pathDepth(b.path) || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +/** + * The effective access for `path` under `rules`: the DEEPEST rule whose path + * covers it; no match → 'none' (inaccessible). This one function IS the policy + * semantics — the dashboard path tester and the unit tests both call it, and + * both compilers are tested to agree with it. + */ +export function accessForPath(rules: readonly FsRule[], path: string): { access: FsAccess | 'none'; rule?: FsRule } { + const p = normalizeFsPath(path); + if (!p) return { access: 'none' }; + let best: FsRule | undefined; + for (const r of rules) { + if (!coversPath(r.path, p)) continue; + if (!best || pathDepth(r.path) > pathDepth(best.path)) best = r; + } + return best ? { access: best.access, rule: best } : { access: 'none' }; +} + +// ───────────────────────────── baseline presets ────────────────────────────── + +/** Home-relative entries shared by both platforms. Existence-filtered by the + * worker before compiling (a missing path has nothing to protect or expose). */ +function commonHomeBaseline(h: string): FsRule[] { + const ro = (p: string): FsRule => ({ path: p, access: 'readOnly', source: 'baseline' }); + const rw = (p: string): FsRule => ({ path: p, access: 'readWrite', source: 'baseline' }); + const deny = (p: string): FsRule => ({ path: p, access: 'deny', source: 'baseline' }); + return [ + // Shell/env init read by non-interactive subshells (the Bash tool sources + // .zshenv — botmux's lark-cli identity mapping lives there) + git identity. + ro(`${h}/.zshenv`), ro(`${h}/.zprofile`), ro(`${h}/.zshrc`), + ro(`${h}/.profile`), ro(`${h}/.bashrc`), ro(`${h}/.bash_profile`), + ro(`${h}/.gitconfig`), ro(`${h}/.config/git`), ro(`${h}/.gitignore_global`), ro(`${h}/.gitattributes`), + // Language toolchains / version managers / package caches commonly under + // $HOME (read+exec so python/perl/ruby/rust/go/node/java built or managed + // there just work — the deny-by-default read posture would otherwise hide a + // pyenv python or a cargo-built binary). READ-ONLY: the agent runs them, it + // doesn't need to mutate the toolchain. Credential files that live INSIDE a + // few of these (crates.io / rubygems / maven tokens) are re-denied DEEPER + // below so longest-prefix keeps them secret. + ro(`${h}/.fnm`), ro(`${h}/.nvm`), ro(`${h}/.volta`), ro(`${h}/.npm-global`), + ro(`${h}/.nodenv`), ro(`${h}/.yarn`), ro(`${h}/.pnpm`), ro(`${h}/.bun`), ro(`${h}/.deno`), + ro(`${h}/.pyenv`), ro(`${h}/Library/Python`), ro(`${h}/.local/lib`), ro(`${h}/.pipx`), + ro(`${h}/.rbenv`), ro(`${h}/.rvm`), ro(`${h}/.gem`), ro(`${h}/perl5`), ro(`${h}/.cpanm`), ro(`${h}/.cpan`), + ro(`${h}/.cargo`), ro(`${h}/.rustup`), + ro(`${h}/go`), ro(`${h}/.gvm`), ro(`${h}/.goenv`), + ro(`${h}/.sdkman`), ro(`${h}/.jenv`), ro(`${h}/.m2`), ro(`${h}/.gradle`), + ro(`${h}/.asdf`), ro(`${h}/.mise`), ro(`${h}/.plenv`), + ro(`${h}/.opam`), ro(`${h}/.ghcup`), ro(`${h}/.stack`), ro(`${h}/.nimble`), ro(`${h}/.pub-cache`), + ro(`${h}/.local/share`), ro(`${h}/.local/bin`), + // Toolchain credential files sitting inside the read-allowed dirs above — + // re-denied deeper (registry/publish tokens, maven server passwords). + deny(`${h}/.cargo/credentials`), deny(`${h}/.cargo/credentials.toml`), + deny(`${h}/.gem/credentials`), + deny(`${h}/.m2/settings.xml`), deny(`${h}/.m2/settings-security.xml`), + deny(`${h}/.gradle/gradle.properties`), + // Scratch/caches every CLI + spawned tool needs. + rw(`${h}/.cache`), rw(`${h}/.npm`), rw(`${h}/.local/state`), + // The daemon-written botmux wrapper (head of PATH) + skill plugin dir. + ro(`${h}/.botmux/bin`), ro(`${h}/.botmux/claude-plugin`), + // Crown jewels — most are already unreachable via deny-by-default; these + // explicit denies guard the ones that could fall under an allowed tree + // (workingDir = $HOME, a broad user readOnly, …). Defence-in-depth. + deny(`${h}/.ssh`), deny(`${h}/.aws`), deny(`${h}/.azure`), deny(`${h}/.gnupg`), + deny(`${h}/.netrc`), deny(`${h}/.config/gh`), deny(`${h}/.config/glab-cli`), + deny(`${h}/.config/gcloud`), deny(`${h}/.config/op`), deny(`${h}/.config/1Password`), + deny(`${h}/.1password`), deny(`${h}/.password-store`), deny(`${h}/.git-credentials`), + deny(`${h}/.npmrc`), deny(`${h}/.pypirc`), deny(`${h}/.docker/config.json`), deny(`${h}/.kube`), + deny(`${h}/.lark-cli`), + ]; +} + +function darwinBaseline(h: string): FsRule[] { + const ro = (p: string): FsRule => ({ path: p, access: 'readOnly', source: 'baseline' }); + const rw = (p: string): FsRule => ({ path: p, access: 'readWrite', source: 'baseline' }); + const deny = (p: string): FsRule => ({ path: p, access: 'deny', source: 'baseline' }); + return [ + ...commonHomeBaseline(h), + // System: dyld/frameworks/toolchain — broad readOnly + surgical deny holes. + ro('/System'), ro('/usr'), ro('/bin'), ro('/sbin'), ro('/Library'), ro('/opt'), + ro('/private/etc'), + ro('/private/var/select'), // /var/select/sh + ro('/private/var/db/timezone'), + ro('/private/var/run'), // resolv.conf + deny('/Library/Keychains'), + // Scratch: macOS per-user TMPDIR root + tmp family + devices. + rw('/dev'), rw('/private/tmp'), rw('/private/var/tmp'), rw('/private/var/folders'), + // ~/Library: CLIs need broad read (fonts/prefs/frameworks caches) and write + // into Caches + Application Support — with the secret stores denied DEEPER + // (longest-prefix beats the rw grant). + ro(`${h}/Library`), + rw(`${h}/Library/Caches`), + rw(`${h}/Library/Application Support`), + rw(`${h}/Library/Logs`), + deny(`${h}/Library/Keychains`), + // lark-cli's key store holds EVERY bot's app-secret ciphertext + master key + // (the known pre-refactor leak — see design doc §2.4). Deny the dir; the + // bot's OWN send path uses send-cred.json in BOT_HOME, not this store. + deny(`${h}/Library/Application Support/lark-cli`), + ]; +} + +function linuxBaseline(h: string): FsRule[] { + const ro = (p: string): FsRule => ({ path: p, access: 'readOnly', source: 'baseline' }); + const rw = (p: string): FsRule => ({ path: p, access: 'readWrite', source: 'baseline' }); + return [ + ...commonHomeBaseline(h), + // Toolchain + config. /bin,/lib*… on usrmerge distros are symlinks — the + // bwrap compiler replicates them (see CompileBwrapOpts.symlinks); on + // non-usrmerge hosts the worker passes them as existing dirs instead. + ro('/usr'), ro('/etc'), ro('/opt'), + // /run is a fresh tmpfs (compiler primitive); fnm/nvm farms under /run are + // re-exposed via ctx.execPaths. /tmp,/dev/shm,/var/tmp fresh tmpfs; /dev, + // /proc via bwrap primitives — all emitted by the compiler, not rules. + rw(`${h}/.cache`), rw(`${h}/.npm`), + ]; +} + +// ───────────────────────────── policy assembly ─────────────────────────────── + +/** + * Build the unified FsPolicy: baseline preset (platform) + adapter-declared + * paths + botmux-internal injections + user sandboxPaths (highest precedence). + * Pure — the worker canonicalizes and existence-filters all ctx paths first. + */ +export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy { + const candidates: FsRule[] = []; + const push = (paths: readonly string[] | undefined, access: FsAccess, source: FsRuleSource) => { + for (const p of paths ?? []) candidates.push({ path: p, access, source }); + }; + + candidates.push(...(ctx.platform === 'darwin' ? darwinBaseline(ctx.homeDir) : linuxBaseline(ctx.homeDir))); + + // Adapter-declared surfaces. + push(ctx.execPaths, 'readOnly', 'adapter'); + push(ctx.authPaths, 'readWrite', 'adapter'); + if (!ctx.redirectedCliData) push(ctx.cliDataPaths, 'readWrite', 'adapter'); + + // botmux internals. + push([ctx.workingDir, ctx.botHome], 'readWrite', 'internal'); + push(ctx.outbox ? [ctx.outbox] : [], 'readWrite', 'internal'); + push(ctx.extraWritePaths, 'readWrite', 'internal'); + push(ctx.readonlyRoots, 'readOnly', 'internal'); + // Own routing metadata (`botmux send` reply routing) — read-only. + push([`${ctx.sessionDataDir}/sessions-${ctx.currentAppId}.json`], 'readOnly', 'internal'); + // Own upload bucket — readWRITE: `botmux quoted` / downloadResources writes the + // downloaded attachment under attachments///… (not just reads + // pre-uploaded files). The worker mkdirs it pre-spawn so it survives the + // existence-filter and gets bound rw. Siblings' buckets stay uncovered. + push([`${ctx.sessionDataDir}/attachments/${ctx.currentAppId}`], 'readWrite', 'internal'); + // Own per-bot lark-cli config (agent-facing lark-cli identity). + push([`${ctx.homeDir}/.lark-cli-bots/${ctx.currentAppId}`], 'readWrite', 'internal'); + + // ── botmux CLI runtime surface (deny-by-default ALLOW-LIST) ── + // The agent runs `botmux …` and claude fires SessionStart / AskUserQuestion + // hooks that exec `node /dist/cli.js …`. They need the install dir + + // a SMALL set of ~/.botmux files. We do NOT expose ~/.botmux wholesale: it + // holds live credentials (config.json's voice accessKey/secretKey/apiKey, .env, + // data/webhook-master.key + webhook-secrets.json) and other bots' private + // content (data/schedules.json prompts+routing, sibling sessions/identities/ + // send-creds, connectors/federations/…). A readOnly umbrella + deny-list is + // the fragile "expose-then-blocklist" pattern this refactor exists to kill — + // and it fails OPEN for files created after spawn. So allow-list ONLY what the + // CLI/hooks genuinely read; everything else (incl. future files + all creds) + // stays denied by construction. (Verified against codex review 2026-07-16.) + const bh = ctx.botmuxHome, sd = ctx.sessionDataDir, app = ctx.currentAppId; + push(ctx.botmuxInstallRoot ? [ctx.botmuxInstallRoot] : [], 'readOnly', 'internal'); + push([ + `${bh}/.data-dir`, // resolves DATA_DIR location + `${bh}/.dashboard-port`, // dashboard port (owner term-link; harmless port int) + `${bh}/bin`, // the daemon-written `botmux` wrapper (head of PATH) + `${bh}/claude-plugin`, // skill/plugin dir (claude --plugin-dir); no secrets + `${bh}/lark-scopes.json`, // static scope catalog + `${sd}/dashboard-daemons`, // per-bot {ipcPort, resolvedAllowedUsers} — hook IPC discovery (operational, not secret) + `${sd}/bots-info.json`, // bot display names / avatars for + recipient rendering + `${sd}/bot-openids-${app}.json`, // OWN routing cross-ref (sibling ones stay denied) + // own sessions-.json + attachments/ already pushed above (readOnly) + ], 'readOnly', 'internal'); + // turn-sends: the CLI APPENDS a send-marker here (dedup); needs write, not read. + // Exposed readWrite (write-only isn't expressible); it holds only message-id + // markers (no content/creds) so read-exposure of the OWN-appended markers is benign. + push([`${sd}/turn-sends`], 'readWrite', 'internal'); + // schedules.json: `botmux schedule` is a READ-MODIFY-WRITE store shared by all + // bots (one file). It must be readWrite — a read-deny makes a sandboxed + // `botmux schedule` load an empty map and overwrite, wiping EVERY bot's tasks. + // This DOES expose other bots' task prompts+routing; accepted by the owner + // (王旭 2026-07-16) as the cost of the schedule feature — same call the old + // read-isolation made (schedules.json deliberately never denied). + push([`${sd}/schedules.json`], 'readWrite', 'internal'); + // macOS lark-cli key store carve-out. The baseline DENIES the whole + // `~/Library/Application Support/lark-cli` dir (it holds EVERY bot's appsecret + // ciphertext + the master key — the pre-refactor cross-bot leak). But this bot + // needs its OWN appsecret + the master key to decrypt it and authenticate + // (verified: without these `lark-cli auth scopes` fails "keychain Get failed … + // operation not permitted"). Re-allow ONLY those two, read-only, at a DEEPER + // path than the deny so longest-prefix-wins. Siblings' `appsecret_*.enc` and + // user tokens stay denied → the master key alone can't decrypt what it can't + // read (verified: sibling ciphertext still DENIED). Linux keeps its keys in the + // per-bot `.lark-cli-bots/` dir (already readWrite above), so this is + // darwin-only. + if (ctx.platform === 'darwin') { + const larkStore = `${ctx.homeDir}/Library/Application Support/lark-cli`; + push([`${larkStore}/master.key.file`, `${larkStore}/appsecret_${ctx.currentAppId}.enc`], 'readOnly', 'internal'); + } + + // User config — highest precedence. + push(ctx.userPaths?.readWrite, 'readWrite', 'user'); + push(ctx.userPaths?.readOnly, 'readOnly', 'user'); + push(ctx.userPaths?.deny, 'deny', 'user'); + + return { + rules: mergeFsRules(candidates), + net: ctx.net !== false, + writeRegexes: [...(ctx.writeRegexes ?? [])], + }; +} + +// ───────────────────────────── Seatbelt compiler ───────────────────────────── + +function escSb(p: string): string { + return p.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} +function escSbRegex(r: string): string { + return r.replace(/"/g, '.'); // a `"` would terminate the #"…" literal +} + +/** Every strict ancestor of every non-deny rule path. Under read-deny-default, + * realpath()/stat of an intermediate dir fails and crashes CLIs that + * canonicalize their config dir — so each ancestor needs file-read-metadata + * (literal, NOT subpath: no listing/enumeration is granted). */ +export function ancestorsNeedingTraverse(rules: readonly FsRule[]): string[] { + const out = new Set(); + for (const r of rules) { + if (r.access === 'deny') continue; + let p = r.path; + for (;;) { + const parent = p === '/' ? null : p.slice(0, p.lastIndexOf('/')) || '/'; + if (!parent) break; + out.add(parent); + if (parent === '/') break; + p = parent; + } + } + return [...out].sort((a, b) => pathDepth(a) - pathDepth(b) || (a < b ? -1 : 1)); +} + +/** Apple's stock base profile — sets up the process-bootstrap file reads + * (dyld shared cache, sysctl-backed system paths, mach services) that a raw + * `(deny file-read* (subpath "/"))` would block, aborting the process before + * main(). Empirically required: with a blanket read-deny even `/usr/bin/true` + * SIGABRTs at dyld; importing this base fixes bootstrap WITHOUT opening the + * disk (verified: /etc/hosts and $HOME stay denied under it). */ +const SEATBELT_BASE_PROFILE = '/System/Library/Sandbox/Profiles/bsd.sb'; + +/** + * Compile the policy to a macOS Seatbelt profile. `(deny default)` + Apple's + * bsd.sb base makes it deny-by-default for BOTH files and other operations, + * then we re-allow the non-file operation classes the CLI needs (process/mach/ + * ipc/sysctl/signal, network gated on policy.net) and the three file tiers + * emitted shallow→deep (Seatbelt applies the LAST matching rule → deepest rule + * wins, matching accessForPath()). + */ +export function compileToSeatbelt(policy: FsPolicy): string { + const lines = [ + '(version 1)', + '(deny default)', + `(import "${SEATBELT_BASE_PROFILE}")`, + // Non-file operation classes. `(allow default)` used to grant these wholesale; + // under `(deny default)` we re-grant exactly what a CLI + its subprocesses need. + '(allow process*)', + '(allow signal)', + '(allow mach*)', + '(allow ipc*)', + '(allow sysctl*)', + '(allow file-ioctl)', + ...(policy.net ? ['(allow network*)', '(allow system-socket)'] : []), + ]; + for (const r of policy.rules) { + const p = escSb(r.path); + if (r.access === 'deny') { + lines.push(`(deny file-read* (subpath "${p}"))`); + lines.push(`(deny file-write* (subpath "${p}"))`); + } else { + lines.push(`(allow file-read* (subpath "${p}"))`); + if (r.access === 'readWrite') lines.push(`(allow file-write* (subpath "${p}"))`); + else lines.push(`(deny file-write* (subpath "${p}"))`); // re-assert: deeper ro inside a rw tree must drop write + } + } + // Traversal metadata for ancestors of every allowed path — emitted AFTER the + // rules so a broad deny cannot clobber the narrow literal grants a nested + // allow needs (deny ~/x + readWrite ~/x/y: realpath must stat ~/x). Literal + // (not subpath): grants stat/readlink of the dir itself, never listing. + for (const p of ancestorsNeedingTraverse(policy.rules)) { + lines.push(`(allow file-read-metadata (literal "${escSb(p)}"))`); + } + for (const re of policy.writeRegexes) { + lines.push(`(allow file-write* (regex #"${escSbRegex(re)}"))`); + } + return lines.join('\n') + '\n'; +} + +// ───────────────────────────── bwrap compiler ──────────────────────────────── + +export interface CompileBwrapOpts { + /** Top-level symlinks to replicate inside the tmpfs root (usrmerge /bin → + * usr/bin etc.). Resolved by the worker (impure readlink). */ + symlinks?: readonly { path: string; target: string }[]; + /** Directory that will hold empty placeholder files for FILE-shaped deny + * rules (dirs are masked with tmpfs; files need an empty ro-bind source). + * The worker creates the returned `emptyFiles` before spawning. */ + emptiesDir: string; + /** Rule paths that are FILES (not dirs) on the host — deny compiles to an + * empty-file bind, readOnly/readWrite file binds work as-is. Resolved by + * the worker (impure stat). */ + filePaths?: ReadonlySet; + /** chdir target (the project working dir). */ + chdir: string; +} + +export interface BwrapCompilation { + /** bwrap argv prefix (caller appends --setenv pairs and `-- cli args`). */ + args: string[]; + /** Empty placeholder files the worker must create before spawn. */ + emptyFiles: { path: string; maskedPath: string }[]; +} + +/** + * Compile the policy to a bwrap argv prefix. Deny-by-default is bwrap's + * natural shape: a fresh tmpfs root, then ONLY the rule paths are bound in + * (later mounts win → emitting shallow→deep gives deepest-rule-wins, matching + * accessForPath()). deny rules materialize as tmpfs/empty-file masks and are + * only emitted when they sit under an exposed tree (outside it they're + * unreachable already, and bwrap would fail mounting onto a void path). + */ +export function compileToBwrap(policy: FsPolicy, opts: CompileBwrapOpts): BwrapCompilation { + const a: string[] = []; + const emptyFiles: { path: string; maskedPath: string }[] = []; + a.push('--tmpfs', '/'); + a.push('--proc', '/proc', '--dev', '/dev'); + a.push('--tmpfs', '/tmp', '--tmpfs', '/run', '--tmpfs', '/var/tmp', '--tmpfs', '/dev/shm'); + for (const s of opts.symlinks ?? []) a.push('--symlink', s.target, s.path); + + const exposed: string[] = []; // non-deny paths emitted so far (for deny reachability) + let emptyIdx = 0; + for (const r of policy.rules) { + if (r.access === 'deny') { + if (!exposed.some(e => coversPath(e, r.path))) continue; // unreachable → already denied by default + if (opts.filePaths?.has(r.path)) { + const empty = `${opts.emptiesDir}/mask-${emptyIdx++}`; + emptyFiles.push({ path: empty, maskedPath: r.path }); + a.push('--ro-bind', empty, r.path); + } else { + a.push('--tmpfs', r.path); + } + continue; + } + a.push(r.access === 'readWrite' ? '--bind' : '--ro-bind', r.path, r.path); + exposed.push(r.path); + } + + a.push('--unshare-user', '--unshare-pid', '--unshare-ipc', '--unshare-uts', '--unshare-cgroup-try'); + if (!policy.net) a.push('--unshare-net'); + a.push('--die-with-parent', '--new-session', '--chdir', opts.chdir); + return { args: a, emptyFiles }; +} + +// ───────────────────────────── legacy config migration ─────────────────────── + +export interface LegacySandboxFields { + sandbox?: boolean; + readIsolation?: boolean; + sandboxReadonlyPaths?: readonly string[]; + sandboxHidePaths?: readonly string[]; + readDenyExtraPaths?: readonly string[]; +} + +export interface MigratedSandboxFields { + sandbox: boolean; + sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; +} + +/** + * old→new field mapping (lossless: the old fields' expressiveness is a subset + * of the three-tier model). Used by the registry's load-time auto-migration, + * which writes the NEW fields while KEEPING the old ones in bots.json — a + * downgraded daemon reads the untouched old fields, so downgrade needs no + * reverse script (design doc §6.2). Returns null when nothing to migrate. + */ +export function migrateLegacySandboxFields(entry: LegacySandboxFields & { sandboxPaths?: unknown }): MigratedSandboxFields | null { + if (entry.sandboxPaths !== undefined) return null; // already on the new model + const hasLegacy = entry.readIsolation === true + || (entry.sandboxReadonlyPaths?.length ?? 0) > 0 + || (entry.sandboxHidePaths?.length ?? 0) > 0 + || (entry.readDenyExtraPaths?.length ?? 0) > 0; + const sandbox = entry.sandbox === true || entry.readIsolation === true; + if (!hasLegacy) return null; // plain `sandbox: true` needs no path migration + const readOnly = [...(entry.sandboxReadonlyPaths ?? [])]; + const deny = [...(entry.sandboxHidePaths ?? []), ...(entry.readDenyExtraPaths ?? [])]; + const sandboxPaths: MigratedSandboxFields['sandboxPaths'] = {}; + if (readOnly.length) sandboxPaths.readOnly = readOnly; + if (deny.length) sandboxPaths.deny = [...new Set(deny)]; + return { + sandbox, + ...(readOnly.length || deny.length ? { sandboxPaths } : {}), + }; +} diff --git a/src/adapters/cli/read-isolation.ts b/src/adapters/cli/read-isolation.ts index 9c04f6066..aa08e8d6d 100644 --- a/src/adapters/cli/read-isolation.ts +++ b/src/adapters/cli/read-isolation.ts @@ -97,398 +97,6 @@ export function buildCliExecutableReadCarveOuts(input: { return [standaloneRoot]; } -export interface V2IsolationContext { - /** The bot user's home directory. */ - homeDir: string; - /** BOTMUX_HOME root (e.g. `~/.botmux`). NOT denied wholesale — the botmux CLI the - * agent runs (`botmux send`/`list`/`status`) needs broad read access to it (config, - * daemon registry, pm2, session store). Only its cross-bot-SENSITIVE parts are denied. */ - botmuxHome: string; - /** botmux session data root (SESSION_DATA_DIR, e.g. `~/.botmux/data`). */ - sessionDataDir: string; - /** This bot's Feishu app id. All carve-outs are keyed on it (immutable, never the - * user-controllable cwd) — sibling data needs NO enumeration: per-bot dirs are - * denied WHOLESALE and per-bot session files by a filename-pattern regex. */ - currentAppId: string; - /** Per-bot extra deny paths (BotConfig.readDenyExtraPaths). */ - extraDenyPaths?: string[]; -} - -/** v2 DENY set (HYBRID). The F1 fix is a WHOLE-deny of the CLI data dirs `~/.claude` - * (+ `~/.claude.json`) and `~/.codex`: this bot's OWN CLI data is redirected into its - * BOT_HOME (readable) via CLAUDE_CONFIG_DIR/CODEX_HOME, so whole-denying the globals - * blocks the admin/other/pre-migration data + kills the per-cwd carve-out + /cd hole, - * while own resume/memory keep working from BOT_HOME. `~/.botmux` is NOT whole-denied - * (the agent's botmux CLI needs its config/registry/pm2/data structure) — instead its - * cross-bot-SENSITIVE parts are denied surgically: bots.json, logs, other bots' lark - * configs / session files / send-creds / BOT_HOMEs, and the shared conversation-content - * dirs (frozen-cards, queues, attachments, whiteboards, …). Own BOT_HOME + own - * session/cred + own attachments bucket + config/registry stay readable by default. - * Plus the system-credential stores. Everything else (code/repos/runtime) stays open. - * - * NOTE: per-bot session stores (`sessions-.json`) are denied by PATTERN — see - * {@link buildV2DenyRegexes} — so no sibling-appId enumeration is needed anywhere. */ -export function buildV2DenyPaths(ctx: V2IsolationContext): string[] { - const h = ctx.homeDir.replace(/\/+$/, ''); - const bh = ctx.botmuxHome.replace(/\/+$/, ''); - const sd = ctx.sessionDataDir.replace(/\/+$/, ''); - return dedupe( - [ - // ── F1: whole-deny the CLI data dirs (own is redirected to BOT_HOME) ── - `${h}/.claude`, - `${h}/.claude.json`, - `${h}/.codex`, - // ── System credential / secret stores (broad, since outside is open by default) ── - `${h}/.ssh`, - `${h}/.aws`, - `${h}/.azure`, - `${h}/.gnupg`, - `${h}/.netrc`, - `${h}/.config/gh`, - `${h}/.config/glab-cli`, - `${h}/.config/gcloud`, - `${h}/.config/op`, - `${h}/.config/1Password`, - `${h}/.1password`, - `${h}/.password-store`, - `${h}/.git-credentials`, - `${h}/.npmrc`, - `${h}/.pypirc`, - `${h}/.docker/config.json`, - `${h}/.kube`, - `${h}/Library/Keychains`, - // ── botmux SENSITIVE (surgical — leave config/registry/pm2/data structure readable) ── - `${bh}/bots.json`, // ALL bots' secrets - `${bh}/logs`, // daemon logs (cross-bot content) - `${h}/.lark-cli`, // default lark config (may hold creds) - // WHOLESALE-deny the per-bot lark config dir (same rationale as bots/ below): a - // newly-added bot is covered without cold-restart, and `ls .lark-cli-bots/` can't - // enumerate siblings. Own is re-allowed via buildV2CarveOuts (+ traverse shim). - `${h}/.lark-cli-bots`, - // WHOLESALE-deny the bots/ dir (every sibling BOT_HOME + their send-cred). Own is - // re-allowed via buildV2CarveOuts (allow subpath own + file-read-metadata traverse - // shim so the CLI can realpath its redirected config). Denying the DIR (not each - // sibling appId) means a NEWLY-ADDED bot is covered WITHOUT cold-restarting this - // one, and `ls bots/` can't enumerate siblings. - `${bh}/bots`, - `${sd}/sessions.json`, // legacy shared store - `${sd}/frozen-cards`, // conversation content (all bots') - `${sd}/turn-sends`, // CLI only APPENDS markers here — read-deny is safe - `${sd}/crash-diagnostics`, - // All bots' Feishu-uploaded files. Own per-appId bucket (attachments//) is - // re-allowed via buildV2CarveOuts; siblings' buckets + the legacy flat - // per-messageId layout stay denied. - `${sd}/attachments`, - // whiteboards/ is deliberately NOT denied (owner decision): whiteboards are - // shared content, meant to be visible across the bots that collaborate on - // them. NOTE the store is currently GLOBAL (no per-chat scoping), so a - // sandboxed bot can `whiteboard list`/`read` boards from other chats too — - // an accepted tradeoff until whiteboards are bucketed by chat/appId. Also - // un-denying the read avoids the read-modify-write clobber a deny would cause. - // Queued inbound messages (queues/.jsonl = full LarkMessage - // content for EVERY bot). Daemon-side only — the CLI never reads it. - `${sd}/queues`, - // Seatbelt profiles + reattach markers: filenames/rules enumerate sibling - // session ids and appIds. sandbox-exec parses the profile BEFORE applying it, - // and the markers are read by the daemon — the sandboxed CLI never reads these. - `${sd}/read-isolation`, - // NOTE: schedules.json is deliberately NOT denied. It's a read-modify-write - // store: denying the read makes a sandboxed `botmux schedule` load an empty - // map and then overwrite the shared file, silently wiping EVERY bot's tasks - // (read-modify-write degraded to write-only). We accept the minor leak - // (isolated bots can read others' scheduled prompts) until schedule-store - // fail-closes on read errors (ENOENT vs EPERM). See PR #387 review. - `${bh}/feishu-session.json`, // Feishu web login session (setup automation) — can mint bots - // The dashboard admin credentials. `.dashboard-secret` signs the loopback-HMAC - // that gates `/__cli/rotate` AND the daemon-IPC write-link routes — the ipc-server - // comment (dashboard-ipc-server.ts) explicitly relies on the sandbox hiding it to - // "keep a sandboxed worker from minting write tokens" for sessions it doesn't own - // (a cross-bot escalation reachable by a semi-trusted conversant driving the agent). - // Only owner-facing admin verbs (`botmux dashboard`/`term-link`) sign with it — the - // agent's send/list/status never do, so denying it costs the agent nothing. Its - // sibling `.dashboard-token` (dashboard bearer) is denied for the same reason; - // `.dashboard-port` is just a port number (no credential value) → left readable. - `${bh}/.dashboard-secret`, - `${bh}/.dashboard-token`, - // NOTE: extraDenyPaths (readDenyExtraPaths) are NOT here — they go to - // buildV2CarveOuts().finalDenyPaths so they win over the own-BOT_HOME allow. - ] - .map(normalizeIsolationPath) - .filter((p): p is string => !!p), - ); -} - -/** Escape a literal path for embedding in a Seatbelt regex filter. */ -function escapeForRegex(p: string): string { - return p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** v2 PATTERN denies — Seatbelt `regex` filters that deny a whole FILENAME CLASS - * instead of enumerating sibling app ids. Covers: - * - per-bot session stores (`/sessions-.json`, routing metadata): a - * newly-added bot's store is denied WITHOUT cold-restarting the ones already - * running, and no caller needs to know the sibling app ids. The bot's OWN store - * is re-allowed by carve-out (Seatbelt last-match). - * - EVERY `bots.json.*` sidecar (`.bak` written by setup/migration, `.tmp`, ad-hoc - * `.bak.` copies): the exact `bots.json` is subpath-denied in - * {@link buildV2DenyPaths}, but its backups carry the SAME plaintext - * larkAppSecret for every bot under a DIFFERENT basename, which `subpath` does - * not match — so without this an isolated bot could `cat bots.json.bak` and - * recover all siblings' credentials, defeating the whole isolation. */ -export function buildV2DenyRegexes(ctx: V2IsolationContext): string[] { - const sd = ctx.sessionDataDir.replace(/\/+$/, ''); - const bh = ctx.botmuxHome.replace(/\/+$/, ''); - return [ - `^${escapeForRegex(sd)}/sessions-[^/]+\\.json$`, - // Any `bots.json.` sidecar (backups/temp) — trailing dot so it matches - // bots.json.bak / .tmp / .bak. but NOT the exact bots.json (that is - // the subpath deny's job) and NOT an unrelated `bots.jsonx`. - `^${escapeForRegex(bh)}/bots\\.json\\.`, - // LEGACY pre-BOT_HOME send-cred files (`/.send-cred-`): current - // builds write send-cred.json inside BOT_HOME, but leftovers from older - // builds still carry live per-bot send credentials at the data root. - `^${escapeForRegex(sd)}/\\.send-cred-`, - // Per-bot user display-name caches (`identities-.json`): open_id→name - // PII of every bot's interlocutors. The CLI never reads them (daemon-side - // prompt injection only), so the OWN file gets NO carve-out — the whole - // filename class is denied. - `^${escapeForRegex(sd)}/identities-[^/]+\\.json$`, - ]; -} - -/** The v2 Seatbelt carve-outs that accompany {@link buildV2DenyPaths} + - * {@link buildV2DenyRegexes}. They re-open the bot's OWN slice of each denied class: - * - allowPaths: the own BOT_HOME subpath (redirected CLI data + creds + send-cred), - * the own lark-cli config dir, and the own session store file. - * - traverseDirs: `bots/` etc., as file-read-metadata ONLY — lets the CLI realpath() - * its config dir THROUGH the denied parent WITHOUT allowing `ls bots/` (enumeration). - * - finalDenyPaths: admin `readDenyExtraPaths`, emitted AFTER the allows so an admin - * deny UNDER the own BOT_HOME still wins (Seatbelt last-match). */ -export function buildV2CarveOuts(ctx: V2IsolationContext): { - allowPaths: string[]; - traverseDirs: string[]; - finalDenyPaths: string[]; -} { - const bh = ctx.botmuxHome.replace(/\/+$/, ''); - const sd = ctx.sessionDataDir.replace(/\/+$/, ''); - const h = ctx.homeDir.replace(/\/+$/, ''); - const self = assertSafeAppId(ctx.currentAppId); - return { - // Re-open the bot's OWN slice of each wholesale/pattern-denied per-bot class. - allowPaths: [ - botHomePath(bh, self), - `${h}/.lark-cli-bots/${self}`, - // Own routing metadata (`botmux send` reads it to route replies); siblings' - // stay denied by the buildV2DenyRegexes filename pattern. - `${sd}/sessions-${self}.json`, - // Own Feishu-upload bucket (attachments///…) — the agent must - // read files the user uploads in chat. getAttachmentsDir keys the bucket on the - // appId precisely so this spawn-time-static carve-out can exist. - `${sd}/attachments/${self}`, - ], - // file-read-metadata on the wholesale-denied parents so the CLI/skill can realpath() - // through them WITHOUT `ls` (enumeration) leaking. - traverseDirs: [`${bh}/bots`, `${h}/.lark-cli-bots`, `${sd}/attachments`], - finalDenyPaths: (ctx.extraDenyPaths ?? []) - .map(normalizeIsolationPath) - .filter((p): p is string => !!p), - }; -} - -// ─── Mac file-sandbox: WRITE isolation (the Seatbelt twin of Linux bwrap) ───── - -export interface WriteSandboxContext { - /** The bot user's home directory. */ - homeDir: string; - /** BOTMUX_HOME root (e.g. `~/.botmux`). */ - botmuxHome: string; - /** botmux session data root (SESSION_DATA_DIR, e.g. `~/.botmux/data`). */ - sessionDataDir: string; - /** The session's project working dir — writes here PERSIST (the point of the sandbox). */ - workingDir: string; - /** This bot's Feishu app id (keys its own BOT_HOME carve-out). */ - currentAppId: string; - /** Extra writable roots the worker resolves at spawn time (realpath'd TMPDIR, - * extra worktrees, admin-configured writable paths). */ - extraWritePaths?: string[]; -} - -/** - * Write-isolation rules for the macOS file sandbox — the FUNCTIONAL twin of the - * Linux bwrap overlay (which lets the agent read the whole real FS but confines - * WRITES). Seatbelt has no copy-on-write overlay, so we approximate the same - * guarantee with a deny-all-writes + allow-list: the agent can write its project - * and the ephemeral scratch/cache the CLI needs, but CANNOT tamper the rest of - * your real disk (home dotfiles, other projects, other bots' data, system dirs). - * Reads stay wide open (like Linux) — this is orthogonal to read isolation, which - * layers its own read-deny set into the SAME profile when also enabled. - * - * `allowWritePaths` re-open the writable zones AFTER the profile's `(deny - * file-write* (subpath "/"))`; `denyWritePaths` are crown jewels re-denied AFTER - * the allows (Seatbelt last-match), so they stay protected even if the project or - * an allowed zone nests them. The allow-list is intentionally generous toward - * CLI scratch/cache — the exact set is empirically tuned on real macOS (a missing - * cache path makes the CLI fail, an over-broad one weakens the sandbox). Pure: the - * worker realpath's every path (symlink-safe) before emitting. - */ -export function buildWriteSandboxRules(ctx: WriteSandboxContext): { - allowWritePaths: string[]; - allowWriteRegexes: string[]; - denyWritePaths: string[]; -} { - const h = ctx.homeDir.replace(/\/+$/, ''); - const bh = ctx.botmuxHome.replace(/\/+$/, ''); - const wd = ctx.workingDir.replace(/\/+$/, ''); - const keep = (arr: string[]) => - dedupe(arr.map(normalizeIsolationPath).filter((p): p is string => !!p)); - return { - allowWritePaths: keep([ - // The project — writes here PERSIST (no overlay/landing step; direct like a - // non-sandboxed run would, only nothing OUTSIDE the allow-list can be touched). - wd, - // Own BOT_HOME — where read isolation redirects the CLI's data when co-enabled; - // harmless to allow when write-sandbox is standalone (dir just won't exist). - botHomePath(bh, ctx.currentAppId), - // CLI data dirs (write-sandbox standalone leaves them at the real path). - `${h}/.claude`, `${h}/.claude.json`, `${h}/.codex`, - // Claude Code's updater/OAuth refresh uses sibling lock directories rather - // than placing every state file under ~/.claude. Without these, a valid - // login can fail to refresh because lock acquisition returns EPERM. - `${h}/.claude.lock`, `${h}/.claude.json.lock`, - `${h}/.local/state/claude`, - // Ephemeral scratch / caches every CLI + spawned tool (git, npm, node) needs. - `${h}/Library/Caches`, - `${h}/Library/Application Support`, - `${h}/.cache`, - `${h}/.npm`, - '/private/var/folders', // macOS per-user TMPDIR / DARWIN_USER_TEMP_DIR root - '/private/tmp', '/tmp', '/var/tmp', - '/dev', // ptys, /dev/null, /dev/tty — required to run at all - ...(ctx.extraWritePaths ?? []), - ]), - allowWriteRegexes: [ - // Claude Code saves ~/.claude.json atomically through a PID/random-suffixed - // sibling (e.g. .claude.json.tmp.1234.abcd). A subpath rule for the exact - // state file cannot match those siblings, so allow only that basename class - // at the home root — not arbitrary home dotfiles. - `^${escapeForRegex(h)}/\\.claude\\.json\\.tmp\\.[^/]+$`, - ], - denyWritePaths: keep([ - // Crown jewels — re-denied AFTER the allows so a semi-trusted operator can't - // plant an ssh key or tamper another bot's creds even if the project/home - // nests these. (Most already fall under deny-by-default; this is the guard for - // a broad workingDir and defence-in-depth.) - `${h}/.ssh`, `${h}/.aws`, `${h}/.gnupg`, - `${bh}/bots.json`, - `${bh}/feishu-session.json`, - `${bh}/.dashboard-secret`, `${bh}/.dashboard-token`, - ]), - }; -} - -// ─── Linux read isolation: bwrap mask set (the Seatbelt read-deny twin) ────── - -export interface LinuxReadIsolationInput { - ctx: V2IsolationContext; - /** Every OTHER bot's appId — the worker enumerates these from the registry. - * bwrap has NO regex (unlike the macOS Seatbelt profile), so each sibling's - * per-bot paths are masked INDIVIDUALLY. Consequence: the mask set is - * spawn-time-static — a bot added AFTER this session spawned isn't covered - * until a cold restart (the macOS regex covers new bots without one). */ - siblingAppIds: string[]; - /** Existing `bots.json.*` sidecars (backups/tmp) globbed by the worker at spawn - * — each carries every bot's plaintext secret under a dynamic name that no - * wholesale rule matches, so they're masked individually. */ - botsJsonSidecars?: string[]; -} - -/** - * The bwrap MASK set for Linux read isolation — the functional twin of the macOS - * Seatbelt read-deny profile ({@link buildV2DenyPaths} + regexes + carve-outs), - * expressed for bwrap (which blanks paths with tmpfs/empty-binds and has no regex). - * - * SAME cross-bot-sensitive set as macOS, but the per-bot classes (other bots' - * BOT_HOMEs / lark configs / session stores / identities / send-creds / attachment - * buckets) are enumerated PER SIBLING instead of wholesale-denied + regex-matched. - * The bot's OWN slice is simply never masked (so it stays readable through the - * overlay), and its BOT_HOME is additionally bound real+writable via - * `ownReadWritePaths` so its redirected CLI data persists — matching macOS. - * - * Pure: the worker resolves the impure inputs (sibling list, sidecar glob, realpath). - * NOTE: keep the `shared` set in lock-step with {@link buildV2DenyPaths} — the - * `read-isolation.test.ts` parity test fails if a sensitive path is added to one - * platform but not the other. - */ -export function buildLinuxReadIsolationMasks(input: LinuxReadIsolationInput): { - /** Paths to blank inside bwrap (prepareSandbox stat-classifies: dir→tmpfs, - * file→empty ro-bind, missing→skipped). Feed as prepareSandbox.hidePaths. */ - hidePaths: string[]; - /** The bot's OWN BOT_HOME — bound REAL + writable (feed as authPaths) so the - * redirected CLI data (CLAUDE_CONFIG_DIR/CODEX_HOME) persists and escapes the - * write overlay. Its parent `bots/` is enumerated per-sibling (NOT wholesale- - * masked) so this real bind survives. */ - ownReadWritePaths: string[]; - /** The bot's OWN slices that sit UNDER a wholesale-masked parent and must be - * re-exposed read-only AFTER the masks (feed as readonlyRoots — bound after the - * tmpfs masks). Currently the own attachments bucket, under the wholesale - * `data/attachments` mask. Read-only is enough (the agent only READS uploads). */ - ownReadOnlyPaths: string[]; -} { - const { ctx } = input; - const h = ctx.homeDir.replace(/\/+$/, ''); - const bh = ctx.botmuxHome.replace(/\/+$/, ''); - const sd = ctx.sessionDataDir.replace(/\/+$/, ''); - const self = assertSafeAppId(ctx.currentAppId); - const keep = (arr: string[]) => dedupe(arr.map(normalizeIsolationPath).filter((p): p is string => !!p)); - - // Cross-bot-sensitive paths handled WHOLESALE (not per-bot, OR a per-bot dir with - // a non-enumerable layout) — MUST mirror the same entries in buildV2DenyPaths. - const shared = [ - `${h}/.claude`, `${h}/.claude.json`, `${h}/.codex`, - `${h}/.ssh`, `${h}/.aws`, `${h}/.azure`, `${h}/.gnupg`, `${h}/.netrc`, - `${h}/.config/gh`, `${h}/.config/glab-cli`, `${h}/.config/gcloud`, `${h}/.config/op`, - `${h}/.config/1Password`, `${h}/.1password`, `${h}/.password-store`, - `${h}/.git-credentials`, `${h}/.npmrc`, `${h}/.pypirc`, `${h}/.docker/config.json`, `${h}/.kube`, - `${h}/Library/Keychains`, - `${bh}/bots.json`, `${bh}/logs`, `${h}/.lark-cli`, - `${bh}/feishu-session.json`, `${bh}/.dashboard-secret`, `${bh}/.dashboard-token`, - `${sd}/sessions.json`, `${sd}/frozen-cards`, `${sd}/turn-sends`, - `${sd}/crash-diagnostics`, `${sd}/queues`, `${sd}/read-isolation`, - // attachments/ is masked WHOLESALE (like macOS) — covers every sibling bucket - // AND the legacy flat per-messageId layout (which per-sibling enumeration can't - // reach); the OWN bucket is re-exposed read-only via ownReadOnlyPaths below. - `${sd}/attachments`, - // schedules.json + whiteboards deliberately NOT masked (same owner decision as - // macOS: RMW-clobber / shared content). - ]; - - // Per-sibling enumeration for the classes macOS covers by REGEX (bwrap has none): - // other bots' BOT_HOMEs, lark configs, session stores, identities, send-creds. Own - // BOT_HOME + lark + session stay UNMASKED (readable via the overlay lower; BOT_HOME - // also bound real+writable below). identities/send-cred get NO own carve-out — the - // CLI never reads them (daemon-side) — so the own ones are masked too. - const perBot: string[] = []; - for (const raw of input.siblingAppIds) { - let sib: string; - try { sib = assertSafeAppId(raw); } catch { continue; } - if (sib === self) continue; - perBot.push( - `${bh}/bots/${sib}`, - `${h}/.lark-cli-bots/${sib}`, - `${sd}/sessions-${sib}.json`, - `${sd}/identities-${sib}.json`, - `${sd}/.send-cred-${sib}`, - ); - } - perBot.push(`${sd}/identities-${self}.json`, `${sd}/.send-cred-${self}`); - - return { - hidePaths: keep([...shared, ...perBot, ...(input.botsJsonSidecars ?? [])]), - ownReadWritePaths: keep([botHomePath(bh, self)]), - ownReadOnlyPaths: keep([`${sd}/attachments/${self}`]), - }; -} - /** * Decide whether read isolation is enabled for a session, or fail-closed. * Pure: the caller resolves the impure inputs. This is the SINGLE decision @@ -523,61 +131,6 @@ export function evaluateReadIsolationGate(opts: { return { enabled: true }; } -/** - * macOS Seatbelt (sandbox-exec) profile for the whole-process isolation wrapper. - * Blocklist: allow everything, deny reads of the sensitive paths/patterns, THEN - * re-allow the carve-outs — `subpath` covers a file or a whole subtree. Rule ORDER - * matters: Seatbelt applies the LAST matching rule, so an allow listed AFTER a - * broader deny re-opens that subpath, and the final denies win over the allows. - * Verified: reads of denied paths fail EPERM; carved-out subpaths read normally; the - * wrapped CLI (which bypasses its OWN sandbox — nested Seatbelt would hang) runs fine. - */ -export function buildSeatbeltProfile( - denyPaths: string[], - allowPaths: string[] = [], - finalDenyPaths: string[] = [], - traverseDirs: string[] = [], - denyRegexes: string[] = [], - /** When set, layer the macOS file-sandbox WRITE isolation (Linux-bwrap twin) into - * the SAME profile: deny all writes, re-allow the writable zones, then final-deny - * the crown jewels. Reads are unaffected. Omit for read-isolation-only sessions. */ - writeSandbox?: { allowWritePaths: string[]; allowWriteRegexes?: string[]; denyWritePaths: string[] }, -): string { - const esc = (p: string) => p.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - // Regex literals (#"…") pass backslashes RAW to the regex engine — do NOT - // double-escape them. A `"` would terminate the literal; swap it for `.` - // (single-char wildcard that still matches the quote) instead of breaking syntax. - const escRe = (r: string) => r.replace(/"/g, '.'); - const lines = ['(version 1)', '(allow default)']; - for (const p of denyPaths) lines.push(`(deny file-read* (subpath "${esc(p)}"))`); - // Filename-pattern denies (e.g. every bot's sessions-.json) — no sibling - // enumeration; the own file is re-opened by an allow below (last-match). - for (const r of denyRegexes) lines.push(`(deny file-read* (regex #"${escRe(r)}"))`); - // Traversal metadata-allows: a carve-out under a DENIED parent (e.g. BOT_HOME under - // ~/.botmux) is reachable for a plain open (Seatbelt matches the final path), but a - // realpath()/stat of an INTERMEDIATE dir is denied — which crashes CLIs that - // canonicalize their config dir (Codex: "failed to canonicalize CODEX_HOME"). Allow - // read-METADATA (stat/traverse) on those specific ancestor DIRS only (literal, not - // subpath) — dir LISTING (read-data) stays denied, so no enumeration leak. - for (const p of traverseDirs) lines.push(`(allow file-read-metadata (literal "${esc(p)}"))`); - // Carve-outs override the broad denies above (Seatbelt applies the LAST match). - for (const p of allowPaths) lines.push(`(allow file-read* (subpath "${esc(p)}"))`); - // FINAL denies win over the carve-outs — admin `readDenyExtraPaths` must hold even - // for a path that falls under the bot's own re-allowed BOT_HOME. - for (const p of finalDenyPaths) lines.push(`(deny file-read* (subpath "${esc(p)}"))`); - // ── Write isolation (macOS file sandbox): deny ALL writes, re-allow the writable - // zones, then re-deny the crown jewels. Ordered AFTER the read rules but they are - // an independent operation class (file-write* vs file-read*), so they never - // interact with the read allow/deny above. Emitted only when write-sandbox is on. - if (writeSandbox) { - lines.push('(deny file-write* (subpath "/"))'); - for (const p of writeSandbox.allowWritePaths) lines.push(`(allow file-write* (subpath "${esc(p)}"))`); - for (const r of writeSandbox.allowWriteRegexes ?? []) lines.push(`(allow file-write* (regex #"${escRe(r)}"))`); - for (const p of writeSandbox.denyWritePaths) lines.push(`(deny file-write* (subpath "${esc(p)}"))`); - } - return lines.join('\n') + '\n'; -} - /** * Decide whether a live persistent pane (tmux/zellij/herdr) may be reattached for * an isolated bot. Isolation is injected at CLI *spawn* time (the Seatbelt diff --git a/src/bot-registry.ts b/src/bot-registry.ts index de33156d9..2d161e2fe 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -510,25 +510,29 @@ export interface BotConfig { */ disableCliBypass?: boolean; /** - * Run this bot's CLI inside a per-session file sandbox (bubblewrap, Linux): - * the agent sees only a clone of the project + a de-identified config dir, - * never the host home/secrets/other sessions. Intended for oncall bots shared - * with semi-trusted users. Linux-only; ignored elsewhere. Env BOTMUX_SANDBOX=1 - * forces it on regardless (testing). + * Run this bot's CLI inside a per-session file sandbox (unified three-tier + * whitelist, deny-by-default; Linux bwrap + macOS Seatbelt with identical + * semantics — see adapters/cli/fs-policy.ts). The agent can read/write the + * project + its own BOT_HOME, read the system toolchain baseline, and touch + * NOTHING else. Env BOTMUX_SANDBOX=1 forces it on regardless (testing). */ sandbox?: boolean; /** - * Per-bot privacy masks for the sandbox: absolute paths blanked inside the - * overlay sandbox (dirs → empty tmpfs; files → empty placeholder). OPT-IN with - * NO defaults — the agent reads the entire real fs natively unless a path is - * listed here. Only meaningful when `sandbox` is true. Linux-only. + * User增量 three-tier path lists layered ON TOP of the baseline preset + * (never replacing it). Deepest matching rule wins, so nested black/white + * lists work (readOnly a tree, deny a subdir inside it). Same semantics on + * Linux and macOS. Absent → pure baseline. */ - sandboxHidePaths?: string[]; + sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; /** - * Extra paths to expose read-only inside the sandbox. Useful when a bot should - * inspect sibling/source repos without being able to write to them. Only - * meaningful when `sandbox` is true. Linux-only. + * LEGACY (pre fs-policy, kept for downgrade only): privacy masks under the + * old read-everything model. Auto-migrated into sandboxPaths.deny at daemon + * startup (old fields are kept on disk so a downgraded daemon still reads + * them); no longer consulted by the new spawn path. */ + sandboxHidePaths?: string[]; + /** LEGACY: extra read-only paths — auto-migrated into sandboxPaths.readOnly + * (see sandboxHidePaths note). */ sandboxReadonlyPaths?: string[]; /** * Whether the sandbox keeps network access. Missing/true preserves the existing @@ -537,18 +541,13 @@ export interface BotConfig { */ sandboxNetwork?: boolean; /** - * Per-bot LOCAL READ ISOLATION (distinct from the Linux bwrap `sandbox` - * above). When true, the bot's CLI data is redirected into its own BOT_HOME - * and the whole CLI process is wrapped in a macOS Seatbelt sandbox, so its - * agent cannot read OTHER bots' session data / lark-cli credentials / the - * full bots.json / common host credentials. Only honored on CLIs whose - * adapter reports `supportsReadIsolation` and on macOS; a bot that sets this - * where it cannot be enforced is fail-closed (refused) rather than run - * unisolated. Default false → no behavior change. + * LEGACY read-isolation flag (pre fs-policy). The unified sandbox is + * deny-by-default, so cross-bot read isolation is inherent — this flag is + * auto-migrated to `sandbox: true` at daemon startup and kept on disk only + * for downgrade. No longer consulted by the new spawn path. */ readIsolation?: boolean; - /** Extra absolute paths to deny reading, appended to the built-in default - * credential set. Only meaningful when `readIsolation` is true. */ + /** LEGACY: extra read-deny paths — auto-migrated into sandboxPaths.deny. */ readDenyExtraPaths?: string[]; backendType?: BackendType; /** @@ -1149,6 +1148,15 @@ function botsConfigDiskPath(): string | null { * result into {@link brandFooterSegment} for the unset→default / ''→off rule. */ export function resolveBrandLabel(larkAppId: string): string | undefined { + // A sandboxed one-shot `botmux send` can't read bots.json (deny-by-default), + // so it has no in-memory registry and would fall through to a bots.json read + // that EPERMs → role footer lost. The worker injects THIS bot's resolved + // brandLabel via env; honour it first (gated on the own appId). Present-but- + // empty ('') = suppress; absent → fall through. brandLabel is a cosmetic + // markdown template, not a secret, so env-passing is safe. + if (process.env.BOTMUX_LARK_APP_ID === larkAppId && 'BOTMUX_BRAND_LABEL' in process.env) { + return process.env.BOTMUX_BRAND_LABEL; + } const inMem = bots.get(larkAppId); if (inMem) return inMem.config.brandLabel; const path = loadedConfigPath ?? botsConfigDiskPath(); @@ -1420,6 +1428,13 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { : undefined, disableCliBypass: entry.disableCliBypass === true, sandbox: entry.sandbox === true, + sandboxPaths: entry.sandboxPaths && typeof entry.sandboxPaths === 'object' && !Array.isArray(entry.sandboxPaths) + ? { + readWrite: normalizeStringList(entry.sandboxPaths.readWrite), + readOnly: normalizeStringList(entry.sandboxPaths.readOnly), + deny: normalizeStringList(entry.sandboxPaths.deny), + } + : undefined, sandboxHidePaths: normalizeStringList(entry.sandboxHidePaths), sandboxReadonlyPaths: normalizeStringList(entry.sandboxReadonlyPaths), sandboxNetwork: typeof entry.sandboxNetwork === 'boolean' ? entry.sandboxNetwork : undefined, diff --git a/src/cli.ts b/src/cli.ts index 4b694057a..b93ddae06 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2786,25 +2786,31 @@ function loadSessions(): Map { } catch { /* ignore */ } } - // Read per-bot session files (sessions-{appId}.json) + // Read per-bot session files (sessions-{appId}.json). + const loadPerBot = (appId: string) => { + try { + const data = JSON.parse(readFileSync(join(dataDir, `sessions-${appId}.json`), 'utf-8')); + for (const [, v] of Object.entries(data)) { + const session = v as SessionData; + if (!session.sessionId) continue; + if (!session.larkAppId) session.larkAppId = appId; // stamp so saveSession writes back correctly + sessions.set(session.sessionId, session); + } + } catch { /* ignore */ } + }; try { for (const file of readdirSync(dataDir)) { if (file.startsWith('sessions-') && file.endsWith('.json')) { - try { - // Extract appId from filename: sessions-{appId}.json - const appId = file.slice('sessions-'.length, -'.json'.length); - const data = JSON.parse(readFileSync(join(dataDir, file), 'utf-8')); - for (const [, v] of Object.entries(data)) { - const session = v as SessionData; - if (!session.sessionId) continue; - // Stamp larkAppId so saveSession writes back to the correct file - if (!session.larkAppId) session.larkAppId = appId; - sessions.set(session.sessionId, session); - } - } catch { /* ignore */ } + loadPerBot(file.slice('sessions-'.length, -'.json'.length)); } } - } catch { /* ignore */ } + } catch { + // readdir(dataDir) EPERMs under the file sandbox (the deny-by-default policy + // exposes sessions-.json but NOT a listing of data/). Fall back to the + // OWN per-bot file directly via the injected appId — the sandboxed agent only + // ever operates its own session, so no enumeration is needed. + if (process.env.BOTMUX_LARK_APP_ID) loadPerBot(process.env.BOTMUX_LARK_APP_ID); + } // Migrate: remove sessions from legacy file if they have larkAppId (belong in per-bot files) let legacyDirty = false; diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 2bb0c0954..79eb8e73e 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -14,8 +14,7 @@ import * as scheduler from './scheduler.js'; import { scanProjects, scanMultipleProjects, describeProjectDir } from '../services/project-scanner.js'; import { createRepoWorktree } from '../services/git-worktree.js'; import { worktreeSlugFromContextAI } from '../services/worktree-slug-ai.js'; -import { buildRepoSelectCard, buildAdoptSelectCard, buildCodexAppThreadSelectCard, buildSlashListCard, getCliDisplayName, buildConfigCard, buildLandCard } from '../im/lark/card-builder.js'; -import { computeSandboxDiff } from '../services/sandbox-land.js'; +import { buildRepoSelectCard, buildAdoptSelectCard, buildCodexAppThreadSelectCard, buildSlashListCard, getCliDisplayName, buildConfigCard } from '../im/lark/card-builder.js'; import { handleDashboardCommand } from './dashboard-command/index.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import type { CliId, ResumableSession } from '../adapters/cli/types.js'; @@ -1246,41 +1245,6 @@ export async function handleCommand( break; } - case '/land': { - // 把沙盒会话副本里 agent 的改动落回真实仓库。owner 审阅 diff 卡后点「应用到磁盘」。 - // agent 在沙盒里无感(以为改的就是真文件),所以只能由 owner 在此手动触发。 - if (!ds) { await sessionReply(rootId, t('cmd.no_active_session', undefined, loc)); break; } - const sid = ds.session.sessionId; - const wd = ds.session.workingDir; - if (!wd) { await sessionReply(rootId, t('cmd.land.no_workingdir', undefined, loc)); break; } - const d = computeSandboxDiff(config.session.dataDir, sid, loc); - if (!d.ok) { await sessionReply(rootId, t('cmd.land.cannot', { error: d.error }, loc)); break; } - if (d.empty) { await sessionReply(rootId, t('cmd.land.empty', undefined, loc)); break; } - // In-card preview: cap by lines AND chars (Lark card size limit); the FULL - // diff goes to an attached .patch file (better for large changesets). - const MAX_LINES = 60, MAX_CHARS = 4000; - const allLines = d.patch.split('\n'); - let preview = allLines.slice(0, MAX_LINES).join('\n'); - let truncated = allLines.length > MAX_LINES; - if (preview.length > MAX_CHARS) { preview = preview.slice(0, MAX_CHARS); truncated = true; } - // Attach the full .patch (git apply-able) — sent as a file message first, - // then the review card below it. - let patchAttached = false; - if (larkAppId) { - try { - const patchName = `botmux-land-${sid.slice(0, 8)}.patch`; - const patchPath = join(config.session.dataDir, 'sandboxes', sid, patchName); - writeFileSync(patchPath, d.patch); - const fileKey = await uploadFile(larkAppId, patchPath); - await sendMessage(larkAppId, ds.session.chatId, JSON.stringify({ file_key: fileKey }), 'file'); - patchAttached = true; - } catch (e) { logger.warn(`[${logTag}] /land patch attach failed: ${(e as Error).message}`); } - } - const card = buildLandCard({ sessionId: sid, workingDir: wd, statText: d.statText, files: d.files, insertions: d.insertions, deletions: d.deletions, preview, truncated, patchAttached }, loc); - await sessionReply(rootId, card, 'interactive'); - logger.info(`[${logTag}] /land: ${d.files} files (+${d.insertions}/-${d.deletions}) → card${patchAttached ? ' + .patch' : ''}`); - break; - } case '/detach': case '/disconnect': { diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index cb390d368..3c0c9a2f3 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -51,7 +51,6 @@ import { findConfigField, applyConfigField, coerceConfigValue } from '../service import { globalBuiltinSkillInjectionDefault, resolveSkillInjectionSupport } from '../skills/injection-mode.js'; import { summaryRangeFromBotConfig, updateDashboardSummaryRange } from '../services/summary-range-store.js'; import { config } from '../config.js'; -import { computeSandboxDiff, applySandboxDiff } from '../services/sandbox-land.js'; import { buildSafeInsightConversation, buildSafeInsightOverview, buildSafeInsightReport, buildSafeInsightTurnDetail } from '../services/insight/report.js'; import type { InsightConversationRole, InsightDetail, InsightSeverity, SafeSpanTag } from '../services/insight/types.js'; import { readRawConfig, findEntryIndex, requireConfigPath, rmwBotEntry } from '../services/config-store.js'; @@ -749,28 +748,6 @@ function workingDirForSession(sessionId: string): string | undefined { return sessionStore.listSessions().find(s => s.sessionId === sessionId)?.workingDir; } -ipcRoute('GET', '/api/sessions/:sessionId/sandbox-diff', (_req, res, params) => { - const d = computeSandboxDiff(config.session.dataDir, params.sessionId, localeForBot(cachedLarkAppId)); - if (!d.ok) return jsonRes(res, 200, { ok: false, error: d.error }); - jsonRes(res, 200, { - ok: true, empty: d.empty, files: d.files, insertions: d.insertions, deletions: d.deletions, - statText: d.statText, patch: d.patch, workingDir: workingDirForSession(params.sessionId) ?? null, - }); -}); - -ipcRoute('POST', '/api/sessions/:sessionId/sandbox-land/:action', (_req, res, params) => { - if (params.action === 'discard') return jsonRes(res, 200, { ok: true, discarded: true }); - if (params.action !== 'apply') return jsonRes(res, 400, { ok: false, error: 'unknown action' }); - const locLand = localeForBot(cachedLarkAppId); - const wd = workingDirForSession(params.sessionId); - if (!wd) return jsonRes(res, 404, { ok: false, error: t('sandbox.workingdir_not_found', undefined, locLand) }); - const d = computeSandboxDiff(config.session.dataDir, params.sessionId, locLand); - if (!d.ok) return jsonRes(res, 200, { ok: false, error: d.error }); - if (d.empty) return jsonRes(res, 200, { ok: false, error: t('sandbox.no_changes_left', undefined, locLand) }); - const a = applySandboxDiff(wd, config.session.dataDir, params.sessionId, locLand); - jsonRes(res, 200, a.ok ? { ok: true, files: d.files, insertions: d.insertions, deletions: d.deletions, workingDir: wd } : { ok: false, error: a.error }); -}); - /** * Reactivate a closed session — counterpart to `/close`. Used by both the * "▶️ 恢复会话" card button (via card-handler) and the `botmux resume ` diff --git a/src/core/passthrough-commands.ts b/src/core/passthrough-commands.ts index a9a26ffd6..db954c7ff 100644 --- a/src/core/passthrough-commands.ts +++ b/src/core/passthrough-commands.ts @@ -10,7 +10,7 @@ * chat) rather than relayed to the CLI. Used both for routing and to reject * `customPassthroughCommands` entries that would shadow a daemon command. */ -export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/land', '/subscribe-lark-doc', '/watch-comment', '/insight', '/dashboard', '/vc-auth']); +export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/insight', '/dashboard', '/vc-auth']); /** * Slash commands that are forwarded verbatim to the underlying CLI (e.g. diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 56001b2dd..c273838b1 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -1730,6 +1730,7 @@ export function forkWorker(ds: DaemonSession, prompt: string, resumeOrTurnId: bo if (ds.session.sandbox === undefined) { if (!resume) { ds.session.sandbox = botCfg.sandbox === true; + ds.session.sandboxPaths = botCfg.sandboxPaths; ds.session.sandboxHidePaths = botCfg.sandboxHidePaths ?? []; ds.session.sandboxReadonlyPaths = botCfg.sandboxReadonlyPaths ?? []; ds.session.sandboxNetwork = botCfg.sandboxNetwork !== false; @@ -1863,6 +1864,7 @@ export function forkWorker(ds: DaemonSession, prompt: string, resumeOrTurnId: bo // Use the decision recorded on the session (above), NOT the live bot flag, so // historical sessions never get retroactively sandboxed on restart. sandbox: ds.session.sandbox === true, + sandboxPaths: ds.session.sandboxPaths ?? botCfg.sandboxPaths, sandboxHidePaths: ds.session.sandboxHidePaths ?? [], sandboxReadonlyPaths: ds.session.sandboxReadonlyPaths ?? [], sandboxNetwork: ds.session.sandboxNetwork !== false, diff --git a/src/daemon.ts b/src/daemon.ts index c0e173c65..f3e3d0f05 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -31,6 +31,7 @@ import { resolveGroupJoinPrompt, waitForAllowedUserInChat } from './core/auto-st import { loadBotConfigs, registerBot, getBot, getAllBots, getOwnerOpenId, findOncallChat, effectiveDefaultWorkingDir, effectiveBotDisplayName, type BotConfig, type BotState, type OncallChat, type VcMeetingAgentConfig, type VcMeetingConsumerAgentConfig } from './bot-registry.js'; import { setDisplayNameRefresher, findConfigField, applyConfigField } from './services/bot-config-store.js'; import { renameBotOnOpenPlatform } from './services/open-platform-rename.js'; +import { migrateSandboxConfigAtStartup } from './services/sandbox-migration.js'; import * as sessionStore from './services/session-store.js'; import * as chatFirstSeenStore from './services/chat-first-seen-store.js'; import { ensureDefaultOncallBound } from './services/oncall-store.js'; @@ -8261,6 +8262,12 @@ export async function startDaemon(botIndex?: number): Promise { // 不阻塞:首张截图可能仍是豆腐块,装完重启 daemon 即可正常。 ensureCjkFontsInstalled(); + // Auto-migrate legacy sandbox fields (readIsolation / sandboxHidePaths / + // sandboxReadonlyPaths / readDenyExtraPaths → sandbox + sandboxPaths) BEFORE + // loading, so the parsed configs below already carry the new model. Writes + // new fields, keeps old ones (downgrade = zero-op), backs up once. Idempotent. + await migrateSandboxConfigAtStartup(); + // Load the assigned bot (one daemon per bot) const botConfigs = loadBotConfigs(); const idx = botIndex ?? 0; diff --git a/src/dashboard.ts b/src/dashboard.ts index 9f1dae29a..d2bf9d181 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -2812,25 +2812,6 @@ const server = createServer(async (req, res) => { return; } - // Sandbox landing: review the clone's diff (GET) then apply/discard (POST). - if (req.method === 'GET' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/sandbox-diff$/))) { - const sid = decodeURIComponent(m[1]); - const owner = aggregator.ownerOf(sid); - if (!owner) return jsonRes(res, 404, { ok: false, error: 'unknown_session' }); - const upstream = await proxyToDaemon(owner, `/api/sessions/${sid}/sandbox-diff`, { method: 'GET' }); - res.writeHead(upstream.status, { 'content-type': 'application/json' }); - res.end(await upstream.text()); - return; - } - if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/sessions\/([^/]+)\/sandbox-land\/(apply|discard)$/))) { - const sid = decodeURIComponent(m[1]); const action = m[2]; - const owner = aggregator.ownerOf(sid); - if (!owner) return jsonRes(res, 404, { ok: false, error: 'unknown_session' }); - const upstream = await proxyToDaemon(owner, `/api/sessions/${sid}/sandbox-land/${action}`, { method: 'POST' }); - res.writeHead(upstream.status, { 'content-type': 'application/json' }); - res.end(await upstream.text()); - return; - } if (req.method === 'POST' && (m = url.pathname.match(/^\/api\/schedules\/([^/]+)\/(run|pause|resume|delivery)$/))) { const id = decodeURIComponent(m[1]); const op = m[2]; diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index f4f3af147..b241f5b63 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -318,15 +318,6 @@ const zh: DashboardMessages = { 'sessions.dismiss': '关闭', 'sessions.closeConfirm': '关闭这个会话?', 'sessions.resumeFailed': '恢复失败', - 'sessions.land': '落盘', - 'sessions.landLoading': '加载沙盒 diff…', - 'sessions.landUnavailable': '无法落盘', - 'sessions.landEmpty': '沙盒副本没有改动。', - 'sessions.landApply': '应用到磁盘', - 'sessions.landDiscard': '丢弃', - 'sessions.landApplied': '已落盘', - 'sessions.landFailed': '落盘失败', - 'sessions.landDiscarded': '已丢弃(沙盒副本未应用)。', 'sessions.insight': '洞察', 'sessions.insightLoading': '分析中…', 'sessions.insightUnavailable': '无法分析', @@ -2187,15 +2178,6 @@ const en: DashboardMessages = { 'sessions.dismiss': 'Close', 'sessions.closeConfirm': 'Close this session?', 'sessions.resumeFailed': 'Resume failed', - 'sessions.land': 'Land', - 'sessions.landLoading': 'Loading sandbox diff…', - 'sessions.landUnavailable': 'Cannot land', - 'sessions.landEmpty': 'No changes in the sandbox clone.', - 'sessions.landApply': 'Apply to disk', - 'sessions.landDiscard': 'Discard', - 'sessions.landApplied': 'Landed', - 'sessions.landFailed': 'Land failed', - 'sessions.landDiscarded': 'Discarded (sandbox clone not applied).', 'sessions.insight': 'Insight', 'sessions.insightLoading': 'Analyzing…', 'sessions.insightUnavailable': 'Insight unavailable', diff --git a/src/dashboard/web/sessions-page.tsx b/src/dashboard/web/sessions-page.tsx index f5b424f13..14c0e1b30 100644 --- a/src/dashboard/web/sessions-page.tsx +++ b/src/dashboard/web/sessions-page.tsx @@ -1243,60 +1243,6 @@ function TerminalModal(props: { state: TerminalState | null; onClose: () => void ); } -function LandPanel(props: { row: any }): JSX.Element { - const [state, setState] = useState<{ loading: boolean; diff?: any; patch?: string; message?: ReactNode } | null>(null); - useEffect(() => setState(null), [props.row.sessionId]); - const load = async () => { - setState({ loading: true }); - try { - const r = await fetch(`/api/sessions/${encodeURIComponent(props.row.sessionId)}/sandbox-diff`); - const d = await r.json().catch(() => ({})); - if (!d.ok) { setState({ loading: false, message:

{t('sessions.landUnavailable')}: {String(d.error ?? r.status)}

}); return; } - if (d.empty) { setState({ loading: false, message:

{t('sessions.landEmpty')}

}); return; } - const full = String(d.patch ?? ''); - setState({ - loading: false, - diff: d, - patch: full.slice(0, 20000) + (full.length > 20000 ? '\n...(truncated)' : ''), - }); - } catch (e) { - setState({ loading: false, message:

{t('sessions.landUnavailable')}: {String(e)}

}); - } - }; - const apply = async () => { - const rr = await fetch(`/api/sessions/${encodeURIComponent(props.row.sessionId)}/sandbox-land/apply`, { method: 'POST' }); - const res = await rr.json().catch(() => ({})); - setState({ - loading: false, - message: res.ok - ?

{t('sessions.landApplied')}: {res.files} files (+{res.insertions}/-{res.deletions}) → {String(res.workingDir ?? '')}

- :

{t('sessions.landFailed')}: {String(res.error ?? rr.status)}

, - }); - }; - const discard = async () => { - await fetch(`/api/sessions/${encodeURIComponent(props.row.sessionId)}/sandbox-land/discard`, { method: 'POST' }); - setState({ loading: false, message:

{t('sessions.landDiscarded')}

}); - }; - return ( - <> - -
- {state?.loading ? : null} - {state?.message} - {state?.diff && state.patch !== undefined ? ( - <> -

{state.diff.files} files (+{state.diff.insertions}/-{state.diff.deletions}) → {String(state.diff.workingDir ?? '')}

-
{state.patch}
-
- - -
- - ) : null} -
- - ); -} function InsightReport(props: { report: any }): JSX.Element { const rep = props.report; @@ -1460,7 +1406,6 @@ function Drawer(props: { {row.status !== 'closed' ? ( ) : null} - diff --git a/src/global-config.ts b/src/global-config.ts index 445823138..81e73faae 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -342,7 +342,10 @@ function readRawConfig(): Record { const parsed = JSON.parse(readFileSync(path, 'utf-8')); return parsed && typeof parsed === 'object' ? parsed as Record : {}; } catch (err: any) { - if (!warnedOnce) { + // Under the file sandbox config.json is intentionally NOT exposed (it can + // hold voice TTS credentials); EPERM/EACCES here is EXPECTED, not a parse + // failure — return defaults silently. Only warn on a genuine corrupt-JSON. + if (err?.code !== 'EPERM' && err?.code !== 'EACCES' && !warnedOnce) { warnedOnce = true; // eslint-disable-next-line no-console console.warn(`[botmux] Failed to parse ${path}: ${err?.message ?? err}. Ignoring file.`); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 4663f277d..67d7249d2 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -91,25 +91,6 @@ export const messages: Record = { 'card.grant.toast_expired': 'This request has expired', 'card.grant.toast_no_repo_perm': 'No permission', 'card.grant.toast_failed': 'Grant failed: {reason}', - 'card.land.title': '🧳 Land sandbox changes', - 'card.land.body': 'The sandbox session produced **{files}** changed file(s) (**+{ins} / -{del}**).\nTarget: `{dir}`\n\nReview the diff below; "Apply to disk" will `git apply` these changes back to the real repo.', - 'card.land.files_header': 'Changed files', - 'card.land.preview_header': 'Diff preview', - 'card.land.truncated': 'Preview truncated — full diff in the attached .patch file', - 'card.land.patch_note': 'Full .patch attached (git apply-able)', - 'card.land.btn_apply': 'Apply to disk', - 'card.land.btn_discard': 'Discard', - 'card.land.note': 'Owner-only; changes come from an isolated clone — the real repo is untouched until you apply.', - 'card.land.applied_title': '✅ Landed', - 'card.land.discarded_title': '🗑 Discarded sandbox changes', - 'card.land.failed_title': '❌ Land failed', - 'card.land.applied_body': 'Applied {files} changed file(s) (+{ins}/-{del}) to `{dir}`.', - 'card.land.discarded_body': 'Changes not applied; the sandbox clone is unchanged.', - 'card.land.toast_owner_only': 'Only the owner can land changes', - 'card.land.stale': 'This card is missing session info (likely an old card); send /land again in the session.', - 'cmd.land.no_workingdir': 'The session has no workingDir; cannot determine the land target.', - 'cmd.land.cannot': 'Cannot land: {error}', - 'cmd.land.empty': 'No changes in the sandbox clone — nothing to land.', 'card.grant.result_chat': '✅ Granted for this chat', 'card.grant.result_global': '✅ Granted globally', 'card.grant.result_deny': '🚫 Request denied', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 51922d89b..d4246981a 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -94,25 +94,6 @@ export const messages: Record = { 'card.grant.toast_expired': '该授权请求已失效', 'card.grant.toast_no_repo_perm': '无操作权限', 'card.grant.toast_failed': '授权失败:{reason}', - 'card.land.title': '🧳 沙盒改动落盘', - 'card.land.body': '沙盒会话产生了 **{files}** 个文件改动(**+{ins} / -{del}**)。\n落盘目标:`{dir}`\n\n审阅下方 diff,确认后「应用到磁盘」会把这些改动 `git apply` 回真实仓库。', - 'card.land.files_header': '改动文件', - 'card.land.preview_header': 'diff 预览', - 'card.land.truncated': '预览已截断,完整改动见下方 .patch 文件', - 'card.land.patch_note': '已附完整 .patch 文件(可 git apply)', - 'card.land.btn_apply': '应用到磁盘', - 'card.land.btn_discard': '丢弃', - 'card.land.note': '仅 owner 可应用;改动来自隔离副本,应用前真实仓库不受影响。', - 'card.land.applied_title': '✅ 已落盘', - 'card.land.discarded_title': '🗑 已丢弃沙盒改动', - 'card.land.failed_title': '❌ 落盘失败', - 'card.land.applied_body': '已把 {files} 个文件的改动(+{ins}/-{del})应用到 `{dir}`。', - 'card.land.discarded_body': '改动未应用,沙盒副本保持原样。', - 'card.land.toast_owner_only': '仅 owner 可操作落盘', - 'card.land.stale': '卡片缺少会话信息(可能是旧卡),请在会话里重新发 /land。', - 'cmd.land.no_workingdir': '会话没有 workingDir,无法确定落盘目标。', - 'cmd.land.cannot': '无法落盘:{error}', - 'cmd.land.empty': '沙盒副本没有改动,无需落盘。', 'card.grant.result_chat': '✅ 已授权本群使用', 'card.grant.result_global': '✅ 已全局授权', 'card.grant.result_deny': '🚫 已拒绝该申请', diff --git a/src/im/lark/card-builder.ts b/src/im/lark/card-builder.ts index a0cc40539..2243de4bb 100644 --- a/src/im/lark/card-builder.ts +++ b/src/im/lark/card-builder.ts @@ -1980,54 +1980,3 @@ export function buildCodexAppThreadSelectCard(threads: CodexAppThreadSummary[], return JSON.stringify(card); } -// ── Sandbox landing card (owner reviews the sandbox clone's diff, then applies -// it back to the real repo). Owner-gated apply; the agent never sees this. ── -export interface LandCardOpts { - sessionId: string; - workingDir: string; - statText: string; - files: number; - insertions: number; - deletions: number; - preview: string; // patch text for the in-card preview (already truncated) - truncated?: boolean; // preview was cut → full diff is in the attached .patch - patchAttached?: boolean; // a .patch file message accompanies this card -} - -export function buildLandCard(o: LandCardOpts, locale?: Locale): string { - const v = { sessionId: o.sessionId, workingDir: o.workingDir }; - const body = t('card.land.body', { files: o.files, ins: o.insertions, del: o.deletions, dir: escapeMd(o.workingDir) }, locale); - const elements: any[] = [{ tag: 'div', text: { tag: 'lark_md', content: body } }]; - // Use the card v2 `markdown` element (NOT a lark_md div) for the stat + diff — - // it renders ``` fenced code blocks as real monospace blocks, which lark_md - // divs do not. Paths are already project-relative (computeSandboxDiff). - if (o.statText) elements.push({ tag: 'markdown', content: `**${t('card.land.files_header', undefined, locale)}**\n` + '```text\n' + o.statText.slice(0, 2000) + '\n```' }); - if (o.preview) { - const note = o.truncated ? `\n\n_${t('card.land.truncated', undefined, locale)}_` : ''; - elements.push({ tag: 'markdown', content: `**${t('card.land.preview_header', undefined, locale)}**\n` + '```diff\n' + o.preview + '\n```' + note }); - } - if (o.patchAttached) elements.push({ tag: 'note', elements: [{ tag: 'lark_md', content: t('card.land.patch_note', undefined, locale) }] }); - elements.push( - { tag: 'hr' }, - { tag: 'action', actions: [ - { tag: 'button', type: 'primary', text: { tag: 'plain_text', content: t('card.land.btn_apply', undefined, locale) }, value: { action: 'land_apply', ...v } }, - { tag: 'button', type: 'danger', text: { tag: 'plain_text', content: t('card.land.btn_discard', undefined, locale) }, value: { action: 'land_discard', ...v } }, - ] }, - { tag: 'note', elements: [{ tag: 'lark_md', content: t('card.land.note', undefined, locale) }] }, - ); - return JSON.stringify({ config: { wide_screen_mode: true }, header: { template: 'turquoise', title: { tag: 'plain_text', content: t('card.land.title', undefined, locale) } }, elements }); -} - -export function buildLandResultCard(kind: 'applied' | 'discarded' | 'failed', detail: string, locale?: Locale): string { - const meta = { - applied: { template: 'green', titleKey: 'card.land.applied_title' }, - discarded: { template: 'grey', titleKey: 'card.land.discarded_title' }, - failed: { template: 'red', titleKey: 'card.land.failed_title' }, - }[kind]; - const body = detail || (kind === 'discarded' ? t('card.land.discarded_body', undefined, locale) : ''); - return JSON.stringify({ - config: { wide_screen_mode: true }, - header: { template: meta.template, title: { tag: 'plain_text', content: t(meta.titleKey, undefined, locale) } }, - elements: [{ tag: 'div', text: { tag: 'lark_md', content: body } }], - }); -} diff --git a/src/im/lark/card-handler.ts b/src/im/lark/card-handler.ts index 48995a3b8..3409c61b3 100644 --- a/src/im/lark/card-handler.ts +++ b/src/im/lark/card-handler.ts @@ -9,8 +9,7 @@ import { config } from '../../config.js'; import { getBot, getAllBots, getOwnerOpenId } from '../../bot-registry.js'; import { canOperate, canTalk } from './event-dispatcher.js'; import { updateMessage, deleteMessage, replyMessage, sendMessage, sendUserMessage, sendEphemeralCard, getMessageDetail, isHumanOpenId, resolveUserUnionId as defaultResolveUserUnionId } from './client.js'; -import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildTuiPromptResolvedCard, buildGrantResultCard, buildGrantNotifyCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigTextCard, CONFIG_UNSET, buildLandResultCard, buildRepoSelectCard } from './card-builder.js'; -import { computeSandboxDiff, applySandboxDiff } from '../../services/sandbox-land.js'; +import { buildSessionCard, buildStreamingCard, buildTuiPromptCard, buildTuiPromptProcessingCard, buildTuiPromptResolvedCard, buildGrantResultCard, buildGrantNotifyCard, getCliDisplayName, truncateContent, buildConfigCard, buildConfigTextCard, CONFIG_UNSET, buildRepoSelectCard } from './card-builder.js'; import { findConfigField, applyConfigField, coerceConfigValue, getConfigCardData } from '../../services/bot-config-store.js'; import { updateBotGrantPrefs } from '../../services/grant-prefs-store.js'; import { writeTeamRoleFile, deleteTeamRoleFile } from '../../core/role-resolver.js'; @@ -581,30 +580,6 @@ export async function handleCardAction(data: CardActionData, deps: CardHandlerDe // Use the receiving bot's allowedUsers — the operator open_id in card actions // is scoped to the app that received the callback. const operatorOpenId = data?.operator?.open_id; - // ─── 沙盒落盘卡(land_apply / land_discard)────────────────────────────────── - // 不绑 session(sessionId + workingDir 都在 value 里)。owner 强闸门:只有 owner 能把 - // 隔离副本的改动应用回真实磁盘。agent 在沙盒里无感,不参与。 - if (value?.action && (value.action === 'land_apply' || value.action === 'land_discard') && larkAppId) { - const loc = localeForBot(larkAppId); - const owner = getOwnerOpenId(larkAppId); - if (!operatorOpenId || operatorOpenId !== owner) { - logger.info(`Land action "${value.action}" blocked for non-owner: ${operatorOpenId}`); - return { toast: { type: 'error', content: t('card.land.toast_owner_only', undefined, loc) } }; - } - if (value.action === 'land_discard') { - return JSON.parse(buildLandResultCard('discarded', '', loc)); - } - const sid: string = value.sessionId; - const wd: string = value.workingDir; - if (!sid || !wd) return JSON.parse(buildLandResultCard('failed', t('card.land.stale', undefined, loc), loc)); - const d = computeSandboxDiff(config.session.dataDir, sid, loc); - if (!d.ok) return JSON.parse(buildLandResultCard('failed', d.error, loc)); - if (d.empty) return JSON.parse(buildLandResultCard('discarded', '', loc)); - const a = applySandboxDiff(wd, config.session.dataDir, sid, loc); - if (!a.ok) return JSON.parse(buildLandResultCard('failed', a.error, loc)); - logger.info(`Land applied: ${d.files} files (+${d.insertions}/-${d.deletions}) → ${wd}`); - return JSON.parse(buildLandResultCard('applied', t('card.land.applied_body', { files: d.files, ins: d.insertions, del: d.deletions, dir: wd }, loc), loc)); - } // ─── 群内授权卡片动作(grant_chat / grant_global / grant_deny,talk-only)───── // 不绑定 session,必须在 session 解析之前处理。owner 强闸门 + nonce 校验。 if (value?.action && (value.action === 'grant_chat' || value.action === 'grant_global' || value.action === 'grant_deny') && larkAppId) { diff --git a/src/services/sandbox-land.ts b/src/services/sandbox-land.ts deleted file mode 100644 index 7bfa47364..000000000 --- a/src/services/sandbox-land.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Sandbox landing (overlayfs model): the project overlay UPPER dir IS the - * changeset the sandboxed agent produced. We compute a human-readable diff from - * it (preview/patch via `git diff --no-index` against the real project), and on - * the owner's confirmation copy the changed files back into the real project. - * - * The agent in the sandbox is oblivious it's overlaid — it thinks it edits the - * real files. So landing is owner-triggered only (the `/land` command or the - * dashboard button), never by the agent. - * - * Upper-layer semantics (overlayfs): - * - a regular file in upper → a new-or-modified file - * - a symlink in upper → a symlink the agent created/changed - * - a character device with rdev 0 → a WHITEOUT (the agent deleted the file) - * - a dir with xattr trusted.overlay.opaque=y → the dir's lower contents are - * hidden. NOTE: overlayfs sets this xattr on BOTH a freshly-created dir AND a - * wholesale-replaced dir, so opacity ALONE cannot tell "new" from "replaced". - * The real discriminator is whether the dir also exists in the lower (= the - * target). We therefore only treat an opaque dir as a wholesale REPLACE (drop - * the lower's stale contents) when it ALSO exists in the target; a purely-new - * opaque dir is just mkdir'd — never rm -rf'd over unrelated real files. - */ -import { spawnSync } from 'node:child_process'; -import { - existsSync, readdirSync, lstatSync, copyFileSync, mkdirSync, rmSync, readFileSync, - readlinkSync, symlinkSync, unlinkSync, realpathSync, -} from 'node:fs'; -import { join, dirname, relative, resolve } from 'node:path'; -import { t, type Locale } from '../i18n/index.js'; - -/** Canonicalize dataDir the same way prepareSandbox does: the sandbox tree is - * created under the CANONICAL dataDir, so a symlink `config.session.dataDir` - * would otherwise make `/land` / diff / patch look under the wrong path and - * find no changeset. Best-effort — falls back to a plain normalize. */ -function canonicalDataDir(dataDir: string): string { - try { return realpathSync(dataDir); } catch { return resolve(dataDir); } -} - -export interface LandDiff { - ok: true; - empty: boolean; - patch: string; - statText: string; // file list + per-file change kind - files: number; - insertions: number; - deletions: number; -} -export interface LandError { ok: false; error: string } - -/** One change the agent made, derived from the overlay upper layer. */ -interface UpperChange { - /** Path relative to the project root. */ - rel: string; - /** file = regular file; symlink = a link (target in `linkTarget`); delete = a - * whiteout; opaque = an opaque dir (REPLACE iff it also exists in the target). */ - kind: 'file' | 'symlink' | 'delete' | 'opaque'; - /** Symlink target string (only for kind:'symlink'). */ - linkTarget?: string; -} - -/** Locate the project overlay UPPER dir (= SandboxSpawn.workDir) for a session. */ -export function upperDir(dataDir: string, sessionId: string): string { - return join(canonicalDataDir(dataDir), 'sandboxes', sessionId, 'proj-upper'); -} - -/** - * The project LOWER source recorded by prepareSandbox at create time (meta.json). - * Used to tell a wholesale-REPLACED opaque dir (existed in the lower) from a - * purely-NEW opaque dir (didn't) — overlayfs marks BOTH opaque, so the lower is - * the only reliable discriminator, and the LIVE landing target may have drifted - * (concurrent work) so we must NOT use it for this. Returns '' when unknown - * (older session / meta missing) → callers fall back to the target. - */ -function projectLower(dataDir: string, sessionId: string): string { - try { - const meta = JSON.parse(readFileSync(join(canonicalDataDir(dataDir), 'sandboxes', sessionId, 'meta.json'), 'utf8')); - return typeof meta?.projectLower === 'string' ? meta.projectLower : ''; - } catch { return ''; } -} - -/** True if a path is an overlayfs whiteout (char device, rdev 0). */ -function isWhiteout(p: string): boolean { - try { - const st = lstatSync(p); - return st.isCharacterDevice() && st.rdev === 0; - } catch { return false; } -} - -/** - * Read the overlay opaque xattr for a dir. - * - 'y' → the dir is opaque - * - '' → the dir is present and NOT opaque (xattr absent) - * - null → could NOT determine (no xattr tooling available / read failed) - * - * `getfattr` is provided by the `attr` package and is frequently NOT installed; - * `trusted.overlay.*` also requires CAP_SYS_ADMIN to read (we run as root). We - * try getfattr first, then python3's os.getxattr (commonly present), and return - * null when neither can answer — callers must NOT silently treat null as "not - * opaque" (that would leak stale lower files); they fail-closed where it matters. - */ -function readOpaqueXattr(p: string): 'y' | '' | null { - // getfattr (attr package). --only-values prints just the value; ENOATTR → exit≠0. - const gf = spawnSync('getfattr', ['-n', 'trusted.overlay.opaque', '--only-values', '-h', p], { encoding: 'utf8' }); - if (gf.error == null && typeof gf.status === 'number') { - if (gf.status === 0) return (gf.stdout ?? '').trim() === 'y' ? 'y' : ''; - // exit≠0 with stderr mentioning "No such attribute"/ENOATTR → present, not opaque. - const se = (gf.stderr ?? '').toLowerCase(); - if (se.includes('no such attribute') || se.includes('enoattr') || se.includes('not found')) return ''; - // other failure (e.g. permission) → fall through to python. - } - // python3's os.getxattr — exit 0 + 'y' on stdout when opaque; exit 0 + '' when - // the attr is absent; exit 2 only if python itself is unavailable. - const py = spawnSync('python3', [ - '-c', - "import os,sys\n" + - "try:\n" + - " v=os.getxattr(sys.argv[1],'trusted.overlay.opaque')\n" + - " sys.stdout.write(v.decode('latin1','replace'))\n" + - "except OSError as e:\n" + - " import errno\n" + - " sys.exit(0) if e.errno in (errno.ENODATA,errno.ENOATTR if hasattr(errno,'ENOATTR') else errno.ENODATA) else sys.exit(3)\n", - p, - ], { encoding: 'utf8' }); - if (py.error == null && py.status === 0) return (py.stdout ?? '').trim() === 'y' ? 'y' : ''; - return null; // undeterminable -} - -/** Walk the upper layer, classifying every entry into an UpperChange. */ -function walkUpper(upper: string): UpperChange[] { - const changes: UpperChange[] = []; - const recurse = (dir: string) => { - let entries: string[] = []; - try { entries = readdirSync(dir); } catch { return; } - for (const name of entries) { - const full = join(dir, name); - const rel = relative(upper, full); - if (isWhiteout(full)) { changes.push({ rel, kind: 'delete' }); continue; } - let st; - try { st = lstatSync(full); } catch { continue; } - if (st.isSymbolicLink()) { - // A symlink is landed as a symlink (NOT dereferenced into a content copy, - // which would destroy the link and could snapshot out-of-tree host data). - let linkTarget = ''; - try { linkTarget = readlinkSync(full); } catch { /* */ } - changes.push({ rel, kind: 'symlink', linkTarget }); - } else if (st.isDirectory()) { - // Opacity is recorded; the new-vs-replace decision is deferred to apply - // (where the target — the lower — is known). Either way we recurse so the - // dir's surviving children land as their own 'file'/'symlink' changes. - const opaque = readOpaqueXattr(full); - if (opaque === 'y' || opaque === null) changes.push({ rel, kind: 'opaque' }); - recurse(full); - } else if (st.isFile()) { - changes.push({ rel, kind: 'file' }); - } - // other special files (fifos/sockets/non-whiteout devices) are skipped. - } - }; - recurse(upper); - return changes; -} - -/** - * Compute the agent's changeset from the session's overlay upper layer, with a - * human-readable preview (per-file `git diff --no-index` vs the real project). - */ -export function computeSandboxDiff(dataDir: string, sessionId: string, locale?: Locale): LandDiff | LandError { - const upper = upperDir(dataDir, sessionId); - if (!existsSync(upper)) return { ok: false, error: t('sandbox.no_clone', undefined, locale) }; - - let changes: UpperChange[]; - try { changes = walkUpper(upper); } - catch (e: any) { return { ok: false, error: t('sandbox.diff_failed', { detail: (e?.message ?? e).toString().slice(0, 300) }, locale) }; } - - if (changes.length === 0) { - return { ok: true, empty: true, patch: '', statText: '', files: 0, insertions: 0, deletions: 0 }; - } - - // Diff each upper file against the SAME-NAMED real file in the overlay lower - // (the recorded project root) so modified files show a true unified diff (not - // the whole file as "added"), then rewrite git's --no-index headers to - // project-relative a/ b/ — clean for the card AND `git apply`-able as - // the .patch file. New files diff vs /dev/null; deletions diff lower vs /dev/null. - const lower = projectLower(dataDir, sessionId); // real project root ('' if unknown) - const relDiff = (oldPath: string, newPath: string, rel: string): string => { - let out = ''; - try { out = spawnSync('git', ['diff', '--no-index', '--', oldPath, newPath], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).stdout ?? ''; } - catch { /* */ } - if (!out) return ''; - return out.split('\n').map(line => { - if (line.startsWith('diff --git ')) return `diff --git a/${rel} b/${rel}`; - if (line.startsWith('--- ')) return line.includes('/dev/null') ? '--- /dev/null' : `--- a/${rel}`; - if (line.startsWith('+++ ')) return line.includes('/dev/null') ? '+++ /dev/null' : `+++ b/${rel}`; - return line; - }).join('\n'); - }; - const countPlus = (b: string) => b.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).length; - const countMinus = (b: string) => b.split('\n').filter(l => l.startsWith('-') && !l.startsWith('---')).length; - - const statLines: string[] = []; - const patchParts: string[] = []; - let insertions = 0, deletions = 0, fileCount = 0; - for (const c of changes) { - if (c.kind === 'delete') { - const lowFile = lower ? join(lower, c.rel) : ''; - const body = (lowFile && existsSync(lowFile)) - ? relDiff(lowFile, '/dev/null', c.rel) - : `diff --git a/${c.rel} b/${c.rel}\ndeleted file\n--- a/${c.rel}\n+++ /dev/null\n`; - statLines.push(`D ${c.rel}`); - deletions += countMinus(body) || 1; - patchParts.push(body); fileCount++; - continue; - } - if (c.kind === 'symlink') { - statLines.push(`L ${c.rel} -> ${c.linkTarget ?? ''}`); - patchParts.push(`# symlink ${c.rel} -> ${c.linkTarget ?? ''} (recreated on land; not in this text patch)`); - fileCount++; continue; - } - if (c.kind === 'opaque') { - // Replaced directory; its surviving children land as their own 'file' - // changes (walkUpper recursed), so no diff body here — just note it. - statLines.push(`R ${c.rel}/ (directory replaced)`); - continue; - } - // regular file: modified (exists in lower) vs new - const upPath = join(upper, c.rel); - const lowFile = lower ? join(lower, c.rel) : ''; - const isMod = !!(lowFile && existsSync(lowFile)); - const body = relDiff(isMod ? lowFile : '/dev/null', upPath, c.rel) - || `diff --git a/${c.rel} b/${c.rel}\n(binary or unreadable: ${c.rel})\n`; - statLines.push(`${isMod ? 'M' : 'A'} ${c.rel}`); - insertions += countPlus(body); - deletions += countMinus(body); - patchParts.push(body); fileCount++; - } - - return { - ok: true, - empty: false, - patch: patchParts.join('\n'), - statText: statLines.join('\n'), - files: fileCount, - insertions, - deletions, - }; -} - -/** - * Apply the session's overlay upper changeset onto the real target project: - * - regular file in upper → mkdir -p parent, copy over the real file - * - symlink in upper → recreate the link (NOT a dereferenced copy) - * - whiteout → remove the corresponding real file/dir - * - opaque dir → only when the dir ALSO exists in the target (= a - * wholesale replace): drop the stale lower contents, - * then re-create it (its surviving files arrive as - * separate 'file'/'symlink' changes). A purely-new - * opaque dir is just mkdir'd (never rm -rf'd). - * - * Two-phase for safety: - * 1) PRE-VALIDATE every source is landable (dangling symlink, unreadable regular - * file, undeterminable opacity on a dir that exists in the target) BEFORE we - * mutate anything — so the common mid-loop failure (a dangling relative - * symlink) is caught up front and the real project is left untouched. - * 2) APPLY; if a per-file op still throws (race / permission / ENOSPC), we - * report which files were already applied so the owner isn't told "failed" - * with the repo silently half-landed and no clue about its state. - */ -export function applySandboxDiff(targetDir: string, dataDir: string, sessionId: string, locale?: Locale): { ok: true } | LandError { - const upper = upperDir(dataDir, sessionId); - if (!existsSync(upper)) return { ok: false, error: t('sandbox.no_clone', undefined, locale) }; - if (!existsSync(targetDir)) return { ok: false, error: t('sandbox.target_not_git', { dir: targetDir }, locale) }; - - let changes: UpperChange[]; - try { changes = walkUpper(upper); } - catch (e: any) { return { ok: false, error: t('sandbox.apply_failed', { detail: (e?.message ?? e).toString().slice(0, 300) }, locale) }; } - if (changes.length === 0) return { ok: false, error: t('sandbox.nothing_to_land', undefined, locale) }; - - // Project LOWER (create-time) — the reliable new-vs-replace discriminator for - // opaque dirs; '' when unknown → fall back to the live target. - const lower = projectLower(dataDir, sessionId); - // Does dir `rel` count as a REPLACE (existed in the lower) vs a fresh NEW dir? - const existedInLower = (rel: string): boolean => - lower ? existsSync(join(lower, rel)) : existsSync(join(targetDir, rel)); - - // ── Phase 1: pre-validate — fail-closed BEFORE any mutation ──────────────── - const preErrors: string[] = []; - for (const c of changes) { - if (c.kind === 'file') { - const src = join(upper, c.rel); - // Resolve via lstat: a regular file must be readable; a broken/special src - // would make the in-loop copyFileSync throw mid-land. Catch it now. - try { lstatSync(src); } catch (e: any) { preErrors.push(`${c.rel}: source missing (${e?.code ?? e})`); } - } else if (c.kind === 'symlink') { - if (!c.linkTarget) preErrors.push(`${c.rel}: unreadable symlink target`); - } else if (c.kind === 'opaque') { - // Only an opaque dir that existed in the LOWER triggers a destructive - // rm -rf (a wholesale replace). For such a dir, if we could NOT determine - // opacity (readOpaqueXattr → null, no xattr tooling) we MUST NOT guess - // "not opaque" (that leaks stale lower files); fail-closed with a clear - // message. A purely-NEW opaque dir needs no opacity read at all. - if (existedInLower(c.rel)) { - const opaque = readOpaqueXattr(join(upper, c.rel)); - if (opaque === null) { - preErrors.push(`${c.rel}/: cannot determine overlay opacity (install the "attr" package, i.e. getfattr) — refusing to land a possibly-replaced directory without knowing whether to drop its stale contents`); - } - } - } - } - if (preErrors.length > 0) { - return { ok: false, error: t('sandbox.apply_failed', { detail: `pre-flight (no changes applied): ${preErrors.slice(0, 8).join('; ').slice(0, 380)}` }, locale) }; - } - - // ── Phase 2: apply ───────────────────────────────────────────────────────── - const applied: string[] = []; - try { - for (const c of changes) { - const real = join(targetDir, c.rel); - if (c.kind === 'delete') { - try { rmSync(real, { recursive: true, force: true }); } catch { /* already gone */ } - applied.push(`-${c.rel}`); - continue; - } - if (c.kind === 'opaque') { - // Wholesale REPLACE only when the dir existed in the LOWER (create-time) - // — drop its stale contents. A purely-NEW opaque dir is just mkdir'd, so - // we never rm -rf unrelated real files that drifted into the live target - // under a path the agent merely created fresh (the new-dir clobber). - if (existedInLower(c.rel) && existsSync(real)) { - try { rmSync(real, { recursive: true, force: true }); } catch { /* */ } - } - mkdirSync(real, { recursive: true }); - applied.push(`R ${c.rel}/`); - continue; - } - if (c.kind === 'symlink') { - // Recreate the link verbatim (preserve symlink-ness; do NOT dereference). - mkdirSync(dirname(real), { recursive: true }); - try { unlinkSync(real); } catch { /* not present */ } - try { rmSync(real, { recursive: true, force: true }); } catch { /* was a dir */ } - symlinkSync(c.linkTarget ?? '', real); - applied.push(`L ${c.rel}`); - continue; - } - // regular file: copy the upper content over the real file. - mkdirSync(dirname(real), { recursive: true }); - // If a symlink/dir is squatting the target path, clear it first so copy lands a file. - try { const st = lstatSync(real); if (st.isSymbolicLink() || st.isDirectory()) rmSync(real, { recursive: true, force: true }); } catch { /* */ } - copyFileSync(join(upper, c.rel), real); - applied.push(c.rel); - } - return { ok: true }; - } catch (e: any) { - const detail = `${(e?.message ?? e).toString().slice(0, 240)} — already applied (${applied.length}): ${applied.slice(0, 12).join(', ').slice(0, 200)}`; - return { ok: false, error: t('sandbox.apply_failed', { detail }, locale) }; - } -} diff --git a/src/services/sandbox-migration.ts b/src/services/sandbox-migration.ts new file mode 100644 index 000000000..5ebaa512a --- /dev/null +++ b/src/services/sandbox-migration.ts @@ -0,0 +1,75 @@ +/** + * bots.json sandbox-config auto-migration (old fields → fs-policy model). + * + * Runs at daemon startup, inside the shared cross-process file lock (multiple + * per-bot daemons boot concurrently against the same bots.json). Writes the + * NEW fields while KEEPING the legacy ones on disk — a downgraded daemon reads + * the untouched legacy fields, so downgrade needs no reverse script (design + * doc §6.2: upgrade rewrites, downgrade is zero-op). The original file is + * backed up once to `bots.json.bak-sandbox-v1` before the first rewrite. + * + * Idempotent: entries that already carry `sandboxPaths` (or have nothing to + * migrate) are skipped, so every subsequent boot is a no-op. + */ +import { promises as fsp, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { withFileLock } from '../utils/file-lock.js'; +import { writeRawConfigAtomic } from './config-store.js'; +import { migrateLegacySandboxFields } from '../adapters/cli/fs-policy.js'; +import { logger } from '../utils/logger.js'; + +/** The bots.json path the daemon will load (same resolution as loadBotConfigs). */ +export function resolveBotsConfigPath(): string | null { + const fromEnv = process.env.BOTS_CONFIG; + if (fromEnv) { + const p = resolve(fromEnv); + return existsSync(p) ? p : null; + } + const p = resolve(homedir(), '.botmux', 'bots.json'); + return existsSync(p) ? p : null; +} + +/** + * Migrate every entry's legacy sandbox fields in `path`. Returns the appIds + * that were migrated (empty = file untouched). Never throws on a malformed + * file — migration must not brick daemon startup; loadBotConfigs surfaces + * parse errors with better context right after. + */ +export async function migrateSandboxConfigOnDisk(path: string): Promise<{ migrated: string[] }> { + try { + return await withFileLock(path, async () => { + let raw: unknown; + try { raw = JSON.parse(await fsp.readFile(path, 'utf-8')); } catch { return { migrated: [] }; } + if (!Array.isArray(raw)) return { migrated: [] }; + const migrated: string[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const m = migrateLegacySandboxFields(entry); + if (!m) continue; + entry.sandbox = m.sandbox; + if (m.sandboxPaths) entry.sandboxPaths = m.sandboxPaths; + migrated.push(typeof entry.larkAppId === 'string' ? entry.larkAppId : ''); + } + if (!migrated.length) return { migrated }; + const bak = `${path}.bak-sandbox-v1`; + if (!existsSync(bak)) { + await fsp.copyFile(path, bak); + await fsp.chmod(bak, 0o600); + } + await writeRawConfigAtomic(path, raw); + logger.info(`[sandbox-migration] migrated legacy sandbox fields for ${migrated.length} bot(s): ${migrated.join(', ')} (backup: ${bak})`); + return { migrated }; + }); + } catch (err) { + logger.warn(`[sandbox-migration] skipped (${err instanceof Error ? err.message : String(err)})`); + return { migrated: [] }; + } +} + +/** Startup convenience: resolve the config path and migrate if present. */ +export async function migrateSandboxConfigAtStartup(): Promise { + const path = resolveBotsConfigPath(); + if (!path) return; + await migrateSandboxConfigOnDisk(path); +} diff --git a/src/types.ts b/src/types.ts index a635153d1..e38d31b2a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -176,7 +176,10 @@ export interface Session { * sessions created before this field existed → treated as not sandboxed. */ sandbox?: boolean; - /** Per-bot privacy masks recorded alongside `sandbox` at session creation. */ + /** User three-tier path lists (fs-policy) recorded alongside `sandbox` at + * session creation. */ + sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; + /** LEGACY privacy masks (pre fs-policy) recorded at session creation. */ sandboxHidePaths?: string[]; /** Extra read-only paths recorded alongside `sandbox` at session creation. */ sandboxReadonlyPaths?: string[]; @@ -332,7 +335,7 @@ export type TermActionKey = /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; disableCliBypass?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } + | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; disableCliBypass?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } | { type: 'message'; content: string; turnId?: string } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate diff --git a/src/worker.ts b/src/worker.ts index 4b52e17f7..0b8a957c5 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -19,19 +19,12 @@ import { join, basename } from 'node:path'; import { homedir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { - evaluateReadIsolationGate, - buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, - buildV2DenyPaths, - buildV2DenyRegexes, - buildV2CarveOuts, buildCliExecutableReadCarveOuts, - buildWriteSandboxRules, - buildLinuxReadIsolationMasks, - type V2IsolationContext, } from './adapters/cli/read-isolation.js'; +import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields } from './adapters/cli/fs-policy.js'; import { killPersistentSession, type PersistentBackendType } from './core/persistent-backend.js'; import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent, extractTurnStartText, splitTranscriptEventsByCutoff, type TranscriptEvent } from './services/claude-transcript.js'; import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js'; @@ -46,7 +39,7 @@ import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } f import { sanitizePerBotEnv } from './core/per-bot-env.js'; import { ensureGatewayEntry } from './core/plugins/mcp/gateway-installer.js'; import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js'; -import { loadBotConfigs, type BotConfig } from './bot-registry.js'; +import { loadBotConfigs, resolveBrandLabel, type BotConfig } from './bot-registry.js'; import { readGlobalConfig } from './global-config.js'; import { resolveTerminalWriteForRequest } from './core/terminal-write-auth.js'; import { readPlatformBinding } from './platform/binding.js'; @@ -84,6 +77,7 @@ import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './ import { extractKiroSessionIdFromOutput } from './services/kiro-session.js'; import { baselineJsonlCursor } from './services/jsonl-cursor.js'; import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createServer as createHttpServer, type IncomingMessage } from 'node:http'; import { WebSocketServer, WebSocket } from 'ws'; import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js'; @@ -119,7 +113,7 @@ import { zellijEnv } from './setup/ensure-zellij.js'; import { isObserveBackend, type ObserveBackend } from './adapters/backend/types.js'; import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js'; import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl } from './adapters/backend/riff-backend.js'; -import { prepareSandbox, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, sandboxedClaudeDataDir, localSandboxApplies } from './adapters/backend/sandbox.js'; +import { prepareDirectSandbox, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies } from './adapters/backend/sandbox.js'; import type { BackendType, SessionBackend } from './adapters/backend/types.js'; import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js'; import { tmuxRestartJitterMs } from './core/tmux-recovery.js'; @@ -4479,74 +4473,31 @@ function spawnCli(cfg: Extract): void { // JSONL/pid/bridge gate below keys off it instead of `cliId === 'claude-code'`, // so a fork inherits the whole submit-confirm + bridge-fallback machinery. let claudeDataDir = cliAdapter.claudeDataDir; - // When this session will be file-sandboxed, the CLI's session jsonl is written - // into the overlay's EPHEMERAL home upper (CLAUDE_CONFIG_DIR lives under $HOME), - // invisible at the real path the bridge normally watches → "Bridge mark expired" - // and the turn never relays (2026-06-10 incident). Redirect every jsonl/pid/ - // bridge gate below to the upper copy where the sandboxed CLI actually writes. - const willFileSandbox = - process.platform === 'linux' && // bwrap overlay is Linux-only; macOS uses the Seatbelt write-sandbox below - (cfg.sandbox === true || sandboxEnabled()) && - (effectiveBackendType === 'pty' || effectiveBackendType === 'tmux') && - !!process.env.SESSION_DATA_DIR; - if (claudeDataDir && willFileSandbox) { - const redirected = sandboxedClaudeDataDir(cfg.sessionId, claudeDataDir); - log(`[sandbox] redirecting Claude bridge dataDir → overlay upper: ${redirected}`); - claudeDataDir = redirected; - } - // v2 read isolation: relocate the CLI's data root into the per-bot BOT_HOME - // (`/bots//{claude,codex}`) so each bot's transcripts/memory - // land in its OWN (Seatbelt-allowed) dir, NOT the shared/global ~/.claude|~/.codex - // (which v2 denies wholesale). Decided EARLY — like willFileSandbox above — so - // every JSONL/bridge/resume path below already targets the per-bot dir. The - // matching CLAUDE_CONFIG_DIR/CODEX_HOME env, per-bot provisioning and Seatbelt - // wrapper are applied at spawn time further down. This gate is the SINGLE - // decision point: configured-but-unenforceable fail-closes HERE (never run a - // session unisolated that asked for isolation). - // UNIFIED file sandbox: read isolation engages with the same toggle as write - // isolation. On darwin the legacy standalone `readIsolation` flag ALSO engages it - // (Seatbelt can read-deny without a write overlay). On Linux read isolation RIDES - // the bwrap sandbox (its masks go into the same bwrap plan), so it requires the - // sandbox to be on — hence the trigger is the sandbox toggle there, never the bare - // legacy flag (which would redirect the data dir but apply no masks → a hole). - // riff runs in its own REMOTE sandbox with no local CLI process — every - // local isolation flavor (Linux bwrap, macOS Seatbelt write-sandbox, read - // isolation incl. the legacy fail-closed flag) is meaningless for it and - // must be bypassed on ALL platforms, or a sandbox/readIsolation-enabled bot - // bricks the moment it switches to riff. - const riffRemoteBackend = effectiveBackendType === 'riff'; + // ── UNIFIED file sandbox (fs-policy, 2026-07-16 refactor) ── + // ONE toggle, BOTH platforms, identical three-tier deny-by-default semantics + // (adapters/cli/fs-policy.ts). Legacy `readIsolation` is auto-migrated to + // `sandbox` at daemon startup; honored here too for an unmigrated read-only + // BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process — + // local confinement is meaningless there and must be bypassed on ALL + // platforms, or a sandbox-enabled bot bricks the moment it switches to riff. + const riffRemoteBackend = !localSandboxApplies(effectiveBackendType); if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) { - log('Sandbox/read-isolation flags set but backend is riff (remote sandbox, no local process) — local isolation bypassed'); - } - const wantsFileSandbox = !riffRemoteBackend && (cfg.sandbox === true || sandboxEnabled()); - // Legacy EXPLICIT read-isolation opt-in (macOS only — Linux read-iso rides the - // bwrap sandbox, so it's driven solely by the sandbox toggle there). Keeps - // FAIL-CLOSED semantics: you asked for read isolation → refuse to start without it. - const explicitLegacyReadIso = process.platform === 'darwin' && cfg.readIsolation === true && !riffRemoteBackend; - const readIsolationGate = evaluateReadIsolationGate({ - configured: wantsFileSandbox || explicitLegacyReadIso, - adapterSupports: cliAdapter.supportsReadIsolation === true, - wrapperCliSet: !!cfg.wrapperCli, - platform: process.platform, - sessionDataDirSet: !!process.env.SESSION_DATA_DIR, - }); - // Fail-closed ONLY for the explicit legacy flag. Under the UNIFIED sandbox toggle - // read isolation is BEST-EFFORT: a CLI/platform that can't enforce the read-deny - // (e.g. a non-supporting adapter) still runs WRITE-isolated — it just doesn't get - // the read masks. Never brick a sandboxed session just because read-iso is N/A. - if (readIsolationGate.failClosedReason && explicitLegacyReadIso) { - throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${readIsolationGate.failClosedReason}`); - } - const willReadIsolate = readIsolationGate.enabled; - // macOS FILE SANDBOX — the Seatbelt WRITE-isolation twin of the Linux bwrap - // overlay. On darwin, `sandbox: true` (or the global sandboxEnabled()) confines - // WRITES via the same `sandbox-exec` wrapper as read isolation instead of bwrap - // (Linux-only). Reads stay open, matching the Linux "read-all / write-isolated" - // model; if read isolation is ALSO on, its read-deny set layers into the SAME - // profile. Like bwrap this is FAIL-SAFE: a requested sandbox that can't be - // established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the - // spawn site below, never a silent unconfined run. - const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox; + log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local sandbox bypassed'); + } + const sandboxRequested = !riffRemoteBackend + && (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled()); + // BOT_HOME CLI-data redirect: sandboxed bots whose adapter supports it keep + // their CLI data (transcripts/memory/auth) in their own BOT_HOME via + // CLAUDE_CONFIG_DIR/CODEX_HOME — under deny-by-default the global ~/.claude| + // ~/.codex are simply not exposed. Best-effort: a non-supporting adapter + // keeps its REAL data dirs instead, which the policy exposes readWrite (the + // sandbox itself still applies). Decided EARLY so every JSONL/bridge/resume + // path below already targets the right dir. wrapperCli strips spawn args, so + // the redirect (and its env) can't be guaranteed there → not redirected. + const willRedirectCliData = sandboxRequested + && cliAdapter.supportsReadIsolation === true + && !cfg.wrapperCli + && !!process.env.SESSION_DATA_DIR; // Every bot — isolated OR not — gets its own BOT_HOME dir as a ready-made private- // storage slot. An isolated sibling denies this path regardless of whether the owner // is isolated (deny uses the full bots.json), so a non-isolated bot can drop private @@ -4563,7 +4514,7 @@ function spawnCli(cfg: Extract): void { } } let isolationBotHome: string | undefined; - if (willReadIsolate) { + if (willRedirectCliData) { isolationBotHome = ownBotHome!; const isClaudeFam = !!claudeDataDir; if (isClaudeFam) claudeDataDir = join(isolationBotHome, 'claude'); @@ -4607,7 +4558,7 @@ function spawnCli(cfg: Extract): void { // so the probe below sees no pane and we cold-spawn fresh isolated. A pane from // this lifetime (suspend→resume) keeps its marker → reattaches normally (it is // still the isolated process). This lets isolated bots use tmux/zellij/herdr. - if ((willReadIsolate || willWriteSandbox) && persistentSessionName && effectiveBackendType !== 'pty') { + if (sandboxRequested && persistentSessionName && effectiveBackendType !== 'pty') { const paneLive = effectiveBackendType === 'tmux' ? TmuxBackend.hasSession(persistentSessionName) : effectiveBackendType === 'zellij' @@ -4763,30 +4714,14 @@ function spawnCli(cfg: Extract): void { }) || (effectiveResume && cliAdapter.initialPromptArgsIgnoredOnResume === true); kiroSessionIdCaptureArmed = cfg.cliId === 'kiro-cli' && !effectiveCliSessionId && !willReattachPersistent; kiroSessionIdCaptureBuffer = ''; - // Per-bot local read isolation: assemble the Seatbelt profile context (the gate - // already fail-closed above — reaching here with willReadIsolate means it is - // enforceable). The worker is on the host (NOT sandboxed), so it holds the - // secret; only the spawned CLI child is confined. A reattach that reaches here - // is safe: the stale-pane guard above already killed any persistent pane not - // stamped as isolated, so `willReattachPersistent` can now only be true for a - // pane spawned isolated (still the confined process). - let readIsolationCtx: V2IsolationContext | undefined; - if (willReadIsolate) { - const sessionDataDir = process.env.SESSION_DATA_DIR!; - readIsolationCtx = { - homeDir: homedir(), - botmuxHome: dirname(sessionDataDir), - sessionDataDir, - currentAppId: cfg.larkAppId, - extraDenyPaths: cfg.readDenyExtraPaths, - }; - // Write this bot's OWN send-credential into its BOT_HOME (the same per-bot - // private storage as its CLI data; siblings' BOT_HOMEs are whole-denied). - // `botmux send` reads the secret from here instead of bots.json — so the - // secret never travels via env/argv (no cross-bot `ps aux` leak) and the CLI - // never needs to escape the sandbox. + // Sandboxed sessions: write this bot's OWN send-credential into its BOT_HOME. + // `botmux send` reads the secret from here instead of bots.json (which + // deny-by-default never exposes) — the secret never travels via env/argv (no + // cross-bot `ps aux` leak) and the CLI never needs to escape the sandbox. + // The worker itself runs on the host (NOT sandboxed) and keeps full access. + if (sandboxRequested && process.env.SESSION_DATA_DIR) { try { - const credPath = sendCredFilePath(sessionDataDir, cfg.larkAppId); + const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId); mkdirSync(dirname(credPath), { recursive: true }); writeFileSync( credPath, @@ -4794,32 +4729,9 @@ function spawnCli(cfg: Extract): void { { mode: 0o600 }, ); } catch (e) { - log(`[read-isolation] WARN could not write send-cred file: ${(e as Error).message}`); + log(`[sandbox] WARN could not write send-cred file: ${(e as Error).message}`); } } - // macOS write-sandbox rules (pure): the writable-zone allow-list + crown-jewel - // re-denies. Realpath'd + emitted into the Seatbelt profile at the spawn site. - let writeSandboxRules: { - allowWritePaths: string[]; - allowWriteRegexes: string[]; - denyWritePaths: string[]; - } | undefined; - if (willWriteSandbox && process.env.SESSION_DATA_DIR) { - const sessionDataDir = process.env.SESSION_DATA_DIR; - // Regex rules cannot be realpath'd after construction, so build their home - // prefix from the canonical path up front (Seatbelt matches canonical paths). - const sandboxHome = (() => { try { return realpathSync(homedir()); } catch { return homedir(); } })(); - writeSandboxRules = buildWriteSandboxRules({ - homeDir: sandboxHome, - botmuxHome: dirname(sessionDataDir), - sessionDataDir, - workingDir: cfg.workingDir, - currentAppId: cfg.larkAppId, - // TMPDIR on macOS resolves under /private/var/folders (already allowed), but - // pass it explicitly so a customized TMPDIR is covered too. - extraWritePaths: process.env.TMPDIR ? [process.env.TMPDIR] : [], - }); - } const args = cliAdapter.buildArgs({ sessionId: effectiveAdapterSessionId, resume: effectiveResume, @@ -4833,7 +4745,7 @@ function spawnCli(cfg: Extract): void { model: ttadkGateway ? undefined : cfg.model, disableCliBypass: cfg.disableCliBypass === true, skillPluginDir: cfg.skillPluginDir, - readIsolation: willReadIsolate, + readIsolation: willRedirectCliData, }); // Extra args from env (CLI_DISABLE_DEFAULT_ARGS is removed — adapters own their defaults) @@ -4920,6 +4832,17 @@ function spawnCli(cfg: Extract): void { childEnv.BOTMUX_CHAT_ID = cfg.chatId; childEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId; childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId; + // This bot's resolved brandLabel template, injected so a SANDBOXED `botmux + // send` renders the role-name footer without reading bots.json (deny-by- + // default → EPERM → role footer would silently fall back to the default + // [botmux] label). resolveBrandLabel honours this env first (gated on the + // own appId). Only set the key when a brandLabel is configured (present-but- + // empty '' = suppress is preserved; unset key → the CLI falls through). It is + // a cosmetic markdown template, not a credential, so env-passing is safe. + { + const bl = resolveBrandLabel(cfg.larkAppId); + if (typeof bl === 'string') childEnv.BOTMUX_BRAND_LABEL = bl; + } // NOTE: under read isolation `botmux send` gets this bot's secret from the worker- // written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located // via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT @@ -4977,148 +4900,14 @@ function spawnCli(cfg: Extract): void { let spawnArgs = args; let spawnCwd = cfg.workingDir; - // Read isolation (macOS): wrap the whole CLI process in a Seatbelt sandbox that - // denies reads of the sensitive paths (blocklist). The CLI bypasses its OWN - // sandbox (see adapter) so the outer wrapper is the sole enforcer. DARWIN ONLY — - // on Linux read isolation is enforced by bwrap masks fed into the overlay sandbox - // below (see readIsoLinuxMasks), NOT sandbox-exec (which doesn't exist there). - if (process.platform === 'darwin' && (readIsolationCtx || writeSandboxRules)) { - // Seatbelt matches CANONICAL paths (it resolves symlinks), so realpath every - // deny/allow before emitting the profile — otherwise a sensitive root reached - // through a symlinked prefix (e.g. a symlinked home / SESSION_DATA_DIR) would - // silently fail-open (the /tmp→/private/tmp class of miss). realpath-if-exists: - // a non-existent path has nothing to read, so its literal form is harmless. - // The ROOT dirs are canonicalized FIRST (profileCtx) so the regex patterns — - // which can't be realpath'd as a result — are built on canonical prefixes too. - const canonical = (p: string) => { try { return realpathSync(p); } catch { return p; } }; - // READ rules — only when read isolation is on. v2 HYBRID model: whole-deny - // ~/.claude|~/.codex (F1 fix — own CLI data is redirected into BOT_HOME, - // readable) + surgical-deny only the cross-bot-SENSITIVE parts of the otherwise- - // readable ~/.botmux + system creds; the own slice is re-opened via carve-outs. - // For a WRITE-sandbox-only session these stay empty → reads wide open (Linux parity). - let denyPaths: string[] = [], denyRegexes: string[] = [], allowPaths: string[] = [], - finalDenyPaths: string[] = [], traverseDirs: string[] = []; - if (readIsolationCtx) { - const profileCtx: V2IsolationContext = { - ...readIsolationCtx, - homeDir: canonical(readIsolationCtx.homeDir), - botmuxHome: canonical(readIsolationCtx.botmuxHome), - sessionDataDir: canonical(readIsolationCtx.sessionDataDir), - }; - denyPaths = buildV2DenyPaths(profileCtx).map(canonical); - denyRegexes = buildV2DenyRegexes(profileCtx); - const carve = buildV2CarveOuts(profileCtx); - allowPaths = [ - ...carve.allowPaths.map(canonical), - ...buildCliExecutableReadCarveOuts({ - homeDir: profileCtx.homeDir, - cliId: cliAdapter.id, - resolvedBin: canonical(cliAdapter.resolvedBin), - }), - ]; - finalDenyPaths = carve.finalDenyPaths.map(canonical); - traverseDirs = carve.traverseDirs.map(canonical); - } - // WRITE rules — the macOS file-sandbox (Linux-bwrap twin). Realpath the writable - // zones + crown-jewel re-denies for the same symlink-safety reason as reads. - const writeRules = writeSandboxRules - ? { - allowWritePaths: writeSandboxRules.allowWritePaths.map(canonical), - allowWriteRegexes: writeSandboxRules.allowWriteRegexes, - denyWritePaths: writeSandboxRules.denyWritePaths.map(canonical), - } - : undefined; - if (!locateOnPath('sandbox-exec')) { - throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`); - } - const profileDir = join(process.env.SESSION_DATA_DIR!, 'read-isolation'); - mkdirSync(profileDir, { recursive: true }); - const profilePath = join(profileDir, `${cfg.sessionId}.sb`); - writeFileSync(profilePath, buildSeatbeltProfile(denyPaths, allowPaths, finalDenyPaths, traverseDirs, denyRegexes, writeRules), { mode: 0o600 }); - seatbeltProfilePath = profilePath; - spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs]; - spawnBin = 'sandbox-exec'; - log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (read-isolation=${!!readIsolationCtx}, write-sandbox=${!!writeRules}): sandbox-exec -f ${profilePath}`); - } - // Fresh sandboxed spawn on a persistent backend: stamp the pane with this daemon's - // boot id so a later suspend→resume reattach can be trusted (see the stale-pane - // guard above). Applies to read-isolation AND write-sandbox panes (both carry the - // Seatbelt confinement on the live process). pty needs no marker (never reattached). - if ((readIsolationCtx || writeSandboxRules) && persistentSessionName && !willReattachPersistent) { - try { - const markerDir = join(process.env.SESSION_DATA_DIR!, 'read-isolation'); - mkdirSync(markerDir, { recursive: true }); - writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), cfg.daemonBootId ?? '', { mode: 0o600 }); - } catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ } - } - // Sandbox wraps the spawned binary in bwrap. Works for pty (PtyBackend runs - // bwrap directly) and tmux (the tmux pane's command becomes `bwrap … -- cli`); - // env is carried via bwrap --setenv (see prepareSandbox), not the backend. - // Linux ONLY — on macOS the same `sandbox: true` is enforced by the Seatbelt - // write-sandbox above (willWriteSandbox), so bwrap must not run here. - // - // riff bypass: there is NO local CLI process to wrap — execution happens in - // riff's own remote sandbox, and the fail-safe "backend not sandboxable" - // hard error below would otherwise brick every sandbox-enabled bot the - // moment it switches to riff (the dashboard agent switch clears only - // readIsolation, not `sandbox`). Bypassing is safe (nothing local runs); - // log it so the state is visible. - if (cfg.sandbox === true && effectiveBackendType === 'riff') { - log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local file sandbox bypassed'); - } - const sandboxOn = localSandboxApplies(process.platform, effectiveBackendType) && (cfg.sandbox === true || sandboxEnabled()); - // Linux read isolation: build the bwrap MASK set (the Seatbelt read-deny twin) and - // fold it into the SAME overlay sandbox plan below. Enumerate sibling bots from the - // FILESYSTEM (the worker runs unsandboxed on the host; bots.json may be denied) — - // spawn-time-static, so a bot added after this spawn is covered on its next cold - // start (the accepted Linux tradeoff vs the macOS regex). Only when read isolation - // is enforceable here (willReadIsolate ⇒ readIsolationCtx set) AND on Linux. - let readIsoLinuxMasks: { hidePaths: string[]; ownReadWritePaths: string[]; ownReadOnlyPaths: string[] } | undefined; - if (readIsolationCtx && process.platform === 'linux') { - const canon = (p: string) => { try { return realpathSync(p); } catch { return p; } }; - // Canonicalize the root prefixes FIRST so every derived mask path is symlink-safe - // (bwrap masks resolve symlinks — a symlinked home/data root would else fail-open). - const ctxReal: V2IsolationContext = { - ...readIsolationCtx, - homeDir: canon(readIsolationCtx.homeDir), - botmuxHome: canon(readIsolationCtx.botmuxHome), - sessionDataDir: canon(readIsolationCtx.sessionDataDir), - }; - const ids = new Set(); - const scan = (dir: string, pick: (name: string) => string | null) => { - try { for (const name of readdirSync(dir)) { const id = pick(name); if (id) ids.add(id); } } catch { /* dir absent → no siblings there */ } - }; - scan(join(ctxReal.botmuxHome, 'bots'), n => n); // sibling BOT_HOME dirs - scan(join(ctxReal.homeDir, '.lark-cli-bots'), n => n); // sibling lark configs - scan(ctxReal.sessionDataDir, n => { const m = /^sessions-(.+)\.json$/.exec(n); return m ? m[1] : null; }); - ids.delete(cfg.larkAppId); // never mask own - let sidecars: string[] = []; - try { sidecars = readdirSync(ctxReal.botmuxHome).filter(n => n.startsWith('bots.json.')).map(n => join(ctxReal.botmuxHome, n)); } catch { /* */ } - const m = buildLinuxReadIsolationMasks({ ctx: ctxReal, siblingAppIds: [...ids], botsJsonSidecars: sidecars }); - // Same execvp carve-out as macOS (Codex 342a3e1c): a standalone Codex whose - // binary lives UNDER the now-masked ~/.codex would fail execvp. Re-expose ONLY - // its executable package tree (read-only, after the masks) — auth/config/sessions - // stay masked. No-op for npm/system installs (binary not under ~/.codex) + non-codex. - const execCarveOuts = buildCliExecutableReadCarveOuts({ - homeDir: ctxReal.homeDir, - cliId: cliAdapter.id, - resolvedBin: canon(cliAdapter.resolvedBin), - }).map(canon); - readIsoLinuxMasks = { - hidePaths: m.hidePaths.map(canon), - ownReadWritePaths: m.ownReadWritePaths.map(canon), - ownReadOnlyPaths: [...m.ownReadOnlyPaths.map(canon), ...execCarveOuts], - }; - log(`[read-isolation] linux bwrap masks: ${m.hidePaths.length} hide, ${[...ids].length} siblings enumerated${execCarveOuts.length ? ', +codex-standalone exec carve' : ''}`); - } - if (sandboxOn) { - // FAIL-SAFE (not fail-open): when the sandbox is requested, a missing - // precondition (no SESSION_DATA_DIR, or a backend we can't wrap) must be a - // HARD ERROR, never a silent skip — otherwise the CLI would spawn UNSANDBOXED - // with full host write access and no log, the exact opposite of the - // oncall-untrusted-agent invariant. In normal operation worker-pool sets - // SESSION_DATA_DIR and the backend is pty/tmux, so this never trips. + // ── UNIFIED file sandbox (fs-policy): ONE policy source, BOTH platforms. ── + // Three-tier deny-by-default whitelist compiled to Seatbelt (darwin) or bwrap + // (linux). Cross-bot read isolation is inherent (siblings' data simply isn't + // exposed) — no enumeration, no deny-list to keep in sync. + if (sandboxRequested) { const dataDir = process.env.SESSION_DATA_DIR; + // FAIL-SAFE (not fail-open): a requested sandbox that can't be established + // must be a HARD ERROR, never a silent unconfined run. if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') { const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`; log(msg); @@ -5129,78 +4918,205 @@ function spawnCli(cfg: Extract): void { log(msg); throw new Error(msg); } - try { - if (willReattachPersistent) { - // Daemon-restart reattach to a persistent (tmux/herdr/zellij) pane whose - // bwrap'd CLI is STILL ALIVE. backend.spawn() ignores bin/args here and - // just re-attaches, and the live CLI is bound to its own namespace-pinned - // overlay — so we must NOT unmount/remount (prepareSandbox would leave a - // duplicate host-side overlay the CLI isn't using). We only re-wire the - // outbox watcher (so the live CLI's `botmux send` keeps being serviced) - // and the cleanup ref (so close/exit reclaims the residue). No re-prep. - const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir }); - if (att) { - if (sandboxStopWatcher) { try { sandboxStopWatcher(); } catch { /* */ } } - if (sandboxCleanup) { try { sandboxCleanup(); } catch { /* */ } } - sandboxCleanup = att.cleanup; - sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId); - log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox} (no remount)`); - } else { - // No sandbox tree on disk for a session we're reattaching to: the - // pane's CLI may be unsandboxed (sandbox enabled after it spawned). Do - // NOT remount under a live CLI; just continue the reattach as-is. - log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane without re-prep`); - } + // Both engines match CANONICAL paths (Seatbelt resolves symlinks, bwrap + // resolves mount sources) — realpath everything, or a symlinked prefix + // (the /tmp→/private/tmp class) silently fail-opens. + const canonical = (p: string) => { try { return realpathSync(p); } catch { return p; } }; + const sandboxHome = canonical(homedir()); + const expandTilde = (raw: string) => raw.replace(/^~(?=\/|$)/, sandboxHome); + const keepExisting = (paths: (string | undefined)[]) => { + const out: string[] = []; + for (const raw of paths) { + if (!raw || typeof raw !== 'string') continue; + const p = expandTilde(raw); + try { if (existsSync(p)) out.push(canonical(p)); } catch { /* */ } + } + return out; + }; + + // User three-tier lists: the new sandboxPaths field, or a pre-migration + // session/config's legacy fields mapped through the SAME lossless mapping + // the startup migration uses. + const legacyMapped = migrateLegacySandboxFields({ + sandbox: cfg.sandbox, + readIsolation: cfg.readIsolation, + sandboxReadonlyPaths: cfg.sandboxReadonlyPaths, + sandboxHidePaths: cfg.sandboxHidePaths, + readDenyExtraPaths: cfg.readDenyExtraPaths, + sandboxPaths: cfg.sandboxPaths, + }); + const userLists = cfg.sandboxPaths ?? legacyMapped?.sandboxPaths; + const droppedUser: string[] = []; + const resolveUser = (paths?: readonly string[]) => { + const out: string[] = []; + for (const raw of paths ?? []) { + if (!raw || typeof raw !== 'string') continue; + const p = expandTilde(raw); + if (existsSync(p)) out.push(canonical(p)); + else droppedUser.push(raw); + } + return out; + }; + // deny paths are NOT existence-filtered on EITHER platform: a deny must guard + // a path that does not exist YET (else the agent creates it under an exposed + // rw parent and the deny is bypassed — codex review finding). Seatbelt keeps + // the rule literally; bwrap masks it with a tmpfs the mount creates (writes + // land in the ephemeral tmpfs, the real path is never created). + const resolveDeny = (paths?: readonly string[]) => + (paths ?? []).filter((p): p is string => typeof p === 'string' && !!p).map(p => canonical(expandTilde(p))); + const userPaths = { + readWrite: resolveUser(userLists?.readWrite), + readOnly: resolveUser(userLists?.readOnly), + deny: resolveDeny(userLists?.deny), + }; + if (droppedUser.length) log(`[sandbox] sandboxPaths entries dropped (path not found): ${droppedUser.join(', ')}`); + + // Every executable spawned INSIDE the sandbox must be readable: the CLI + // binary's dir, the daemon's own node (fnm farms under /run land here), + // adapter second-stage bins, plus the standalone-codex package tree. + const execDirs = [cliAdapter.resolvedBin, process.execPath, ...(cliAdapter.sandboxExtraExecPaths?.() ?? [])] + .filter((p): p is string => typeof p === 'string' && !!p) + .map(p => dirname(canonical(p))); + const execCarve = buildCliExecutableReadCarveOuts({ + homeDir: sandboxHome, + cliId: cliAdapter.id, + resolvedBin: canonical(cliAdapter.resolvedBin), + }).map(canonical); + + // Linux relay outbox (created by prepareDirectSandbox; in the policy so the + // compiled plan binds it read-write). + const outbox = process.platform === 'linux' + ? join(canonical(dataDir), 'sandboxes', cfg.sessionId, 'outbox') + : undefined; + + // The botmux install/checkout root (dir containing dist/ + node_modules). + // This module compiles to /dist/worker.js, so `../../` from here is + // the checkout root. Exposed readOnly so `botmux` + claude hooks can exec + // `node /dist/cli.js`. + const botmuxInstallRoot = canonical(dirname(dirname(fileURLToPath(import.meta.url)))); + + // Pre-create the OWN writable dirs the sandboxed CLI creates on demand, so + // they EXIST at spawn and survive the existence-filter below (bwrap can't + // bind a nonexistent source; a dropped rule → the CLI's mkdir/write EPERMs). + // - data/turn-sends: `botmux send` appends its .jsonl dedup marker + // - data/attachments/: `botmux quoted`/downloadResources writes here + // (BOT_HOME + outbox are created elsewhere.) Best-effort — a failure just + // reverts to the pre-existing drop behaviour. + try { mkdirSync(join(dataDir, 'turn-sends'), { recursive: true }); } catch { /* */ } + try { mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true }); } catch { /* */ } + // schedules.json is a shared RMW store (`botmux schedule`); pre-create as an + // empty map if absent (same content schedule-store itself writes) so a fresh + // install's first in-sandbox `schedule add` can bind+write it on bwrap too. + try { const sf = join(dataDir, 'schedules.json'); if (!existsSync(sf)) writeFileSync(sf, '{}'); } catch { /* */ } + + const policy = buildFsPolicy({ + platform: process.platform as 'darwin' | 'linux', + homeDir: sandboxHome, + botmuxHome: canonical(dirname(dataDir)), + sessionDataDir: canonical(dataDir), + workingDir: canonical(cfg.workingDir), + currentAppId: cfg.larkAppId, + botHome: canonical(ownBotHome!), + redirectedCliData: willRedirectCliData, + cliDataPaths: willRedirectCliData ? undefined : keepExisting([ + cliAdapter.claudeDataDir, + claudeDataDir ? `${sandboxHome}/.claude.json` : undefined, + claudeDataDir ? `${sandboxHome}/.claude.json.lock` : undefined, + claudeDataDir ? `${sandboxHome}/.claude.lock` : undefined, + claudeDataDir ? `${sandboxHome}/.local/state/claude` : undefined, + ]), + authPaths: keepExisting([...(cliAdapter.authPaths ?? [])]), + execPaths: keepExisting([...execDirs, ...execCarve]), + readonlyRoots: keepExisting([...(cfg.skillReadonlyRoots ?? [])]), + botmuxInstallRoot, + outbox, + extraWritePaths: keepExisting([process.env.TMPDIR]), + userPaths, + net: cfg.sandboxNetwork !== false, + // Claude Code saves ~/.claude.json atomically via a PID/random-suffixed + // sibling — only relevant when the data dir is NOT redirected to BOT_HOME. + writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir + ? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`] + : [], + }); + // Existence-filter only the ALLOW rules (baseline entries are candidates, + // not guarantees): a non-existent allow path has nothing to expose, and + // bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless — + // they must guard a path created later in the session (Seatbelt keeps the + // literal rule; bwrap masks with a tmpfs whose mountpoint the mount creates). + policy.rules = policy.rules.filter(r => { + if (r.access === 'deny') return true; + try { return existsSync(r.path); } catch { return false; } + }); + + if (process.platform === 'darwin') { + if (!locateOnPath('sandbox-exec')) { + throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`); + } + const profileDir = join(dataDir, 'read-isolation'); + mkdirSync(profileDir, { recursive: true }); + const profilePath = join(profileDir, `${cfg.sessionId}.sb`); + writeFileSync(profilePath, compileToSeatbelt(policy), { mode: 0o600 }); + seatbeltProfilePath = profilePath; + spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs]; + spawnBin = 'sandbox-exec'; + log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (fs-policy, ${policy.rules.length} rules): sandbox-exec -f ${profilePath}`); + } else if (willReattachPersistent) { + // Daemon-restart reattach to a live bwrap'd pane: backend.spawn() ignores + // bin/args and just re-attaches. Only re-wire the outbox watcher (so the + // live CLI's `botmux send` keeps being serviced) + the cleanup ref. The + // direct model has NO mounts — cleanup is a plain rm at close/exit. + const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir }); + if (att) { + if (sandboxStopWatcher) { try { sandboxStopWatcher(); } catch { /* */ } } + sandboxCleanup = att.cleanup; + sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId); + log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox}`); } else { - const sbx = prepareSandbox({ - enabled: sandboxOn, - cliId: cfg.cliId, - sessionId: cfg.sessionId, - sourceWorkingDir: cfg.workingDir, - dataDir, - cliBin: cliAdapter.resolvedBin, - cliArgs: args, - // Read-isolation masks (Linux) fold into the same plan: hide the cross-bot - // sensitive set, keep the own BOT_HOME real+writable (authPaths), and re- - // expose the own attachments bucket read-only after the masks (readonlyRoots). - hidePaths: [...(cfg.sandboxHidePaths ?? []), ...(readIsoLinuxMasks?.hidePaths ?? [])], - authPaths: [...(cliAdapter.authPaths ?? []), ...(readIsoLinuxMasks?.ownReadWritePaths ?? [])], - extraExecPaths: cliAdapter.sandboxExtraExecPaths?.(), - readonlyRoots: [...(cfg.skillReadonlyRoots ?? []), ...(readIsoLinuxMasks?.ownReadOnlyPaths ?? [])], - userReadonlyPaths: cfg.sandboxReadonlyPaths ?? [], - net: cfg.sandboxNetwork !== false, - }); - if (sbx) { - spawnBin = sbx.bin; - spawnArgs = sbx.args; - // In the overlay model the child still chdirs to projectMount (via bwrap - // --chdir), so spawnCwd stays the real workingDir; the overlay merged dir - // is bound there. sbx.workDir is the UPPER changeset (for landing), NOT a cwd. - Object.assign(childEnv, sbx.env); - if (sandboxStopWatcher) { try { sandboxStopWatcher(); } catch { /* */ } } - if (sandboxCleanup) { try { sandboxCleanup(); } catch { /* */ } } - sandboxCleanup = sbx.cleanup; - // session-id is FORCED here so a relayed send can't target another session. - sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId); - log(`Sandbox ON (${cfg.cliId}): upper=${sbx.workDir} outbox=${sbx.outbox}`); - } else { - // Sandbox was requested but prepareSandbox returned null (a required - // overlay mount failed, or non-Linux). Fail safe: do NOT silently run - // unsandboxed — surface a hard error so the session doesn't leak. - log(`Sandbox ENABLED but prepare returned null (mount failed / unsupported) — aborting spawn to avoid an unsandboxed run`); - throw new Error('sandbox requested but could not be established'); - } + log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane as-is`); } - } catch (err: any) { - log(`Sandbox prepare failed (${err.message}) — aborting (sandbox is a hard requirement when enabled)`); - throw err; + } else { + const sbx = prepareDirectSandbox({ + sessionId: cfg.sessionId, + dataDir, + policy, + chdir: canonical(cfg.workingDir), + home: sandboxHome, + cliBin: cliAdapter.resolvedBin, + cliArgs: args, + }); + if (!sbx) { + // FAIL-SAFE: never silently run unsandboxed. + const msg = 'sandbox requested but could not be established (bwrap missing or setup failed) — aborting spawn'; + log(msg); + throw new Error(msg); + } + spawnBin = sbx.bin; + spawnArgs = sbx.args; + Object.assign(childEnv, sbx.env); + if (sandboxStopWatcher) { try { sandboxStopWatcher(); } catch { /* */ } } + if (sandboxCleanup) { try { sandboxCleanup(); } catch { /* */ } } + sandboxCleanup = sbx.cleanup; + // session-id is FORCED here so a relayed send can't target another session. + sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId); + log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`); } } + // Fresh sandboxed spawn on a persistent backend: stamp the pane with this + // daemon's boot id so a later reattach can be trusted (see the stale-pane + // guard above). pty needs no marker (never reattached). + if (sandboxRequested && persistentSessionName && !willReattachPersistent) { + try { + const markerDir = join(process.env.SESSION_DATA_DIR!, 'read-isolation'); + mkdirSync(markerDir, { recursive: true }); + writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), cfg.daemonBootId ?? '', { mode: 0o600 }); + } catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ } + } // 通用启动前缀(wrapperCli):把启动命令重写成 ` `(首 token 当 // bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的 // --settings(见 buildWrappedLaunch)。与文件沙盒互斥:沙盒已把命令重写成 bwrap,叠加 - // 前缀会破坏隔离,故 sandboxOn 时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。 + // 前缀会破坏隔离,故沙盒开启时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。 // CJADK_INTERACTIVE is a cjadk-only knob we set on the cjadk wrapper branch // below. Strip any value inherited from the daemon's own env first so a // daemon launched under `cjadk feishu` (which exports it) can't leak it via @@ -5210,8 +5126,8 @@ function spawnCli(cfg: Extract): void { delete (childEnv as Record).CJADK_INTERACTIVE; if (cfg.wrapperCli && cfg.wrapperCli.trim()) { - if (sandboxOn) { - log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with bwrap)`); + if (sandboxRequested) { + log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`); } else { const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, { ttadkModel: cfg.model, @@ -5287,7 +5203,7 @@ function spawnCli(cfg: Extract): void { // MARKER inference is unaffected (the launcher-pid marker is still a valid // ancestor of an in-CLI `botmux send`, and the env fallback covers it too). const startWrapperRealPidResolve = (launcherPid: number): void => { - if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxOn || !claudeDataDir) return; + if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxRequested || !claudeDataDir) return; const targetCliId = cfg.cliId as CliId; scheduleWrapperRealCliPid(launcherPid, { findRealPid: (lp) => findLaunchedCliPid(lp, targetCliId), @@ -7099,15 +7015,14 @@ process.on('message', async (raw: unknown) => { } catch { /* best-effort */ } backend = null; isPromptReady = false; - // Suspend INTENDS to resume later: preserve the sandbox overlay mount + the - // upper changeset across the suspension (on resume, prepareSandbox re-mounts - // over the SAME upper). So we stop the outbox watcher (no live CLI to serve) - // but DO NOT run sandboxCleanup (which would unmount + rm the changeset). We - // also disarm the exit-time teardown so process.exit(0) below can't reclaim - // it. (Crash/SIGKILL of a suspended-but-active session is still backstopped - // by the daemon's periodic sandbox reconciler.) + // Suspend INTENDS to resume later: keep the per-session sandbox tree + // (outbox etc.) on disk — the resume spawn recreates/reuses it. Stop the + // outbox watcher (no live CLI to serve) but do NOT run sandboxCleanup + // (which rm's the tree); also disarm the exit-time teardown so + // process.exit(0) below can't reclaim it. (Residue of a session that + // never resumes is swept by the daemon's orphan sweep.) if (sandboxStopWatcher) { try { sandboxStopWatcher(); } catch { /* */ } sandboxStopWatcher = null; } - sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the mount) + sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the tree) sandboxTeardownDone = true; // make the process.on('exit') hook a no-op cleanup(); process.exit(0); diff --git a/test/bot-registry.test.ts b/test/bot-registry.test.ts index 20c17fb05..72280a246 100644 --- a/test/bot-registry.test.ts +++ b/test/bot-registry.test.ts @@ -3,7 +3,7 @@ * * Run: pnpm vitest run test/bot-registry.test.ts */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ─── Mocks ──────────────────────────────────────────────────────────────── @@ -333,6 +333,35 @@ describe('getBot / getBotClient', () => { }); }); +describe('resolveBrandLabel — sandbox env-first (footer role name fix)', () => { + let mod: Awaited>; + const saved = { app: process.env.BOTMUX_LARK_APP_ID, brand: process.env.BOTMUX_BRAND_LABEL }; + beforeEach(async () => { mod = await freshImport(); }); + afterEach(() => { + if (saved.app === undefined) delete process.env.BOTMUX_LARK_APP_ID; else process.env.BOTMUX_LARK_APP_ID = saved.app; + if (saved.brand === undefined) delete process.env.BOTMUX_BRAND_LABEL; else process.env.BOTMUX_BRAND_LABEL = saved.brand; + }); + + it('returns the injected env brandLabel for the own appId WITHOUT reading bots.json (the sandbox path)', () => { + process.env.BOTMUX_LARK_APP_ID = 'app_sbx'; + process.env.BOTMUX_BRAND_LABEL = '[{cwdName}]({cwdUrl})'; + // No bot registered, no config path — old code would return undefined; env wins. + expect(mod.resolveBrandLabel('app_sbx')).toBe('[{cwdName}]({cwdUrl})'); + }); + + it('present-but-empty env brandLabel means suppress (returns "")', () => { + process.env.BOTMUX_LARK_APP_ID = 'app_sbx'; + process.env.BOTMUX_BRAND_LABEL = ''; + expect(mod.resolveBrandLabel('app_sbx')).toBe(''); + }); + + it('ignores env when the appId is NOT the current process bot (no cross-bot bleed)', () => { + process.env.BOTMUX_LARK_APP_ID = 'app_self'; + process.env.BOTMUX_BRAND_LABEL = '[self]()'; + expect(mod.resolveBrandLabel('app_other')).toBeUndefined(); + }); +}); + // ─── getAllBots ──────────────────────────────────────────────────────────── describe('getAllBots', () => { diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 8d2cce81b..82b3d4d9f 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -543,7 +543,7 @@ function mockCodexAppBot(): void { describe('DAEMON_COMMANDS set', () => { it('should contain all expected commands', () => { - const expected = ['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/land', '/subscribe-lark-doc', '/watch-comment', '/insight', '/dashboard', '/vc-auth']; + const expected = ['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/insight', '/dashboard', '/vc-auth']; for (const cmd of expected) { expect(DAEMON_COMMANDS.has(cmd), `Expected DAEMON_COMMANDS to contain ${cmd}`).toBe(true); } @@ -572,7 +572,7 @@ describe('DAEMON_COMMANDS set', () => { it('should have the correct size', () => { // 29 = the prior 28 commands + /watch-comment. /subscribe-lark-doc remains // as its original per-file API subscription command rather than an alias. - expect(DAEMON_COMMANDS.size).toBe(29); + expect(DAEMON_COMMANDS.size).toBe(28); }); it('contains the /list-slash-command lister and its /slash alias', () => { diff --git a/test/fs-policy-seatbelt.e2e.test.ts b/test/fs-policy-seatbelt.e2e.test.ts new file mode 100644 index 000000000..5848678b8 --- /dev/null +++ b/test/fs-policy-seatbelt.e2e.test.ts @@ -0,0 +1,79 @@ +/** + * macOS Seatbelt e2e: compile a real FsPolicy → sandbox-exec profile, launch + * real processes inside it, and assert the three access tiers actually hold at + * the kernel level (not just in the pure accessForPath model). Skipped off + * darwin and when sandbox-exec is unavailable. + * + * This is the guard for the class of bug found during the refactor: a blanket + * `(deny file-read* (subpath "/"))` SIGABRTs every process at dyld bootstrap; + * the fix (import Apple's bsd.sb base) must keep working across OS updates. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs'; +import { tmpdir, homedir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { buildFsPolicy, compileToSeatbelt } from '../src/adapters/cli/fs-policy.js'; + +const darwin = process.platform === 'darwin' + && spawnSync('sh', ['-c', 'command -v sandbox-exec'], { stdio: 'ignore' }).status === 0; +const d = darwin ? describe : describe.skip; + +d('Seatbelt three-tier enforcement (real sandbox-exec)', () => { + let S: string, profile: string; + const canonical = (p: string) => { try { return realpathSync(p); } catch { return p; } }; + + beforeAll(() => { + S = canonical(mkdtempSync(join(tmpdir(), 'fsp-e2e-'))); + for (const dir of ['proj/secrets', 'botmux-home/data', 'botmux-home/bots/cli_e2e', 'ref']) { + mkdirSync(join(S, dir), { recursive: true }); + } + writeFileSync(join(S, 'proj/readme.md'), 'proj'); + writeFileSync(join(S, 'proj/secrets/key.txt'), 'secret'); + writeFileSync(join(S, 'ref/doc.md'), 'ref'); + + const home = canonical(homedir()); + const policy = buildFsPolicy({ + platform: 'darwin', homeDir: home, + botmuxHome: join(S, 'botmux-home'), sessionDataDir: join(S, 'botmux-home/data'), + workingDir: join(S, 'proj'), currentAppId: 'cli_e2e', botHome: join(S, 'botmux-home/bots/cli_e2e'), + redirectedCliData: true, + execPaths: [dirname(canonical(process.execPath))], + userPaths: { readOnly: [join(S, 'ref')], deny: [join(S, 'proj/secrets')] }, + net: true, writeRegexes: [], + }); + policy.rules = policy.rules.filter(r => r.access === 'deny' || (() => { try { return require('node:fs').existsSync(r.path); } catch { return false; } })()); + profile = join(S, 'p.sb'); + writeFileSync(profile, compileToSeatbelt(policy)); + }); + afterAll(() => { if (S) rmSync(S, { recursive: true, force: true }); }); + + const allowed = (...argv: string[]) => + spawnSync('sandbox-exec', ['-f', profile, ...argv], { stdio: 'ignore' }).status === 0; + + it('readWrite: reads AND writes the project', () => { + expect(allowed('/bin/cat', join(S, 'proj/readme.md'))).toBe(true); + expect(allowed('/usr/bin/touch', join(S, 'proj/newfile'))).toBe(true); + }); + it('deny hole inside a readWrite tree: no read, no write', () => { + expect(allowed('/bin/cat', join(S, 'proj/secrets/key.txt'))).toBe(false); + }); + it('readOnly: reads but cannot write', () => { + expect(allowed('/bin/cat', join(S, 'ref/doc.md'))).toBe(true); + expect(allowed('/usr/bin/touch', join(S, 'ref/hack'))).toBe(false); + }); + it('crown-jewel credential dirs are denied even though they sit under $HOME', () => { + // deny-by-default proof on REAL sensitive paths: $HOME is never wholesale + // exposed, and these are explicitly re-denied in the baseline. (A generic + // "uncovered temp sibling" can't be tested here — the scratch tree lives + // under /private/var/folders, which the baseline intentionally makes rw as + // the macOS TMPDIR root; that's covered by the pure accessForPath tests.) + expect(allowed('/bin/ls', join(homedir(), '.ssh'))).toBe(false); + expect(allowed('/bin/ls', join(homedir(), '.aws'))).toBe(false); + expect(allowed('/bin/ls', join(homedir(), 'Library/Keychains'))).toBe(false); + }); + it('processes bootstrap under deny-read-default (bsd.sb base): node runs + writes rw', () => { + expect(allowed('node', '-e', 'process.exit(0)')).toBe(true); + expect(allowed('node', '-e', `require('fs').writeFileSync(${JSON.stringify(join(S, 'proj/n.txt'))},'ok')`)).toBe(true); + }); +}); diff --git a/test/fs-policy.test.ts b/test/fs-policy.test.ts new file mode 100644 index 000000000..fcc00bef4 --- /dev/null +++ b/test/fs-policy.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect } from 'vitest'; +import { + buildFsPolicy, + mergeFsRules, + accessForPath, + normalizeFsPath, + coversPath, + ancestorsNeedingTraverse, + compileToSeatbelt, + compileToBwrap, + migrateLegacySandboxFields, + type FsPolicyContext, + type FsRule, +} from '../src/adapters/cli/fs-policy.js'; + +const ctx = (o: Partial = {}): FsPolicyContext => ({ + platform: 'darwin', + homeDir: '/Users/u', + botmuxHome: '/Users/u/.botmux', + sessionDataDir: '/Users/u/.botmux/data', + workingDir: '/Users/u/proj', + currentAppId: 'cli_self', + botHome: '/Users/u/.botmux/bots/cli_self', + redirectedCliData: true, + ...o, +}); + +describe('normalizeFsPath', () => { + it('normalizes and rejects unusable paths', () => { + expect(normalizeFsPath('/a/b/')).toBe('/a/b'); + expect(normalizeFsPath('/')).toBe('/'); + expect(normalizeFsPath('relative/x')).toBeNull(); + expect(normalizeFsPath('/a/../b')).toBeNull(); + expect(normalizeFsPath('')).toBeNull(); + }); +}); + +describe('coversPath', () => { + it('matches self and descendants only', () => { + expect(coversPath('/a', '/a')).toBe(true); + expect(coversPath('/a', '/a/b')).toBe(true); + expect(coversPath('/a', '/ab')).toBe(false); + expect(coversPath('/', '/anything')).toBe(true); + }); +}); + +describe('mergeFsRules + accessForPath (the policy semantics)', () => { + it('deepest rule wins (longest-prefix)', () => { + const rules = mergeFsRules([ + { path: '/Users/u/Library', access: 'readOnly', source: 'baseline' }, + { path: '/Users/u/Library/Application Support/lark-cli', access: 'deny', source: 'baseline' }, + { path: '/Users/u/Library/Application Support', access: 'readWrite', source: 'baseline' }, + ]); + expect(accessForPath(rules, '/Users/u/Library/Fonts/x.ttf').access).toBe('readOnly'); + expect(accessForPath(rules, '/Users/u/Library/Application Support/Code/settings').access).toBe('readWrite'); + expect(accessForPath(rules, '/Users/u/Library/Application Support/lark-cli/master.key.file').access).toBe('deny'); + }); + + it('uncovered paths are inaccessible (deny-by-default)', () => { + const rules = mergeFsRules([{ path: '/opt', access: 'readOnly', source: 'baseline' }]); + expect(accessForPath(rules, '/etc/passwd').access).toBe('none'); + expect(accessForPath(rules, '/opt/x').access).toBe('readOnly'); + }); + + it('white-in-black nesting: allow inside a denied tree', () => { + const rules = mergeFsRules([ + { path: '/data/bots', access: 'deny', source: 'internal' }, + { path: '/data/bots/self', access: 'readWrite', source: 'internal' }, + ]); + expect(accessForPath(rules, '/data/bots/other/secret').access).toBe('deny'); + expect(accessForPath(rules, '/data/bots/self/cred.json').access).toBe('readWrite'); + }); + + it('same path: higher source rank wins; tie → more restrictive wins', () => { + const rules = mergeFsRules([ + { path: '/p', access: 'deny', source: 'baseline' }, + { path: '/p', access: 'readWrite', source: 'user' }, + ]); + expect(accessForPath(rules, '/p/x').access).toBe('readWrite'); + const tie = mergeFsRules([ + { path: '/q', access: 'readWrite', source: 'user' }, + { path: '/q', access: 'deny', source: 'user' }, + ]); + expect(accessForPath(tie, '/q').access).toBe('deny'); + }); + + it('sorts shallow→deep for emission', () => { + const rules = mergeFsRules([ + { path: '/a/b/c', access: 'deny', source: 'user' }, + { path: '/a', access: 'readOnly', source: 'user' }, + { path: '/a/b', access: 'readWrite', source: 'user' }, + ]); + expect(rules.map(r => r.path)).toEqual(['/a', '/a/b', '/a/b/c']); + }); +}); + +describe('buildFsPolicy', () => { + it('darwin baseline: system ro, scratch rw, crown jewels denied, lark-cli store denied', () => { + const p = buildFsPolicy(ctx()); + expect(accessForPath(p.rules, '/System/Library/Frameworks/x').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/usr/bin/env').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/private/var/folders/ab/T/x').access).toBe('readWrite'); + expect(accessForPath(p.rules, '/Users/u/.ssh/id_rsa').access).toBe('deny'); + expect(accessForPath(p.rules, '/Users/u/Library/Application Support/lark-cli/appsecret.enc').access).toBe('deny'); + expect(accessForPath(p.rules, '/Users/u/Library/Keychains/login.keychain').access).toBe('deny'); + // ~/.botmux is NOT exposed wholesale (deny-by-default) — cross-bot secrets + // and unlisted files are simply uncovered ('none' = inaccessible). + expect(accessForPath(p.rules, '/Users/u/.botmux/bots.json').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/bots/cli_other/send-cred.json').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/other-project/secret').access).toBe('none'); + }); + + it('language toolchains under $HOME are readable so python/perl/rust/go/etc. run; their credential files stay denied', () => { + const p = buildFsPolicy(ctx({ platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj' })); + // toolchains runnable (readOnly) + for (const t of ['.pyenv/versions/3.12/bin/python', '.cargo/bin/rg', 'go/bin/tool', '.rbenv/shims/ruby', 'perl5/lib/X.pm', '.rustup/toolchains/x', '.sdkman/candidates/java/x', '.gem/ruby/x', 'Library/Python/3.9/bin/x', '.local/lib/python3.11/site-packages/x']) { + expect(accessForPath(p.rules, `/home/u/${t}`).access).toBe('readOnly'); + } + // but the token/credential files inside them are re-denied (deeper wins) + expect(accessForPath(p.rules, '/home/u/.cargo/credentials.toml').access).toBe('deny'); + expect(accessForPath(p.rules, '/home/u/.gem/credentials').access).toBe('deny'); + expect(accessForPath(p.rules, '/home/u/.m2/settings.xml').access).toBe('deny'); + // toolchains are read-only, not writable (agent runs them, can't tamper the host toolchain) + expect(accessForPath(p.rules, '/home/u/.cargo/registry/x').access).toBe('readOnly'); + }); + + it('botmux CLI runtime surface is an ALLOW-LIST (deny-by-default): install dir + a small ~/.botmux set readable, everything else — incl. creds + cross-bot — inaccessible', () => { + const p = buildFsPolicy(ctx({ botmuxInstallRoot: '/opt/botmux' })); + // install dir readable (hooks exec node /dist/cli.js — verified live: without this, EPERM) + expect(accessForPath(p.rules, '/opt/botmux/dist/cli.js').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/opt/botmux/node_modules/x').access).toBe('readOnly'); + // explicitly allow-listed ~/.botmux reads the CLI/hooks need + expect(accessForPath(p.rules, '/Users/u/.botmux/.dashboard-port').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/Users/u/.botmux/bin/botmux').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/Users/u/.botmux/claude-plugin/x').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/dashboard-daemons/cli_x.json').access).toBe('readOnly'); // daemon IPC discovery + expect(accessForPath(p.rules, '/Users/u/.botmux/data/bots-info.json').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/bot-openids-cli_self.json').access).toBe('readOnly'); // own + expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('readOnly'); // own + expect(accessForPath(p.rules, '/Users/u/.botmux/data/turn-sends/s.jsonl').access).toBe('readWrite'); // CLI appends markers + // own BOT_HOME rw + own attachments ro (allow-listed elsewhere) + expect(accessForPath(p.rules, '/Users/u/.botmux/bots/cli_self/claude/x').access).toBe('readWrite'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/attachments/cli_self/m/f.pdf').access).toBe('readWrite'); // botmux quoted downloads here + // ── everything else under ~/.botmux is DENY-BY-DEFAULT ('none') — no umbrella ── + // credentials (codex critical finding): config.json voice keys, .env, webhook master key + expect(accessForPath(p.rules, '/Users/u/.botmux/config.json').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/.env').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/webhook-master.key').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/webhook-secrets.json').access).toBe('none'); + // cross-bot content/routing (codex high finding) + expect(accessForPath(p.rules, '/Users/u/.botmux/data/schedules.json').access).toBe('readWrite'); // RMW schedule store — owner-accepted cross-bot exposure + expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_other.json').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/bot-openids-cli_other.json').access).toBe('none'); // sibling + expect(accessForPath(p.rules, '/Users/u/.botmux/bots.json').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/bots/cli_other/send-cred.json').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/logs/daemon-0.log').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/attachments/cli_other/m/f.pdf').access).toBe('none'); + // a file created AFTER spawn (codex #3 fail-open) is ALSO denied — allow-list, not enumeration + expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_futureBot.json').access).toBe('none'); + }); + + it('lark-cli key store: OWN appsecret + master.key readable, siblings denied (verified live: without this `lark-cli auth` fails EPERM)', () => { + const p = buildFsPolicy(ctx()); // currentAppId = cli_self + const store = '/Users/u/Library/Application Support/lark-cli'; + // own material re-allowed (deeper than the store deny) + expect(accessForPath(p.rules, `${store}/master.key.file`).access).toBe('readOnly'); + expect(accessForPath(p.rules, `${store}/appsecret_cli_self.enc`).access).toBe('readOnly'); + // siblings' ciphertext + tokens stay denied → master key alone can't decrypt them + expect(accessForPath(p.rules, `${store}/appsecret_cli_other.enc`).access).toBe('deny'); + expect(accessForPath(p.rules, `${store}/cli_other_ou_x.enc`).access).toBe('deny'); + // linux keeps lark keys in ~/.lark-cli-bots/ → no darwin carve-out there + const lin = buildFsPolicy(ctx({ platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj' })); + expect(lin.rules.some(r => r.path.includes('Library/Application Support/lark-cli'))).toBe(false); + }); + + it('internal injections: workingDir + BOT_HOME rw, own session store + attachments ro; siblings uncovered', () => { + const p = buildFsPolicy(ctx()); + expect(accessForPath(p.rules, '/Users/u/proj/src/x.ts').access).toBe('readWrite'); + expect(accessForPath(p.rules, '/Users/u/.botmux/bots/cli_self/claude/x.jsonl').access).toBe('readWrite'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_self.json').access).toBe('readOnly'); + // siblings simply not covered under the allow-list → inaccessible + expect(accessForPath(p.rules, '/Users/u/.botmux/data/sessions-cli_other.json').access).toBe('none'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/attachments/cli_self/m1/f.pdf').access).toBe('readWrite'); + expect(accessForPath(p.rules, '/Users/u/.botmux/data/attachments/cli_other/m1/f.pdf').access).toBe('none'); + }); + + it('user paths take precedence and support nested deny', () => { + const p = buildFsPolicy(ctx({ + userPaths: { + readWrite: ['/Users/u/my-data'], + readOnly: ['/Users/u/ref'], + deny: ['/Users/u/my-data/secrets'], + }, + })); + expect(accessForPath(p.rules, '/Users/u/my-data/a.txt').access).toBe('readWrite'); + expect(accessForPath(p.rules, '/Users/u/my-data/secrets/k').access).toBe('deny'); + expect(accessForPath(p.rules, '/Users/u/ref/doc.md').access).toBe('readOnly'); + }); + + it('non-redirected CLI data stays rw at real paths', () => { + const p = buildFsPolicy(ctx({ + redirectedCliData: false, + cliDataPaths: ['/Users/u/.claude', '/Users/u/.claude.json'], + })); + expect(accessForPath(p.rules, '/Users/u/.claude/projects/x.jsonl').access).toBe('readWrite'); + const redirected = buildFsPolicy(ctx({ redirectedCliData: true, cliDataPaths: ['/Users/u/.claude'] })); + expect(accessForPath(redirected.rules, '/Users/u/.claude/projects/x.jsonl').access).toBe('none'); + }); + + it('linux baseline: toolchain ro, no darwin paths', () => { + const p = buildFsPolicy(ctx({ platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj' })); + expect(accessForPath(p.rules, '/usr/lib/x.so').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/etc/ssl/certs/ca.pem').access).toBe('readOnly'); + expect(p.rules.some(r => r.path.startsWith('/System'))).toBe(false); + }); + + it('net defaults true; false only when explicitly disabled', () => { + expect(buildFsPolicy(ctx()).net).toBe(true); + expect(buildFsPolicy(ctx({ net: false })).net).toBe(false); + }); +}); + +describe('ancestorsNeedingTraverse', () => { + it('collects strict ancestors of non-deny rules only', () => { + const rules = mergeFsRules([ + { path: '/a/b/c', access: 'readWrite', source: 'user' }, + { path: '/x/y', access: 'deny', source: 'user' }, + ]); + const anc = ancestorsNeedingTraverse(rules); + expect(anc).toContain('/a'); + expect(anc).toContain('/a/b'); + expect(anc).toContain('/'); + expect(anc).not.toContain('/x'); // deny-only subtree needs no traverse + expect(anc).not.toContain('/a/b/c'); + }); +}); + +describe('compileToSeatbelt', () => { + const policy = () => buildFsPolicy(ctx({ + userPaths: { deny: ['/Users/u/proj/secrets'] }, + })); + + it('deny-by-default header: (deny default) + Apple bsd.sb base + op re-grants', () => { + const prof = compileToSeatbelt(policy()); + const lines = prof.split('\n'); + expect(lines[0]).toBe('(version 1)'); + expect(lines[1]).toBe('(deny default)'); + expect(lines[2]).toBe('(import "/System/Library/Sandbox/Profiles/bsd.sb")'); + expect(prof).toContain('(allow process*)'); + expect(prof).toContain('(allow network*)'); // net defaults true + }); + + it('omits network grants when net is disabled', () => { + const prof = compileToSeatbelt(buildFsPolicy(ctx({ net: false }))); + expect(prof).not.toContain('(allow network*)'); + }); + + it('deeper rules are emitted later (last-match wins)', () => { + const prof = compileToSeatbelt(policy()); + const rwProj = prof.indexOf('(allow file-write* (subpath "/Users/u/proj"))'); + const denySecrets = prof.indexOf('(deny file-write* (subpath "/Users/u/proj/secrets"))'); + expect(rwProj).toBeGreaterThan(-1); + expect(denySecrets).toBeGreaterThan(rwProj); + }); + + it('ancestor traverse grants come AFTER rules so nested allows survive a broad deny', () => { + const prof = compileToSeatbelt(compilePolicyWithNestedAllow()); + const denyIdx = prof.indexOf('(deny file-read* (subpath "/data/bots"))'); + const metaIdx = prof.indexOf('(allow file-read-metadata (literal "/data/bots"))'); + expect(denyIdx).toBeGreaterThan(-1); + expect(metaIdx).toBeGreaterThan(denyIdx); + }); + + it('readOnly re-asserts write-deny (ro inside rw tree drops write)', () => { + const p = buildFsPolicy(ctx({ userPaths: { readOnly: ['/Users/u/proj/vendor'] } })); + const prof = compileToSeatbelt(p); + const rwProj = prof.indexOf('(allow file-write* (subpath "/Users/u/proj"))'); + const roVendor = prof.indexOf('(deny file-write* (subpath "/Users/u/proj/vendor"))'); + expect(roVendor).toBeGreaterThan(rwProj); + }); + + it('escapes quotes in paths', () => { + const p = buildFsPolicy(ctx({ userPaths: { readOnly: ['/Users/u/we"ird'] } })); + expect(compileToSeatbelt(p)).toContain('\\"'); + }); + + function compilePolicyWithNestedAllow() { + return { + rules: mergeFsRules([ + { path: '/data/bots', access: 'deny', source: 'internal' }, + { path: '/data/bots/self', access: 'readWrite', source: 'internal' }, + ] as FsRule[]), + net: true, + writeRegexes: [], + }; + } +}); + +describe('compileToBwrap', () => { + const opts = { emptiesDir: '/sbx/empties', chdir: '/home/u/proj' }; + + it('tmpfs root + primitives + ordered binds', () => { + const p = buildFsPolicy(ctx({ platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj' })); + const { args } = compileToBwrap(p, opts); + expect(args.slice(0, 2)).toEqual(['--tmpfs', '/']); + expect(args).toContain('--proc'); + const roUsr = args.indexOf('/usr'); + expect(args[roUsr - 1]).toBe('--ro-bind'); + const rwProj = args.indexOf('/home/u/proj'); + expect(args[rwProj - 1]).toBe('--bind'); + expect(args).toContain('--chdir'); + }); + + it('deny under an exposed tree masks with tmpfs; unreachable deny is skipped', () => { + const p = buildFsPolicy(ctx({ + platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', + botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj', + userPaths: { deny: ['/home/u/proj/secrets', '/home/u/never-exposed/x'] }, + })); + const { args } = compileToBwrap(p, opts); + const mask = args.indexOf('/home/u/proj/secrets'); + expect(args[mask - 1]).toBe('--tmpfs'); + const bind = args.indexOf('/home/u/proj'); + expect(mask).toBeGreaterThan(bind); // deeper mask after the bind it punches + expect(args).not.toContain('/home/u/never-exposed/x'); + }); + + it('masks a NONEXISTENT deny under an exposed parent (codex finding: agent must not create+access it)', () => { + const p = buildFsPolicy(ctx({ + platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', + botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj', + userPaths: { deny: ['/home/u/proj/secrets'] }, + })); + // worker keeps deny rules even when the target does not exist yet; filePaths + // is empty because statSync fails on a nonexistent path → tmpfs branch. + const { args } = compileToBwrap(p, { ...opts, filePaths: new Set() }); + const mask = args.indexOf('/home/u/proj/secrets'); + expect(args[mask - 1]).toBe('--tmpfs'); // masked, not skipped + expect(mask).toBeGreaterThan(args.indexOf('/home/u/proj')); // after the rw bind it punches + }); + + it('file-shaped deny uses an empty ro-bind and reports the needed file', () => { + const p = buildFsPolicy(ctx({ + platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', + botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj', + userPaths: { deny: ['/home/u/proj/.env'] }, + })); + const { args, emptyFiles } = compileToBwrap(p, { ...opts, filePaths: new Set(['/home/u/proj/.env']) }); + expect(emptyFiles).toHaveLength(1); + expect(emptyFiles[0].maskedPath).toBe('/home/u/proj/.env'); + const i = args.indexOf('/home/u/proj/.env'); + expect(args[i - 1]).toBe(emptyFiles[0].path); + expect(args[i - 2]).toBe('--ro-bind'); + }); + + it('replicates usrmerge symlinks and honors net=false', () => { + const p = { ...buildFsPolicy(ctx({ platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/x', botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj', net: false })) }; + const { args } = compileToBwrap(p, { ...opts, symlinks: [{ path: '/bin', target: 'usr/bin' }] }); + const i = args.indexOf('--symlink'); + expect(args.slice(i, i + 3)).toEqual(['--symlink', 'usr/bin', '/bin']); + expect(args).toContain('--unshare-net'); + }); +}); + +describe('migrateLegacySandboxFields', () => { + it('maps old fields losslessly and keeps sandbox truthiness', () => { + const m = migrateLegacySandboxFields({ + sandbox: true, + sandboxReadonlyPaths: ['~/ref'], + sandboxHidePaths: ['~/.ssh'], + readDenyExtraPaths: ['~/.aws', '~/.ssh'], + }); + expect(m).toEqual({ + sandbox: true, + sandboxPaths: { readOnly: ['~/ref'], deny: ['~/.ssh', '~/.aws'] }, + }); + }); + + it('readIsolation:true alone → sandbox:true (absorbed)', () => { + expect(migrateLegacySandboxFields({ readIsolation: true })).toEqual({ sandbox: true }); + }); + + it('no-ops when already migrated or nothing legacy present', () => { + expect(migrateLegacySandboxFields({ sandbox: true, sandboxPaths: {} })).toBeNull(); + expect(migrateLegacySandboxFields({ sandbox: true })).toBeNull(); + expect(migrateLegacySandboxFields({})).toBeNull(); + }); +}); + +describe('compiler parity with accessForPath', () => { + it('a nested white-in-black policy yields consistent structures on both engines', () => { + const p = buildFsPolicy(ctx({ + platform: 'linux', homeDir: '/home/u', botHome: '/home/u/.botmux/bots/cli_self', + botmuxHome: '/home/u/.botmux', sessionDataDir: '/home/u/.botmux/data', workingDir: '/home/u/proj', + userPaths: { readOnly: ['/srv/ref'], deny: ['/srv/ref/private'] }, + })); + // semantic truth + expect(accessForPath(p.rules, '/srv/ref/a').access).toBe('readOnly'); + expect(accessForPath(p.rules, '/srv/ref/private/b').access).toBe('deny'); + // bwrap: ro-bind then deeper tmpfs mask + const { args } = compileToBwrap(p, { emptiesDir: '/e', chdir: '/home/u/proj' }); + expect(args.indexOf('/srv/ref/private')).toBeGreaterThan(args.indexOf('/srv/ref')); + // seatbelt: allow then deeper deny + const prof = compileToSeatbelt(p); + expect(prof.indexOf('(deny file-read* (subpath "/srv/ref/private"))')) + .toBeGreaterThan(prof.indexOf('(allow file-read* (subpath "/srv/ref"))')); + }); +}); diff --git a/test/read-isolation.test.ts b/test/read-isolation.test.ts index 05a38a576..7cbc99e9b 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -1,39 +1,14 @@ import { describe, it, expect } from 'vitest'; import { - buildSeatbeltProfile, evaluateReadIsolationGate, isolatedPaneReattachSafe, botHomePath, - buildV2DenyPaths, - buildV2DenyRegexes, - buildV2CarveOuts, buildCliExecutableReadCarveOuts, - buildWriteSandboxRules, - buildLinuxReadIsolationMasks, sendCredFilePath, assertSafeAppId, normalizeIsolationPath, - type V2IsolationContext, - type WriteSandboxContext, } from '../src/adapters/cli/read-isolation.js'; -const ws = (o: Partial = {}): WriteSandboxContext => ({ - homeDir: '/Users/bot', - botmuxHome: '/Users/bot/.botmux', - sessionDataDir: '/Users/bot/.botmux/data', - workingDir: '/Users/bot/projects/app', - currentAppId: 'cli_self', - ...o, -}); - -const v2 = (o: Partial = {}): V2IsolationContext => ({ - homeDir: '/Users/bot', - botmuxHome: '/Users/bot/.botmux', - sessionDataDir: '/Users/bot/.botmux/data', - currentAppId: 'cli_self', - ...o, -}); - describe('normalizeIsolationPath (path hardening)', () => { it('drops relative / traversal paths instead of silently keeping them', () => { expect(normalizeIsolationPath('relative/x')).toBeNull(); @@ -46,6 +21,7 @@ describe('normalizeIsolationPath (path hardening)', () => { }); }); + describe('buildCliExecutableReadCarveOuts', () => { it('re-opens only the standalone Codex package tree when the canonical binary lives there', () => { expect(buildCliExecutableReadCarveOuts({ @@ -66,241 +42,25 @@ describe('buildCliExecutableReadCarveOuts', () => { }); }); -describe('v2 HYBRID model (buildV2DenyPaths)', () => { + +describe('per-bot private storage primitives', () => { it('botHomePath is per-appId under BOTMUX_HOME/bots', () => { expect(botHomePath('/Users/bot/.botmux', 'cli_self')).toBe('/Users/bot/.botmux/bots/cli_self'); + expect(botHomePath('/Users/bot/.botmux/', 'cli_self')).toBe('/Users/bot/.botmux/bots/cli_self'); }); - it('WHOLE-denies the CLI data dirs (F1): ~/.claude, ~/.claude.json, ~/.codex', () => { - const d = buildV2DenyPaths(v2()); - expect(d).toContain('/Users/bot/.claude'); - expect(d).toContain('/Users/bot/.claude.json'); - expect(d).toContain('/Users/bot/.codex'); - }); - - it('denies the broadened system-credential set (review M1)', () => { - const d = buildV2DenyPaths(v2()); - for (const p of ['/Users/bot/.ssh', '/Users/bot/.aws', '/Users/bot/Library/Keychains', - '/Users/bot/.gnupg', '/Users/bot/.netrc', '/Users/bot/.config/gcloud', - '/Users/bot/.config/1Password', '/Users/bot/.password-store']) expect(d).toContain(p); - }); - - it('SURGICAL on ~/.botmux: denies the cross-bot sensitive parts, NOT the whole tree', () => { - const d = buildV2DenyPaths(v2()); - // denied: secrets + other bots + content - expect(d).toContain('/Users/bot/.botmux/bots.json'); - expect(d).toContain('/Users/bot/.botmux/logs'); - expect(d).toContain('/Users/bot/.lark-cli-bots'); // WHOLESALE (covers every sibling) - // WHOLESALE deny of bots/ — covers EVERY sibling BOT_HOME, including bots added - // AFTER this bot spawned (no cold-restart to pick them up). No per-sibling entries. - expect(d).toContain('/Users/bot/.botmux/bots'); - expect(d).toContain('/Users/bot/.botmux/data/sessions.json'); // legacy shared store - expect(d).toContain('/Users/bot/.botmux/data/frozen-cards'); - expect(d).toContain('/Users/bot/.botmux/data/turn-sends'); - expect(d).toContain('/Users/bot/.botmux/data/queues'); // all bots' inbound message content - expect(d).toContain('/Users/bot/.botmux/data/read-isolation'); // profiles enumerate sibling sessions - // schedules.json is a read-modify-write store — denying the read makes a - // sandboxed `botmux schedule` load an empty map then overwrite the shared - // file, wiping every bot's tasks. Deliberately NOT denied (accept the minor - // leak of others' scheduled prompts) until schedule-store fail-closes. PR #387. - expect(d).not.toContain('/Users/bot/.botmux/data/schedules.json'); - expect(d).toContain('/Users/bot/.botmux/feishu-session.json'); // Feishu web login session (can mint bots) - expect(d).toContain('/Users/bot/.botmux/.dashboard-secret'); // loopback-HMAC signing key (mints write tokens) - expect(d).toContain('/Users/bot/.botmux/.dashboard-token'); // dashboard admin bearer token - // `.dashboard-port` is a bare port number (no credential value) — must stay readable - expect(d).not.toContain('/Users/bot/.botmux/.dashboard-port'); - // NO per-sibling enumeration anywhere: sibling session stores are covered by the - // filename-pattern regex (buildV2DenyRegexes), not per-appId path entries. - expect(d.some((p) => p.includes('cli_other'))).toBe(false); - // NOT denied — the whole tree, own data, and the tooling botmux CLI needs - expect(d).not.toContain('/Users/bot/.botmux'); // never whole-denied - expect(d).not.toContain('/Users/bot/.botmux/data'); // data dir listable - expect(d).not.toContain('/Users/bot/.botmux/bots/cli_self'); // own not a plain deny; re-allowed via carve-out - expect(d).not.toContain('/Users/bot/.botmux/data/sessions-cli_self.json'); - expect(d).not.toContain('/Users/bot/.botmux/config.json'); // config readable - expect(d).not.toContain('/Users/bot/.lark-cli-bots/cli_self'); // own lark readable - }); - - it('denies per-bot session stores by filename PATTERN, re-allows only the own one', () => { - // The regex covers EVERY sessions-.json — including bots added later — - // without any sibling-appId enumeration. - const regexes = buildV2DenyRegexes(v2()); - expect(regexes[0]).toBe('^/Users/bot/\\.botmux/data/sessions-[^/]+\\.json$'); - const re = new RegExp(regexes[0]); - expect(re.test('/Users/bot/.botmux/data/sessions-cli_other1.json')).toBe(true); - expect(re.test('/Users/bot/.botmux/data/sessions-cli_self.json')).toBe(true); // own matches too… - expect(re.test('/Users/bot/.botmux/data/sessions.json')).toBe(false); // legacy handled by path deny - expect(re.test('/Users/bot/.botmux/data/sub/sessions-x.json')).toBe(false); // same dir only - // …but the own file is re-opened by a carve-out allow (Seatbelt last-match). - expect(buildV2CarveOuts(v2()).allowPaths).toContain('/Users/bot/.botmux/data/sessions-cli_self.json'); - }); - - it('denies every bots.json SIDECAR (backup/temp) — .bak carries all siblings secrets', () => { - // Regression: the exact bots.json is subpath-denied, but its setup/migration - // backups (bots.json.bak, .bak., .tmp) carry the SAME plaintext - // larkAppSecret for every bot under a different basename, which subpath does - // NOT match. Without the sidecar regex an isolated bot could `cat bots.json.bak` - // and recover all siblings' credentials. - const re = new RegExp(buildV2DenyRegexes(v2())[1]); - expect(re.test('/Users/bot/.botmux/bots.json.bak')).toBe(true); - expect(re.test('/Users/bot/.botmux/bots.json.bak.isotest.1783089554')).toBe(true); - expect(re.test('/Users/bot/.botmux/bots.json.tmp')).toBe(true); - // The exact file is the subpath deny's job (not this regex), and unrelated - // basenames must not be swept in. - expect(re.test('/Users/bot/.botmux/bots.json')).toBe(false); - expect(re.test('/Users/bot/.botmux/bots.jsonx')).toBe(false); - // The exact bots.json is still covered by the path deny. - expect(buildV2DenyPaths(v2())).toContain('/Users/bot/.botmux/bots.json'); - }); - - it('own attachments bucket re-allowed under the wholesale attachments/ deny (Feishu uploads)', () => { - // The agent must read files the user uploads in chat. attachments/ is keyed - // per-appId (getAttachmentsDir) precisely because the Seatbelt profile is - // static at spawn time — only a spawn-time-known key (the appId) can anchor - // the carve-out. Siblings' buckets and the legacy flat attachments/ - // layout stay under the wholesale deny. - const d = buildV2DenyPaths(v2()); - expect(d).toContain('/Users/bot/.botmux/data/attachments'); - const carve = buildV2CarveOuts(v2()); - expect(carve.allowPaths).toContain('/Users/bot/.botmux/data/attachments/cli_self'); - // traverse shim so Read/realpath can stat through the denied parent without listing it - expect(carve.traverseDirs).toContain('/Users/bot/.botmux/data/attachments'); - }); - - it('denies LEGACY data-root send-cred files by pattern (pre-BOT_HOME leftovers)', () => { - // Older builds wrote `/.send-cred-` at the data root; current builds - // write inside BOT_HOME. The leftovers still hold live send credentials and the - // data root is readable by design — so the legacy filename class must be denied. - const re = new RegExp(buildV2DenyRegexes(v2()).find(r => r.includes('send-cred'))!); - expect(re.test('/Users/bot/.botmux/data/.send-cred-cli_other1')).toBe(true); - expect(re.test('/Users/bot/.botmux/data/.send-cred-cli_self')).toBe(true); // own legacy too — CLI reads BOT_HOME copy - expect(re.test('/Users/bot/.botmux/data/send-cred.json')).toBe(false); - }); - - it('denies every identities-.json (interlocutor PII cache) — no own carve-out', () => { - // open_id→display-name caches are daemon-side prompt-injection data; the CLI - // never reads them, so unlike sessions-.json the OWN file gets no allow. - const re = new RegExp(buildV2DenyRegexes(v2()).find(r => r.includes('identities'))!); - expect(re.test('/Users/bot/.botmux/data/identities-cli_other1.json')).toBe(true); - expect(re.test('/Users/bot/.botmux/data/identities-cli_self.json')).toBe(true); - expect(re.test('/Users/bot/.botmux/data/sub/identities-x.json')).toBe(false); // same dir only - const carve = buildV2CarveOuts(v2()); - expect(carve.allowPaths.some(p => p.includes('identities-'))).toBe(false); - }); - - it('send-cred lives inside BOT_HOME (unified per-bot private storage)', () => { - // The botmux-send credential is stored in the bot's BOT_HOME, the SAME private - // storage as its CLI data — one deny mechanism protects both (and any future - // per-bot secret, e.g. a github token, dropped in there). sendCredFilePath takes - // SESSION_DATA_DIR and derives BOTMUX_HOME (its parent) internally, matching the - // worker's BOT_HOME = botHomePath(dirname(SESSION_DATA_DIR)). + it('send-cred lives inside BOT_HOME and follows a customized SESSION_DATA_DIR', () => { expect(sendCredFilePath('/Users/bot/.botmux/data', 'cli_self')) .toBe('/Users/bot/.botmux/bots/cli_self/send-cred.json'); - const d = buildV2DenyPaths(v2()); - // an OTHER bot's send-cred sits under its BOT_HOME → covered by the wholesale bots/ deny - expect(sendCredFilePath('/Users/bot/.botmux/data', 'cli_other2')) - .toBe('/Users/bot/.botmux/bots/cli_other2/send-cred.json'); - expect(d).toContain('/Users/bot/.botmux/bots'); - // own send-cred is under own BOT_HOME → readable via the carve-out - expect(buildV2CarveOuts(v2()).allowPaths).toContain('/Users/bot/.botmux/bots/cli_self'); - }); - - it('send-cred path follows a customized SESSION_DATA_DIR (review: codex)', () => { - // BOTMUX_HOME is defined as dirname(SESSION_DATA_DIR); a custom data dir must - // still resolve worker-write and deny to the SAME BOT_HOME — no hardcoded ~/.botmux. - expect(sendCredFilePath('/var/botmux/data', 'cli_x')) - .toBe('/var/botmux/bots/cli_x/send-cred.json'); - // the session-store regex must also follow the custom data dir - expect(buildV2DenyRegexes(v2({ sessionDataDir: '/var/botmux/data' }))[0]) - .toBe('^/var/botmux/data/sessions-[^/]+\\.json$'); - }); - - it('buildV2CarveOuts: own slices allowed + traverse shims + extraDenyPaths as FINAL deny', () => { - const carve = buildV2CarveOuts(v2()); - // own slice of EACH wholesale/pattern-denied per-bot class re-allowed - expect(carve.allowPaths).toEqual([ - '/Users/bot/.botmux/bots/cli_self', - '/Users/bot/.lark-cli-bots/cli_self', - '/Users/bot/.botmux/data/sessions-cli_self.json', - '/Users/bot/.botmux/data/attachments/cli_self', - ]); - // traverse shim on each wholesale-denied parent (stat/realpath, not listing) - expect(carve.traverseDirs).toEqual([ - '/Users/bot/.botmux/bots', - '/Users/bot/.lark-cli-bots', - '/Users/bot/.botmux/data/attachments', - ]); - expect(carve.finalDenyPaths).toEqual([]); - // an admin deny UNDER the own BOT_HOME must WIN over the carve-out → goes to finalDeny - const extra = '/Users/bot/.botmux/bots/cli_self/claude/.credentials.json'; - const c2 = buildV2CarveOuts(v2({ extraDenyPaths: [extra] })); - expect(c2.finalDenyPaths).toContain(extra); - expect(buildV2DenyPaths(v2({ extraDenyPaths: [extra] }))).not.toContain(extra); // not a plain deny - }); - - it('drops relative / traversal extraDenyPaths instead of silently keeping them', () => { - const c = buildV2CarveOuts(v2({ extraDenyPaths: ['relative/x', '/a/../b', '/ok/path'] })); - expect(c.finalDenyPaths).toEqual(['/ok/path']); - }); - - it('assertSafeAppId rejects path-traversal / separators, accepts real Feishu ids (review L2)', () => { - expect(assertSafeAppId('cli_aab4eaea67395bc9')).toBe('cli_aab4eaea67395bc9'); - expect(() => assertSafeAppId('../evil')).toThrow(); - expect(() => assertSafeAppId('a/b')).toThrow(); - expect(() => assertSafeAppId('')).toThrow(); - // pure-dot ids are path-traversal segments: as a carve-out subpath, `bots/..` - // canonicalizes to the PARENT (~/.botmux) and — emitted after the deny — would - // re-open bots.json / bots/ / logs. Must be rejected (codex review). - expect(() => assertSafeAppId('.')).toThrow(); - expect(() => assertSafeAppId('..')).toThrow(); - expect(() => assertSafeAppId('...')).toThrow(); - expect(() => buildV2CarveOuts(v2({ currentAppId: '..' }))).toThrow(); - // botHomePath must also reject an unsafe id (used for own + other BOT_HOMEs) - expect(() => botHomePath('/Users/bot/.botmux', '../x')).toThrow(); - }); -}); - -describe('buildSeatbeltProfile (verified format)', () => { - it('emits allow-default + a file-read deny subpath per denied path', () => { - const prof = buildSeatbeltProfile(buildV2DenyPaths(v2())); - expect(prof).toContain('(version 1)'); - expect(prof).toContain('(allow default)'); - expect(prof).toContain('(deny file-read* (subpath "/Users/bot/.botmux/bots.json"))'); - expect(prof).toContain('(deny file-read* (subpath "/Users/bot/.botmux/bots"))'); - }); - - it('emits carve-out allows AFTER the denies (last-match wins) so own slices re-open', () => { - const carve = buildV2CarveOuts(v2()); - const prof = buildSeatbeltProfile( - buildV2DenyPaths(v2()), carve.allowPaths, carve.finalDenyPaths, carve.traverseDirs, - buildV2DenyRegexes(v2())); - expect(prof).toContain('(deny file-read* (subpath "/Users/bot/.botmux/bots"))'); - // traverse: realpath/stat through bots/ works, but LISTING (read-data) stays denied - expect(prof).toContain('(allow file-read-metadata (literal "/Users/bot/.botmux/bots"))'); - expect(prof).toContain('(allow file-read* (subpath "/Users/bot/.botmux/bots/cli_self"))'); - // Seatbelt last-match-wins: own-allow MUST appear after the bots/ deny - expect(prof.indexOf('(allow file-read* (subpath "/Users/bot/.botmux/bots/cli_self"))')) - .toBeGreaterThan(prof.indexOf('(deny file-read* (subpath "/Users/bot/.botmux/bots"))')); - // pattern deny present (backslashes RAW inside the #"…" literal), and the own - // session-file allow comes after it - expect(prof).toContain('(deny file-read* (regex #"^/Users/bot/\\.botmux/data/sessions-[^/]+\\.json$"))'); - expect(prof.indexOf('(allow file-read* (subpath "/Users/bot/.botmux/data/sessions-cli_self.json"))')) - .toBeGreaterThan(prof.indexOf('(deny file-read* (regex')); - }); - - it('FINAL denies come after the allows so admin extraDenyPaths win over the own carve-out', () => { - const extra = '/Users/bot/.botmux/bots/cli_self/claude/.credentials.json'; - const carve = buildV2CarveOuts(v2({ extraDenyPaths: [extra] })); - const prof = buildSeatbeltProfile( - buildV2DenyPaths(v2()), carve.allowPaths, carve.finalDenyPaths, carve.traverseDirs); - expect(prof.indexOf(`(deny file-read* (subpath "${extra}"))`)) - .toBeGreaterThan(prof.indexOf('(allow file-read* (subpath "/Users/bot/.botmux/bots/cli_self"))')); + expect(sendCredFilePath('/srv/custom-data', 'cli_self')) + .toBe('/srv/bots/cli_self/send-cred.json'); }); - it('no allowPaths → deny-only profile', () => { - const prof = buildSeatbeltProfile(['/Users/bot/.botmux/bots.json']); - expect(prof).toContain('(deny file-read* (subpath "/Users/bot/.botmux/bots.json"))'); - expect(prof).not.toContain('(allow file-read* (subpath'); + it('assertSafeAppId rejects path-traversal / separators, accepts real Feishu ids', () => { + expect(assertSafeAppId('cli_a1b2c3')).toBe('cli_a1b2c3'); + for (const bad of ['a/b', '..', '.', '...', 'x/../y', '']) { + expect(() => assertSafeAppId(bad)).toThrow(); + } }); }); @@ -351,123 +111,6 @@ describe('evaluateReadIsolationGate (fail-closed, single decision point)', () => }); }); -describe('macOS write-sandbox (buildWriteSandboxRules)', () => { - it('allows the project + CLI scratch/cache, protects home & other bots', () => { - const r = buildWriteSandboxRules(ws()); - // the project persists writes (the whole point) - expect(r.allowWritePaths).toContain('/Users/bot/projects/app'); - // own BOT_HOME (where read-iso redirects CLI data when co-enabled) - expect(r.allowWritePaths).toContain('/Users/bot/.botmux/bots/cli_self'); - // CLI data + ephemeral scratch the CLI/tools need - expect(r.allowWritePaths).toContain('/Users/bot/.claude'); - expect(r.allowWritePaths).toContain('/Users/bot/.codex'); - expect(r.allowWritePaths).toContain('/Users/bot/.claude.lock'); - expect(r.allowWritePaths).toContain('/Users/bot/.claude.json.lock'); - expect(r.allowWritePaths).toContain('/Users/bot/.local/state/claude'); - expect(r.allowWriteRegexes).toContain('^/Users/bot/\\.claude\\.json\\.tmp\\.[^/]+$'); - expect(r.allowWritePaths).toContain('/private/var/folders'); - expect(r.allowWritePaths).toContain('/dev'); - // NOT writable: home dotfiles at large are protected by the profile's deny-all - // baseline (they simply never appear in the allow-list) - expect(r.allowWritePaths).not.toContain('/Users/bot'); - expect(r.allowWritePaths).not.toContain('/Users/bot/.ssh'); - }); - - it('re-denies crown jewels so a broad project/home cannot reach them', () => { - const r = buildWriteSandboxRules(ws()); - expect(r.denyWritePaths).toContain('/Users/bot/.ssh'); - expect(r.denyWritePaths).toContain('/Users/bot/.aws'); - expect(r.denyWritePaths).toContain('/Users/bot/.botmux/bots.json'); // can't tamper other bots' creds - expect(r.denyWritePaths).toContain('/Users/bot/.botmux/.dashboard-secret'); - }); - - it('folds extraWritePaths (custom TMPDIR / worktrees) and drops unsafe ones', () => { - const r = buildWriteSandboxRules(ws({ extraWritePaths: ['/custom/tmp', 'relative/x', '/a/../b'] })); - expect(r.allowWritePaths).toContain('/custom/tmp'); - expect(r.allowWritePaths).not.toContain('relative/x'); // normalizeIsolationPath drops it - expect(r.allowWritePaths.some(p => p.includes('..'))).toBe(false); - }); - - it('buildSeatbeltProfile emits deny-all-writes + allow-list + final crown-jewel denies, in order', () => { - const prof = buildSeatbeltProfile([], [], [], [], [], { - allowWritePaths: ['/Users/bot/projects/app'], - allowWriteRegexes: ['^/Users/bot/\\.claude\\.json\\.tmp\\.[^/]+$'], - denyWritePaths: ['/Users/bot/.ssh'], - }); - expect(prof).toContain('(deny file-write* (subpath "/"))'); - expect(prof).toContain('(allow file-write* (subpath "/Users/bot/projects/app"))'); - expect(prof).toContain('(allow file-write* (regex #"^/Users/bot/\\.claude\\.json\\.tmp\\.[^/]+$"))'); - expect(prof).toContain('(deny file-write* (subpath "/Users/bot/.ssh"))'); - // ORDER matters (Seatbelt last-match wins): deny-all < allow project < final deny ssh - const iDenyAll = prof.indexOf('(deny file-write* (subpath "/"))'); - const iAllow = prof.indexOf('(allow file-write* (subpath "/Users/bot/projects/app"))'); - const iDenySsh = prof.indexOf('(deny file-write* (subpath "/Users/bot/.ssh"))'); - expect(iDenyAll).toBeLessThan(iAllow); - expect(iAllow).toBeLessThan(iDenySsh); - // reads untouched — no file-read denies when only write-sandbox is passed - expect(prof).not.toContain('(deny file-read*'); - }); - - it('omitting the write param leaves a read-only profile with NO write rules', () => { - const prof = buildSeatbeltProfile(['/Users/bot/.ssh']); - expect(prof).toContain('(deny file-read* (subpath "/Users/bot/.ssh"))'); - expect(prof).not.toContain('file-write*'); - }); -}); - -describe('Linux read isolation (buildLinuxReadIsolationMasks)', () => { - it('masks the shared cross-bot sensitive set + per-sibling paths; own BOT_HOME stays real+writable', () => { - const r = buildLinuxReadIsolationMasks({ ctx: v2(), siblingAppIds: ['cli_other1', 'cli_other2'] }); - // shared sensitive (non-per-bot) - for (const p of ['/Users/bot/.claude', '/Users/bot/.codex', '/Users/bot/.ssh', - '/Users/bot/.botmux/bots.json', '/Users/bot/.botmux/feishu-session.json', - '/Users/bot/.botmux/.dashboard-secret', '/Users/bot/.botmux/data/frozen-cards', - '/Users/bot/.botmux/data/queues']) expect(r.hidePaths).toContain(p); - // per-sibling (enumerated — no regex on bwrap) - for (const p of ['/Users/bot/.botmux/bots/cli_other1', '/Users/bot/.lark-cli-bots/cli_other2', - '/Users/bot/.botmux/data/sessions-cli_other1.json', '/Users/bot/.botmux/data/identities-cli_other2.json', - '/Users/bot/.botmux/data/.send-cred-cli_other1']) expect(r.hidePaths).toContain(p); - // attachments/ masked WHOLESALE (covers legacy flat layout too); own bucket re-exposed RO - expect(r.hidePaths).toContain('/Users/bot/.botmux/data/attachments'); - expect(r.ownReadOnlyPaths).toEqual(['/Users/bot/.botmux/data/attachments/cli_self']); - // OWN slice is NOT masked (readable via the overlay lower) - expect(r.hidePaths).not.toContain('/Users/bot/.botmux/bots/cli_self'); - expect(r.hidePaths).not.toContain('/Users/bot/.botmux/data/sessions-cli_self.json'); - expect(r.hidePaths).not.toContain('/Users/bot/.lark-cli-bots/cli_self'); - // own identities/send-cred ARE masked (daemon-side only, no own carve-out — parity w/ macOS) - expect(r.hidePaths).toContain('/Users/bot/.botmux/data/identities-cli_self.json'); - expect(r.hidePaths).toContain('/Users/bot/.botmux/data/.send-cred-cli_self'); - // own BOT_HOME kept real+writable (persists redirected CLI data) - expect(r.ownReadWritePaths).toEqual(['/Users/bot/.botmux/bots/cli_self']); - // NOT masked: schedules.json + whiteboards (same owner decision as macOS) - expect(r.hidePaths).not.toContain('/Users/bot/.botmux/data/schedules.json'); - expect(r.hidePaths).not.toContain('/Users/bot/.botmux/data/whiteboards'); - }); - - it('PARITY: every non-per-bot path macOS denies is also masked on Linux', () => { - // Guard against the two platforms drifting: any shared-sensitive path added to - // buildV2DenyPaths must also appear in the Linux mask set. The per-bot WHOLESALE - // dirs (bots/, .lark-cli-bots/) are the only ones handled differently (enumerated - // per-sibling), so exclude just those two from the comparison. - const macDeny = buildV2DenyPaths(v2()); - const linux = buildLinuxReadIsolationMasks({ ctx: v2(), siblingAppIds: [] }).hidePaths; - const wholesalePerBot = new Set(['/Users/bot/.botmux/bots', '/Users/bot/.lark-cli-bots']); - const shouldMatch = macDeny.filter(p => !wholesalePerBot.has(p)); - for (const p of shouldMatch) expect(linux).toContain(p); - }); - - it('folds bots.json sidecars + skips unsafe sibling ids', () => { - const r = buildLinuxReadIsolationMasks({ - ctx: v2(), - siblingAppIds: ['cli_ok', '../evil', 'bad/id'], - botsJsonSidecars: ['/Users/bot/.botmux/bots.json.bak', '/Users/bot/.botmux/bots.json.tmp'], - }); - expect(r.hidePaths).toContain('/Users/bot/.botmux/bots.json.bak'); - expect(r.hidePaths).toContain('/Users/bot/.botmux/bots/cli_ok'); - // unsafe sibling ids are dropped, never concatenated into a mask path - expect(r.hidePaths.some(p => p.includes('evil') || p.includes('bad'))).toBe(false); - }); -}); describe('isolatedPaneReattachSafe', () => { it('trusts any pane that carries an isolation marker', () => { diff --git a/test/riff-sandbox-bypass.test.ts b/test/riff-sandbox-bypass.test.ts index 0a4ff4db3..f9ba32def 100644 --- a/test/riff-sandbox-bypass.test.ts +++ b/test/riff-sandbox-bypass.test.ts @@ -9,18 +9,13 @@ import { describe, it, expect } from 'vitest'; import { localSandboxApplies } from '../src/adapters/backend/sandbox.js'; describe('localSandboxApplies', () => { - it('bypasses the local file sandbox for the riff backend on Linux', () => { - expect(localSandboxApplies('linux', 'riff')).toBe(false); + it('bypasses the local file sandbox for the riff backend (remote sandbox, no local process)', () => { + expect(localSandboxApplies('riff')).toBe(false); }); - it('keeps the sandbox for local backends on Linux', () => { - expect(localSandboxApplies('linux', 'pty')).toBe(true); - expect(localSandboxApplies('linux', 'tmux')).toBe(true); - }); - - it('never applies on macOS (Seatbelt handles sandbox there)', () => { - expect(localSandboxApplies('darwin', 'pty')).toBe(false); - expect(localSandboxApplies('darwin', 'riff')).toBe(false); + it('keeps the sandbox for local backends — fs-policy applies on BOTH platforms now', () => { + expect(localSandboxApplies('pty')).toBe(true); + expect(localSandboxApplies('tmux')).toBe(true); }); }); diff --git a/test/sandbox-migration.test.ts b/test/sandbox-migration.test.ts new file mode 100644 index 000000000..de0e8eacf --- /dev/null +++ b/test/sandbox-migration.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { migrateSandboxConfigOnDisk } from '../src/services/sandbox-migration.js'; + +let dir: string; +let botsPath: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'sbx-mig-')); + botsPath = join(dir, 'bots.json'); +}); +afterEach(() => rmSync(dir, { recursive: true, force: true })); + +const write = (entries: any[]) => writeFileSync(botsPath, JSON.stringify(entries, null, 2)); +const read = () => JSON.parse(readFileSync(botsPath, 'utf-8')); + +describe('migrateSandboxConfigOnDisk', () => { + it('migrates legacy fields to sandboxPaths, KEEPS old fields, creates backup', async () => { + write([{ + larkAppId: 'cli_a', larkAppSecret: 's', + sandbox: true, + sandboxReadonlyPaths: ['~/ref'], + sandboxHidePaths: ['~/.ssh'], + customUnknownField: { keep: 'me' }, + }]); + const { migrated } = await migrateSandboxConfigOnDisk(botsPath); + expect(migrated).toEqual(['cli_a']); + const [e] = read(); + expect(e.sandbox).toBe(true); + expect(e.sandboxPaths).toEqual({ readOnly: ['~/ref'], deny: ['~/.ssh'] }); + // old fields preserved for downgrade + expect(e.sandboxReadonlyPaths).toEqual(['~/ref']); + expect(e.sandboxHidePaths).toEqual(['~/.ssh']); + // unknown fields survive the rewrite + expect(e.customUnknownField).toEqual({ keep: 'me' }); + // backup created with the ORIGINAL (pre-migration) content + const bak = `${botsPath}.bak-sandbox-v1`; + expect(existsSync(bak)).toBe(true); + expect(JSON.parse(readFileSync(bak, 'utf-8'))[0].sandboxPaths).toBeUndefined(); + }); + + it('absorbs readIsolation:true into sandbox:true', async () => { + write([{ larkAppId: 'cli_b', larkAppSecret: 's', readIsolation: true, readDenyExtraPaths: ['~/x'] }]); + await migrateSandboxConfigOnDisk(botsPath); + const [e] = read(); + expect(e.sandbox).toBe(true); + expect(e.readIsolation).toBe(true); // kept for downgrade + expect(e.sandboxPaths).toEqual({ deny: ['~/x'] }); + }); + + it('is idempotent: second run migrates nothing and leaves the file unchanged', async () => { + write([{ larkAppId: 'cli_a', larkAppSecret: 's', sandbox: true, sandboxHidePaths: ['~/.aws'] }]); + await migrateSandboxConfigOnDisk(botsPath); + const after1 = readFileSync(botsPath, 'utf-8'); + const { migrated } = await migrateSandboxConfigOnDisk(botsPath); + expect(migrated).toEqual([]); + expect(readFileSync(botsPath, 'utf-8')).toBe(after1); + }); + + it('does not touch entries with nothing to migrate (plain sandbox / no sandbox)', async () => { + write([ + { larkAppId: 'cli_plain', larkAppSecret: 's', sandbox: true }, + { larkAppId: 'cli_none', larkAppSecret: 's' }, + ]); + const { migrated } = await migrateSandboxConfigOnDisk(botsPath); + expect(migrated).toEqual([]); + expect(existsSync(`${botsPath}.bak-sandbox-v1`)).toBe(false); + expect(read()[0].sandboxPaths).toBeUndefined(); + }); + + it('never throws on malformed json (startup must not brick)', async () => { + writeFileSync(botsPath, '{not json'); + const { migrated } = await migrateSandboxConfigOnDisk(botsPath); + expect(migrated).toEqual([]); + }); + + it('backup is written only once (first migration wins)', async () => { + write([{ larkAppId: 'cli_a', larkAppSecret: 's', sandbox: true, sandboxHidePaths: ['~/.aws'] }]); + await migrateSandboxConfigOnDisk(botsPath); + const bakContent = readFileSync(`${botsPath}.bak-sandbox-v1`, 'utf-8'); + // simulate a later legacy edit + re-migration + const entries = read(); + entries.push({ larkAppId: 'cli_late', larkAppSecret: 's', readIsolation: true }); + write(entries); + await migrateSandboxConfigOnDisk(botsPath); + expect(readFileSync(`${botsPath}.bak-sandbox-v1`, 'utf-8')).toBe(bakContent); + }); +}); diff --git a/test/sandbox.test.ts b/test/sandbox.test.ts index c9afe4caf..5663a70ab 100644 --- a/test/sandbox.test.ts +++ b/test/sandbox.test.ts @@ -1,252 +1,21 @@ /** * sandbox.test.ts * - * Pure-logic tests for the overlayfs file-isolation sandbox: the bwrap arg - * builder (overlay binds + privacy masks + outbox-last), the relay security - * boundary (validateRelayRequest / materializeOutboxFile, unchanged), and the - * upper-layer landing (computeSandboxDiff / applySandboxDiff). No real mount — - * just the argv shape and the upper-walk/apply contract. + * Tests for the DIRECT-mode file sandbox module: the relay security boundary + * (validateRelayRequest / materializeOutboxFile — the sandbox↔host trust + * boundary, unchanged by the fs-policy refactor) and the platform gate of + * prepareDirectSandbox. The pure mount-plan logic lives in fs-policy.ts and is + * covered by fs-policy.test.ts. */ import { describe, it, expect } from 'vitest'; -import { tmpdir, homedir } from 'node:os'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { mkdtempSync, existsSync, writeFileSync, readFileSync, symlinkSync, rmSync, mkdirSync, realpathSync } from 'node:fs'; -import { buildSandboxArgs, buildRelayHostEnv, reexposeRunBinArgs, validateRelayRequest, materializeOutboxFile, prepareSandbox, resolveSandboxMountPath, sandboxedClaudeDataDir, resolveUserReadonlyRoots, type SandboxPlan } from '../src/adapters/backend/sandbox.js'; +import { mkdtempSync, existsSync, writeFileSync, readFileSync, symlinkSync, rmSync, mkdirSync } from 'node:fs'; +import { buildRelayHostEnv, validateRelayRequest, materializeOutboxFile, prepareDirectSandbox } from '../src/adapters/backend/sandbox.js'; import { createCodexAppAdapter } from '../src/adapters/cli/codex-app.js'; -import { computeSandboxDiff, applySandboxDiff, upperDir } from '../src/services/sandbox-land.js'; const tmp = () => mkdtempSync(join(tmpdir(), 'sbx-')); -function plan(over: Partial = {}): SandboxPlan { - return { - projectMount: '/home/u/proj', - projectMerged: '/data/sandboxes/s1/proj-merged', - home: '/root', - homeMerged: '/data/sandboxes/s1/home-merged', - outbox: '/data/sandboxes/s1/outbox', - hideDirs: [], - hideFiles: [], - net: true, - ...over, - }; -} - -/** Find the value bwrap would mount at `dest` for a given bind flag. */ -function bindDest(args: string[], flag: string, src: string): string | undefined { - for (let i = 0; i < args.length - 2; i++) { - if (args[i] === flag && args[i + 1] === src) return args[i + 2]; - } - return undefined; -} - -/** Index of the (flag, a, b) triple in args, or -1. */ -function tripleIdx(args: string[], flag: string, a: string, b: string): number { - for (let i = 0; i < args.length - 2; i++) { - if (args[i] === flag && args[i + 1] === a && args[i + 2] === b) return i; - } - return -1; -} - -describe('buildSandboxArgs (overlay model)', () => { - it('reads the entire real fs read-only (--ro-bind / /)', () => { - const a = buildSandboxArgs(plan()); - expect(tripleIdx(a, '--ro-bind', '/', '/')).toBeGreaterThanOrEqual(0); - }); - - it('binds the merged home overlay AT the real home path', () => { - const a = buildSandboxArgs(plan()); - expect(bindDest(a, '--bind', '/data/sandboxes/s1/home-merged')).toBe('/root'); - }); - - it('binds the merged project overlay AT projectMount and chdirs there', () => { - const a = buildSandboxArgs(plan()); - expect(bindDest(a, '--bind', '/data/sandboxes/s1/proj-merged')).toBe('/home/u/proj'); - const ci = a.indexOf('--chdir'); - expect(a[ci + 1]).toBe('/home/u/proj'); - }); - - it('masks hideDirs with a tmpfs and hideFiles with a read-only empty placeholder', () => { - const a = buildSandboxArgs(plan({ - hideDirs: ['/root/.ssh'], - hideFiles: [{ path: '/root/.botmux/bots.json', empty: '/data/sandboxes/s1/empties/mask-0' }], - })); - // dir → tmpfs - const di = a.indexOf('--tmpfs'); - expect(a).toContain('--tmpfs'); - expect(a.includes('/root/.ssh')).toBe(true); - expect(tripleIdx(a, '--tmpfs', '/root/.ssh', '/root/.ssh')).toBe(-1); // tmpfs takes a single arg - expect(di).toBeGreaterThanOrEqual(0); - // file → ro-bind empty placeholder over the real path - expect(bindDest(a, '--ro-bind', '/data/sandboxes/s1/empties/mask-0')).toBe('/root/.botmux/bots.json'); - }); - - it('binds the outbox LAST so a mask covering a parent dir cannot shadow it', () => { - const a = buildSandboxArgs(plan({ - hideDirs: ['/data/sandboxes/s1'], // covers the outbox's parent - })); - const maskIdx = a.indexOf('/data/sandboxes/s1'); - const outboxIdx = tripleIdx(a, '--bind', '/data/sandboxes/s1/outbox', '/data/sandboxes/s1/outbox'); - expect(outboxIdx).toBeGreaterThanOrEqual(0); - expect(outboxIdx).toBeGreaterThan(maskIdx); // outbox bind comes after the mask - }); - - it('binds selected skill runtime roots read-only before the outbox', () => { - const a = buildSandboxArgs(plan({ - readonlyRoots: ['/data/runtime-skills/s1/claude-plugin'], - })); - const rootIdx = tripleIdx(a, '--ro-bind', '/data/runtime-skills/s1/claude-plugin', '/data/runtime-skills/s1/claude-plugin'); - const outboxIdx = tripleIdx(a, '--bind', '/data/sandboxes/s1/outbox', '/data/sandboxes/s1/outbox'); - - expect(rootIdx).toBeGreaterThanOrEqual(0); - expect(outboxIdx).toBeGreaterThan(rootIdx); - }); - - it('binds user readonly roots BEFORE privacy masks so an overlapping entry cannot re-expose masked content', () => { - const a = buildSandboxArgs(plan({ - hideDirs: ['/root/.ssh'], - hideFiles: [{ path: '/root/.botmux/bots.json', empty: '/data/sandboxes/s1/empties/mask-0' }], - userReadonlyRoots: ['/root/refs'], - })); - const userIdx = tripleIdx(a, '--ro-bind', '/root/refs', '/root/refs'); - const dirMaskIdx = a.indexOf('/root/.ssh'); - const fileMaskIdx = tripleIdx(a, '--ro-bind', '/data/sandboxes/s1/empties/mask-0', '/root/.botmux/bots.json'); - expect(userIdx).toBeGreaterThanOrEqual(0); - expect(dirMaskIdx).toBeGreaterThan(userIdx); - expect(fileMaskIdx).toBeGreaterThan(userIdx); - }); - - it('keeps trusted skill roots AFTER the masks (a broad hideDir must not blank skill delivery)', () => { - const a = buildSandboxArgs(plan({ - hideDirs: ['/data/runtime-skills'], - readonlyRoots: ['/data/runtime-skills/s1/claude-plugin'], - })); - const maskIdx = a.indexOf('/data/runtime-skills'); - const skillIdx = tripleIdx(a, '--ro-bind', '/data/runtime-skills/s1/claude-plugin', '/data/runtime-skills/s1/claude-plugin'); - expect(skillIdx).toBeGreaterThan(maskIdx); - }); - - it('no clone/scrub artefacts: never binds a per-session clone "work" dir', () => { - const a = buildSandboxArgs(plan()); - // The old model bound a `git clone` "work" dir; the overlay model never does - // — the project is a merged overlay bound at projectMount, nothing else. - expect(a.some(x => x.includes('/work'))).toBe(false); - // The only binds are: ro / /, the two overlay merged dirs, and the outbox. - const binds = a.filter((x, i) => x === '--bind').length; - expect(binds).toBe(3); // home-merged, proj-merged, outbox - }); - - it('keeps the network by default and drops it when net=false', () => { - expect(buildSandboxArgs(plan({ net: true }))).not.toContain('--unshare-net'); - expect(buildSandboxArgs(plan({ net: false }))).toContain('--unshare-net'); - }); - - it('always isolates user/pid/ipc namespaces', () => { - const a = buildSandboxArgs(plan()); - for (const flag of ['--unshare-user', '--unshare-pid', '--unshare-ipc']) { - expect(a).toContain(flag); - } - }); -}); - -describe('resolveSandboxMountPath', () => { - it('canonicalizes a symlink mount target so bwrap does not bind over the symlink path', () => { - const root = tmp(); - const realHome = join(root, 'real-home'); - const linkHome = join(root, 'home-link'); - mkdirSync(realHome); - symlinkSync(realHome, linkHome); - - expect(resolveSandboxMountPath(linkHome)).toBe(realpathSync(realHome)); - - rmSync(root, { recursive: true, force: true }); - }); -}); - -describe('sandboxedClaudeDataDir (symlink HOME redirect)', () => { - it('keeps the redirect inside home-upper whether the dataDir is symlink- or canonical-form', () => { - // The home overlay binds (and $HOME is set) at the canonical home, so copy-ups - // land under home-upper relative to that root. A dataDir passed in CANONICAL - // form under a symlink HOME used to escape via `..` (relative(symlinkHome, - // canonicalData)) — breaking the Claude bridge redirect for exactly the - // symlink-HOME case this file hardens. Both forms must stay in home-upper. - const root = tmp(); - const realHome = join(root, 'real-home'); - const linkHome = join(root, 'home-link'); - mkdirSync(realHome); - symlinkSync(realHome, linkHome); - const prevHome = process.env.HOME; - process.env.HOME = linkHome; // os.homedir() reads $HOME per call on Linux - try { - const symlinkForm = join(linkHome, '.claude'); - const canonicalForm = join(realpathSync(linkHome), '.claude'); - const expected = join('/var/tmp/botmux-sbx', 'sid-x', 'home-upper', '.claude'); - expect(sandboxedClaudeDataDir('sid-x', symlinkForm)).toBe(expected); - expect(sandboxedClaudeDataDir('sid-x', canonicalForm)).toBe(expected); - } finally { - if (prevHome !== undefined) process.env.HOME = prevHome; - rmSync(root, { recursive: true, force: true }); - } - }); -}); - -// ── reexposeRunBinArgs: restore fnm/nvm bin dirs masked by --tmpfs /run ── -// Regression for "file sandbox can't start" on fnm/nvm/volta machines: the -// resolved CLI binary (and the daemon's own node) live under /run/user/.../bin, -// which --tmpfs /run masks → bwrap execvp fails → crash-loop. -describe('reexposeRunBinArgs (fnm/nvm /run bin dirs)', () => { - it('re-binds the containing dir of a /run-resident binary read-only at its real path', () => { - const a = reexposeRunBinArgs(['/run/user/1001/fnm_multishells/abc_123/bin/codex']); - expect(tripleIdx(a, '--ro-bind-try', '/run/user/1001/fnm_multishells/abc_123/bin', '/run/user/1001/fnm_multishells/abc_123/bin')).toBe(0); - }); - - it('dedupes when the binary and node share one /run bin dir', () => { - const dir = '/run/user/1001/fnm_multishells/abc_123/bin'; - const a = reexposeRunBinArgs([`${dir}/codex`, `${dir}/node`]); - expect(a).toEqual(['--ro-bind-try', dir, dir]); - }); - - it('handles distinct /run dirs for the binary and node', () => { - const a = reexposeRunBinArgs(['/run/a/bin/codex', '/run/b/bin/node']); - expect(tripleIdx(a, '--ro-bind-try', '/run/a/bin', '/run/a/bin')).toBeGreaterThanOrEqual(0); - expect(tripleIdx(a, '--ro-bind-try', '/run/b/bin', '/run/b/bin')).toBeGreaterThanOrEqual(0); - }); - - it('ignores binaries outside /run (system node, npm/pnpm globals) — non-fnm users unaffected', () => { - expect(reexposeRunBinArgs(['/usr/bin/node', '/usr/local/bin/codex', undefined])).toEqual([]); - }); - - it('NEVER re-binds /run itself (would clobber the tmpfs + the /run/sbxbin relay shim)', () => { - // A binary directly at /run/node has dirname '/run' — must be skipped. - expect(reexposeRunBinArgs(['/run/node'])).toEqual([]); - }); - - it('skips empty/non-string entries', () => { - expect(reexposeRunBinArgs([undefined, '', '/run/user/1/bin/x'])).toEqual(['--ro-bind-try', '/run/user/1/bin', '/run/user/1/bin']); - }); - - it('covers a SECOND-STAGE /run binary (codex-app real codex) when cliBin+node are OUTSIDE /run', () => { - // codex-app: resolvedBin = daemon node (stable fnm install, NOT /run); the real - // codex (spawned inside the sandbox for app-server) is declared via - // sandboxExtraExecPaths. prepareSandbox feeds [cliBin, process.execPath, ...extra]. - const stableNode = '/root/.local/share/fnm/node-versions/v24.16.0/installation/bin/node'; - const runCodex = '/run/user/1001/fnm_multishells/abc_123/bin/codex'; - const a = reexposeRunBinArgs([stableNode, stableNode, runCodex]); - // only the fnm bin dir is re-bound (stable node ignored, never the cwd) - expect(a).toEqual(['--ro-bind-try', '/run/user/1001/fnm_multishells/abc_123/bin', '/run/user/1001/fnm_multishells/abc_123/bin']); - }); - - it('DANGER it guards against: a /run working-dir path would re-bind its parent (so the wiring must NOT feed cwd/cliArgs in)', () => { - // This documents WHY prepareSandbox passes ONLY declared exec paths, never raw - // cliArgs: a `--cwd /run/user/1001/proj` would re-bind PARENT /run/user/1001, - // shadowing the project overlay mounted there + exposing siblings/IPC sockets. - expect(reexposeRunBinArgs(['/run/user/1001/proj'])).toEqual(['--ro-bind-try', '/run/user/1001', '/run/user/1001']); - }); -}); - -// ── codex-app adapter declares ONLY the second-stage executable, never the cwd ── -// Negative regression for Codex's blocker: sandboxExtraExecPaths must return the -// resolved codex binary and NOTHING path-like (the working dir), so the sandbox -// never re-binds a /run cwd's parent. describe('codex-app sandboxExtraExecPaths', () => { it('returns exactly the resolved codex bin and never the working dir', () => { // pathOverride is absolute → resolveCommand short-circuits (no shell-out / flake). @@ -257,13 +26,22 @@ describe('codex-app sandboxExtraExecPaths', () => { const extra = adapter.sandboxExtraExecPaths?.(); expect(extra).toEqual([runCodex]); expect(extra).not.toContain('/run/user/1001/proj'); - // and the resulting re-expose hits only the codex bin dir, not the cwd parent. - const a = reexposeRunBinArgs([adapter.resolvedBin, process.execPath, ...(extra ?? [])]); - expect(a).toContain('/run/user/1001/fnm_multishells/abc_123/bin'); - expect(a).not.toContain('/run/user/1001'); }); }); +describe('prepareDirectSandbox platform gate', () => { + it('returns null off-linux (worker treats null as a hard error, never silent-unsandboxed)', () => { + if (process.platform === 'linux') return; // gate only observable off-linux + const r = prepareDirectSandbox({ + sessionId: 's1', dataDir: tmp(), + policy: { rules: [], net: true, writeRegexes: [] }, + chdir: '/x', home: '/home/u', cliBin: '/usr/bin/true', cliArgs: [], + }); + expect(r).toBeNull(); + }); +}); + + // ── validateRelayRequest: pure schema + flag-allowlist boundary (UNCHANGED) ── // Regression for the "sandbox makes host read an arbitrary path" confused-deputy // blocker: only plain outbox basenames + allowlisted flags pass; raw argv / @@ -389,374 +167,3 @@ describe('materializeOutboxFile (TOCTOU)', () => { expect(elapsed).toBeLessThan(2000); // returned immediately, did NOT block }); }); - -// ── prepareSandbox: the per-bot toggle must actually engage bwrap ──────────── -describe('prepareSandbox enabled gate', () => { - it('returns null when not enabled (regardless of env)', () => { - const r = prepareSandbox({ - enabled: false, cliId: 'codex', sessionId: 's', sourceWorkingDir: tmp(), - dataDir: tmp(), cliBin: '/bin/true', cliArgs: [], - }); - expect(r).toBeNull(); - }); -}); - -// ── Landing from the overlay UPPER layer ───────────────────────────────────── -// The project overlay UPPER dir IS the changeset. computeSandboxDiff walks it -// (regular file = add/modify, char-dev rdev0 = whiteout/delete); applySandboxDiff -// copies the changes onto a real target. We simulate the upper layer on disk. -describe('sandbox landing from upper layer', () => { - function makeUpper(dataDir: string, sid: string): string { - const up = upperDir(dataDir, sid); - mkdirSync(up, { recursive: true }); - return up; - } - - it('computeSandboxDiff classifies a new + a modified file under the upper', () => { - const dataDir = tmp(); const sid = 's-diff'; - const up = makeUpper(dataDir, sid); - writeFileSync(join(up, 'added.txt'), 'brand new\n'); - mkdirSync(join(up, 'sub'), { recursive: true }); - writeFileSync(join(up, 'sub', 'edited.txt'), 'changed content\n'); - - const d = computeSandboxDiff(dataDir, sid); - expect(d.ok).toBe(true); - if (!d.ok) return; - expect(d.empty).toBe(false); - expect(d.files).toBe(2); - // both files visible in the stat summary - expect(d.statText).toContain('added.txt'); - expect(d.statText).toContain('sub/edited.txt'); - }); - - it('resolves a symlink dataDir to the same canonical upper prepareSandbox created', () => { - // prepareSandbox canonicalizes dataDir before creating /sandboxes/…, - // so /land must canonicalize too or it looks under the symlink path and finds - // nothing. Write the changeset under the CANONICAL dir, ask via the SYMLINK. - const realData = tmp(); const sid = 's-symlink-datadir'; - const linkData = join(tmp(), 'data-link'); - symlinkSync(realData, linkData); - const up = upperDir(realData, sid); // canonical upper (where writes land) - mkdirSync(up, { recursive: true }); - writeFileSync(join(up, 'x.txt'), 'hi\n'); - // upperDir must map the symlink dataDir onto the same canonical upper - expect(upperDir(linkData, sid)).toBe(up); - const d = computeSandboxDiff(linkData, sid); // query via the symlink - expect(d.ok).toBe(true); - if (!d.ok) return; - expect(d.empty).toBe(false); - expect(d.statText).toContain('x.txt'); - rmSync(linkData, { force: true }); - }); - - it('computeSandboxDiff reports empty when the upper has no changes', () => { - const dataDir = tmp(); const sid = 's-empty'; - makeUpper(dataDir, sid); - const d = computeSandboxDiff(dataDir, sid); - expect(d.ok).toBe(true); - if (!d.ok) return; - expect(d.empty).toBe(true); - expect(d.files).toBe(0); - }); - - it('computeSandboxDiff errors when the session has no upper layer', () => { - const dataDir = tmp(); - const d = computeSandboxDiff(dataDir, 'nope'); - expect(d.ok).toBe(false); - }); - - it('applySandboxDiff copies new + modified files onto the real target', () => { - const dataDir = tmp(); const sid = 's-apply'; - const up = makeUpper(dataDir, sid); - const target = tmp(); - // target already has the file the agent "modified" - writeFileSync(join(target, 'edited.txt'), 'old content\n'); - // upper = the agent's changeset - writeFileSync(join(up, 'added.txt'), 'NEW FILE\n'); - writeFileSync(join(up, 'edited.txt'), 'NEW CONTENT\n'); - mkdirSync(join(up, 'deep'), { recursive: true }); - writeFileSync(join(up, 'deep', 'nested.txt'), 'nested\n'); - - const a = applySandboxDiff(target, dataDir, sid); - expect(a.ok).toBe(true); - expect(readFileSync(join(target, 'added.txt'), 'utf8')).toBe('NEW FILE\n'); - expect(readFileSync(join(target, 'edited.txt'), 'utf8')).toBe('NEW CONTENT\n'); // overwrote old - expect(readFileSync(join(target, 'deep', 'nested.txt'), 'utf8')).toBe('nested\n'); // mkdir -p parent - }); - - it('applySandboxDiff honors an overlay whiteout (char-dev rdev 0) as a deletion when mknod is available', () => { - const dataDir = tmp(); const sid = 's-del'; - const up = makeUpper(dataDir, sid); - const target = tmp(); - writeFileSync(join(target, 'gone.txt'), 'to be deleted\n'); - // Try to create a real overlay whiteout (char device, 0/0). Needs CAP_MKNOD; - // root in this env has it, but skip gracefully if mknod is unavailable. - const { spawnSync } = require('node:child_process') as typeof import('node:child_process'); - const r = spawnSync('mknod', [join(up, 'gone.txt'), 'c', '0', '0'], { stdio: 'ignore' }); - if (r.status !== 0) { - // Environment can't make a whiteout — assert the detector at least doesn't - // misclassify, and skip the deletion assertion. - expect(existsSync(join(target, 'gone.txt'))).toBe(true); - return; - } - const a = applySandboxDiff(target, dataDir, sid); - expect(a.ok).toBe(true); - expect(existsSync(join(target, 'gone.txt'))).toBe(false); // whiteout → removed from target - }); - - it('applySandboxDiff errors when nothing changed', () => { - const dataDir = tmp(); const sid = 's-noop'; - makeUpper(dataDir, sid); - const target = tmp(); - const a = applySandboxDiff(target, dataDir, sid); - expect(a.ok).toBe(false); - }); - - it('lands a symlink AS a symlink (does not dereference into a content copy)', () => { - const dataDir = tmp(); const sid = 's-link'; - const up = makeUpper(dataDir, sid); - const target = tmp(); - // a project-relative symlink the agent created in the upper layer - writeFileSync(join(up, 'real.txt'), 'payload\n'); - symlinkSync('real.txt', join(up, 'alias.txt')); - - const a = applySandboxDiff(target, dataDir, sid); - expect(a.ok).toBe(true); - const { lstatSync, readlinkSync } = require('node:fs') as typeof import('node:fs'); - expect(lstatSync(join(target, 'alias.txt')).isSymbolicLink()).toBe(true); // still a link - expect(readlinkSync(join(target, 'alias.txt'))).toBe('real.txt'); // target preserved - }); - - it('a dangling symlink no longer throws mid-loop — it lands as a link, edits land too', () => { - const dataDir = tmp(); const sid = 's-dangling'; - const up = makeUpper(dataDir, sid); - const target = tmp(); - writeFileSync(join(target, 'keep.txt'), 'original\n'); - // upper changeset: a normal edit + a NEW file + a DANGLING symlink. The old - // code dereferenced the link via copyFileSync → ENOENT mid-loop → the project - // was left half-landed. Now a symlink (even dangling) is recreated as a link, - // so apply SUCCEEDS and all changes land. - writeFileSync(join(up, 'keep.txt'), 'EDITED\n'); - writeFileSync(join(up, 'added.txt'), 'NEW\n'); - symlinkSync('/nonexistent/dangling/target', join(up, 'dangling.lnk')); - - const a = applySandboxDiff(target, dataDir, sid); - expect(a.ok).toBe(true); - const { lstatSync } = require('node:fs') as typeof import('node:fs'); - expect(lstatSync(join(target, 'dangling.lnk')).isSymbolicLink()).toBe(true); - expect(readFileSync(join(target, 'keep.txt'), 'utf8')).toBe('EDITED\n'); - expect(readFileSync(join(target, 'added.txt'), 'utf8')).toBe('NEW\n'); - }); - - it('multi-file apply lands every change (two-phase apply, happy path)', () => { - // The two-phase apply pre-validates all sources, then applies. Confirm a - // multi-file changeset lands fully. (The fail-closed NO-mutation path — when - // overlay opacity is undeterminable on a lower-existing dir — is verified by - // the crippled-PATH opaque-land probe, which can't run as a unit test here - // because it needs a real opaque overlay dir + no xattr tooling.) - const dataDir = tmp(); const sid = 's-multi'; - const up = makeUpper(dataDir, sid); - const target = tmp(); - writeFileSync(join(target, 'a.txt'), 'old\n'); - writeFileSync(join(up, 'a.txt'), 'new\n'); - writeFileSync(join(up, 'b.txt'), 'new b\n'); - const a = applySandboxDiff(target, dataDir, sid); - expect(a.ok).toBe(true); - expect(readFileSync(join(target, 'a.txt'), 'utf8')).toBe('new\n'); - expect(readFileSync(join(target, 'b.txt'), 'utf8')).toBe('new b\n'); - }); - - // Linux-only: this asserts overlay-landing (applySandboxDiff) behaviour, which - // only runs for the Linux bwrap sandbox — macOS uses a Seatbelt write-sandbox - // with no upper layer to land, so apply is never invoked there and its opaque-dir - // semantics (xattr-driven) don't hold off-Linux. The sibling landing tests are - // pure fs logic and stay cross-platform; only this apply-behaviour case is gated. - it.skipIf(process.platform !== 'linux')('a BRAND-NEW opaque dir is mkdir-only (does NOT rm -rf unrelated real files)', () => { - // Regression #10: overlay marks BOTH new and replaced dirs opaque. apply must - // only rm -rf an opaque dir that ALSO exists in the target; a purely-new dir - // must not clobber concurrent real files that drifted under that path. - const dataDir = tmp(); const sid = 's-newdir'; - const up = makeUpper(dataDir, sid); - const target = tmp(); - // The target real project has drifted: a sibling appeared under a path the - // agent will also create. Simulate "agent created src/new-feature/ fresh". - mkdirSync(join(target, 'src', 'new-feature'), { recursive: true }); - writeFileSync(join(target, 'src', 'new-feature', 'concurrent.txt'), 'do not delete me\n'); - // Upper: the agent's brand-new dir + its file. We cannot set a real opaque - // xattr without root/CAP_SYS_ADMIN here, so this asserts the NON-opaque path - // (plain new dir) never clobbers; the opaque-but-new path shares the same - // exists-in-target guard in apply. - mkdirSync(join(up, 'src', 'new-feature'), { recursive: true }); - writeFileSync(join(up, 'src', 'new-feature', 'feature.txt'), 'brand new feature\n'); - - const a = applySandboxDiff(target, dataDir, sid); - expect(a.ok).toBe(true); - expect(readFileSync(join(target, 'src', 'new-feature', 'feature.txt'), 'utf8')).toBe('brand new feature\n'); - // the concurrent file the agent never touched MUST survive - expect(existsSync(join(target, 'src', 'new-feature', 'concurrent.txt'))).toBe(true); - }); -}); - -// ── prepareSandbox: hidePaths produce the right masks ──────────────────────── -// On Linux with root we can actually mount the overlays; verify the resulting -// argv masks an existing dir via tmpfs and a file via an empty ro-bind. -// ── resolveUserReadonlyRoots: tilde expansion + overlay-root guard ─────────── -describe('resolveUserReadonlyRoots', () => { - it('tilde-expands entries against the given home and keeps only existing paths', () => { - const home = tmp(); - mkdirSync(join(home, 'refs'), { recursive: true }); - const project = tmp(); - expect(resolveUserReadonlyRoots(['~/refs', '~/missing-xyz'], home, project)).toEqual([join(home, 'refs')]); - }); - - it('rejects entries equal to or covering an overlay root (home / project)', () => { - const home = tmp(); - const project = tmp(); - mkdirSync(join(project, 'vendor'), { recursive: true }); - // `~` (= home), the project root itself, and `/` all swallow an overlay root → dropped. - // A path strictly UNDER the project stays allowed (reference-material use case). - expect(resolveUserReadonlyRoots(['~', project, '/', join(project, 'vendor')], home, project)) - .toEqual([join(project, 'vendor')]); - }); - - it('rejects a non-normalized string that resolves to an overlay root (trailing slash / dot)', () => { - const home = tmp(); - const project = tmp(); - // `/repo/` and `/repo/.` both canonicalize to the project root → dropped, - // even though a plain string-prefix check would let them through. - expect(resolveUserReadonlyRoots([`${project}/`, join(project, '.')], home, project)) - .toEqual([]); - }); - - it('rejects a symlink that resolves to the project or home overlay root', () => { - const home = tmp(); - const project = tmp(); - const linkDir = tmp(); - const toProject = join(linkDir, 'to-project'); - const toHome = join(linkDir, 'to-home'); - symlinkSync(project, toProject); - symlinkSync(home, toHome); - // Both symlinks canonicalize to an overlay root → must be dropped, else bwrap - // would re-mount the real tree read-only and shadow write isolation. - expect(resolveUserReadonlyRoots([toProject, toHome], home, project)).toEqual([]); - }); - - it('allows a symlink that resolves OUTSIDE the overlay roots', () => { - const home = tmp(); - const project = tmp(); - const snap = tmp(); - const linkDir = tmp(); - const link = join(linkDir, 'ref'); - symlinkSync(snap, link); - // The expanded original path is returned (docs promise "mounted at the same path"). - expect(resolveUserReadonlyRoots([link], home, project)).toEqual([link]); - }); - - it('drops non-string and empty entries', () => { - const home = tmp(); - const project = tmp(); - expect(resolveUserReadonlyRoots(['', undefined as unknown as string], home, project)).toEqual([]); - }); -}); - -describe.skipIf(process.platform !== 'linux')('prepareSandbox hidePaths masks', () => { - it('tilde-expands hidePaths so the documented `~/...` form masks the real path', () => { - const src = tmp(); - writeFileSync(join(src, 'file.txt'), 'x'); - const prev = process.env.BOTMUX_SANDBOX; - delete process.env.BOTMUX_SANDBOX; - const dataDir = tmp(); - const sid = 'hp-tilde-' + Math.random().toString(36).slice(2); - // A hidePath that does NOT exist still gets an empty-placeholder mask — at the - // EXPANDED path, never at a literal `~/...` dest bwrap can't resolve. - const rel = '.botmux-sbx-test-missing-' + Math.random().toString(36).slice(2); - let r: ReturnType = null; - try { - r = prepareSandbox({ - enabled: true, cliId: 'codex', sessionId: sid, sourceWorkingDir: src, - dataDir, cliBin: '/bin/true', cliArgs: [], - hidePaths: [`~/${rel}`], - }); - if (r === null) return; // overlay mount unavailable in this env — skip assertions - const expanded = join(resolveSandboxMountPath(homedir()), rel); - const idx = r.args.findIndex((x, i) => x === '--ro-bind' && r!.args[i + 2] === expanded); - expect(idx).toBeGreaterThanOrEqual(0); - expect(r.args).not.toContain(`~/${rel}`); - } finally { - if (r) r.cleanup(); - if (prev !== undefined) process.env.BOTMUX_SANDBOX = prev; - } - }); - - it('turns an existing dir hidePath into a tmpfs mask and a file into a ro-bind empty', () => { - const src = tmp(); - writeFileSync(join(src, 'file.txt'), 'x'); - // a dir + a file to hide (both under a tmp tree so they exist) - const secretDir = tmp(); - mkdirSync(join(secretDir, 'sekret'), { recursive: true }); - const secretFile = join(secretDir, 'creds.json'); - writeFileSync(secretFile, 'TOKEN'); - - const prev = process.env.BOTMUX_SANDBOX; - delete process.env.BOTMUX_SANDBOX; - const dataDir = tmp(); - const sid = 'hp-' + Math.random().toString(36).slice(2); - let r: ReturnType = null; - try { - r = prepareSandbox({ - enabled: true, cliId: 'codex', sessionId: sid, sourceWorkingDir: src, - dataDir, cliBin: '/bin/true', cliArgs: [], - hidePaths: [join(secretDir, 'sekret'), secretFile], - }); - if (r === null) return; // overlay mount unavailable in this env — skip assertions - // dir → tmpfs mask - expect(r.args).toContain(join(secretDir, 'sekret')); - const ti = r.args.indexOf('--tmpfs'); - expect(ti).toBeGreaterThanOrEqual(0); - // file → ro-bind of an empty placeholder over the real file path - const idx = r.args.findIndex((x, i) => x === '--ro-bind' && r!.args[i + 2] === secretFile); - expect(idx).toBeGreaterThanOrEqual(0); - } finally { - if (r) r.cleanup(); - if (prev !== undefined) process.env.BOTMUX_SANDBOX = prev; - } - }); - - it('sets child HOME to the canonical mount path, not a symlink form (dangles when its parent is masked)', () => { - // Regression: binding the home overlay at the canonical target but leaving - // env.HOME as the symlink string breaks the inverse of the bug this file - // fixes — a HOME symlink whose parent is masked inside the sandbox (e.g. a - // /tmp path shadowed by the tmpfs) can't resolve, so HOME dangles and the - // CLI can't read/write it. env.HOME MUST equal the path the overlay binds. - const src = tmp(); - writeFileSync(join(src, 'file.txt'), 'x'); - const realHome = tmp(); - const linkHome = join(tmpdir(), 'sbx-linkhome-' + Math.random().toString(36).slice(2)); - symlinkSync(realHome, linkHome); - const prevSandbox = process.env.BOTMUX_SANDBOX; - const prevHome = process.env.HOME; - delete process.env.BOTMUX_SANDBOX; - process.env.HOME = linkHome; // os.homedir() reads $HOME per call on Linux - const dataDir = tmp(); - const sid = 'symhome-' + Math.random().toString(36).slice(2); - let r: ReturnType = null; - try { - r = prepareSandbox({ - enabled: true, cliId: 'codex', sessionId: sid, sourceWorkingDir: src, - dataDir, cliBin: '/bin/true', cliArgs: [], - }); - if (r === null) return; // overlay mount unavailable in this env — skip assertions - const canonicalHome = realpathSync(linkHome); - expect(r.env.HOME).toBe(canonicalHome); - expect(r.env.HOME).not.toBe(linkHome); - // the home overlay is bound AT that same canonical path - const bound = r.args.some((x, i) => x === '--bind' && r!.args[i + 2] === canonicalHome); - expect(bound).toBe(true); - } finally { - if (r) r.cleanup(); - if (prevHome !== undefined) process.env.HOME = prevHome; - if (prevSandbox !== undefined) process.env.BOTMUX_SANDBOX = prevSandbox; - rmSync(linkHome, { force: true }); - } - }); -});