diff --git a/.ai/contexts/trigger-watcher.md b/.ai/contexts/trigger-watcher.md index b67416aa3..0d91bfb81 100644 --- a/.ai/contexts/trigger-watcher.md +++ b/.ai/contexts/trigger-watcher.md @@ -19,6 +19,41 @@ Main-process modules `trigger-watcher.js` and `trigger-context.js` live in ## Public surface +### Sandbox control directory + +`getTriggersDir()` is exported and resolves the nonempty +`SWITCHBOARD_TRIGGERS_DIR` override or `~/.switchboard/triggers` to an absolute +main-process path. The manual sandbox launch path (new/resume/fork) and the +schedule launcher prepare its `processed/` directory and pass that same path +as `SWITCHBOARD_SANDBOX_TRIGGERS_DIR`; a relative app override is never resolved +against a different session cwd. Legacy dispatch and result paths are unchanged. + +The wrapper protects the whole control tree and separately resolved `processed` +target read-only through every visible writable mount alias, pins ancestors, +and refuses unsafe overlaps, mutable symlinks and missing visible paths with +status 125 before CLI execution. Final mount-plan rebuilding rechecks protection. +A control directory inside the project can launch protected read-only. Unrelated +newline-containing writable paths remain allowed; unrepresentable overlapping +mounts refuse. The full final rebuild is retained: rechecking only control +paths would need to validate cached mount sources and ancestor pins after +symlink changes as well as newly materialised state mounts. +Host watcher publication stays writable. Only fresh sandbox launches acquire +these mounts; existing sessions are not terminated or remounted. + +Launch coverage evaluates the shipped complete `open-terminal` registration and +`runScheduleCommand` with injected spawn ports. Each new/resume/fork case asserts +its CLI arguments, spawn cwd and control path at `spawnPty`; the schedule case +checks `cpSpawn`. Wrapper cases use Linux-only skips, including fake-bwrap cases, +so Windows CI does not pay Git Bash startup and mount-plan costs. Real-bwrap +coverage reads a sentinel through every view and writes beside pinned ancestors +before checking denied control writes, ruling out absent or broadly read-only views. + +Protection of triggers, results and markers **does not hold when the Podman +socket is exposed** by `SWITCHBOARD_SANDBOX_PODMAN=1`: that service can mount host +paths writable in another container. The Podman opt-in and successful launch +behavior remain unchanged, with no refusal solely for exposing the socket and +no retry without Sandbox. See [sandbox.md](../../docs/sandbox.md#file-trigger-control-directory). + Result publication uses the synchronous Windows rename retry policy documented in [session-cache.md](session-cache.md#atomic-rename-retries-on-windows-900). After retry exhaustion, the existing log-and-delete-trigger behaviour still diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f1881c3..33f426278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - Images opened from a link or from the Touched list now show in the panel. Other files the panel can't show offer **Open with system viewer** for pictures and PDFs, and **Show in folder** for the rest. (#1025) +### Changed +- New sandboxed sessions protect the triggers directory even when it sits inside a project or another writable directory, and refuse layouts that cannot protect it safely. This protection does not apply when Podman access is enabled. (#1022) + ### Fixed - A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017) diff --git a/docs/sandbox.md b/docs/sandbox.md index 4cf8c8a94..4d978d10b 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -418,6 +418,41 @@ whatever they hold. nvm installs the binary's directory is read-only. - **`/etc`** is readable, like on the host. +## File-trigger control directory + +Fresh sandbox launches cannot write, rename, replace or publish files in the +effective triggers directory, including `processed/` results and action markers, +through their filesystem mounts. Manual new, resumed and forked sessions and +schedules use the same absolute directory as the trigger watcher: a nonempty +`SWITCHBOARD_TRIGGERS_DIR`, or `~/.switchboard/triggers`. Switchboard prepares it +and passes it as the internal single-path `SWITCHBOARD_SANDBOX_TRIGGERS_DIR`. +Standalone wrapper launches use the same override/default policy, resolving a +relative override from their own working directory. + +The wrapper protects the entire tree at every visible writable-mount alias, +including project/worktree, additional-directory and CLI-state mounts. It also +protects a separately resolved `processed` target and pins intervening ancestor +directories against rename and replacement. A control path outside the sandbox +stays unexposed. A control tree inside a project or another writable directory +can launch with read-only protection; only unsafe layouts are refused. A cwd or +explicit extra bind at or below a control path, mutable +symlink chain, missing visible control directory, unresolved or unrepresentable +control path, an unrepresentable overlapping mount, or a writable mount that +would reopen the tree refuses launch with status 125 and a `claude-sandbox:` diagnostic. Paths are checked again when the final +mount plan is built. Unrelated writable paths containing newlines remain allowed. +There is no retry without Sandbox or saved-option change. +The wrapper creates no host state before its pre-flight succeeds; Switchboard's +host-side directory preparation and watcher publication remain writable. + +**This protection does not hold when `SWITCHBOARD_SANDBOX_PODMAN=1` exposes the +Podman socket.** The service can mount host paths writable in another container, +including triggers, results and markers. The existing Podman opt-in and successful +socket-exposing launch behavior are unchanged; exposing the socket alone does +not refuse launch. The sandbox's own mount-plan checks still apply. + +Existing sandbox namespaces retain their original mounts. This protection takes +effect on fresh launches; it does not terminate already-running sessions. + ## Limits - `git commit` has no identity unless the repository sets `user.name` and diff --git a/main.js b/main.js index a32eac314..5f8069798 100644 --- a/main.js +++ b/main.js @@ -3151,6 +3151,8 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se // see docs/sandbox.md ("Schedules") if (projectPath) await scheduleProjects().add(projectPath); if (!isAttach && sessionOptions?.sandbox) { + ptyEnv.SWITCHBOARD_SANDBOX_TRIGGERS_DIR = require('./src/main/triggers/trigger-watcher').getTriggersDir(); + fs.mkdirSync(path.join(ptyEnv.SWITCHBOARD_SANDBOX_TRIGGERS_DIR, 'processed'), { recursive: true }); // Directories the sandboxed claude must still reach beyond the cwd: // the project root when resuming inside a worktree (git metadata lives // there), and any user-configured Additional Directories. @@ -3936,6 +3938,8 @@ if (!app.isPackaged) { if (onDone) onDone(); return { ok: false, error: refusal.error }; } + env.SWITCHBOARD_SANDBOX_TRIGGERS_DIR = require('./src/main/triggers/trigger-watcher').getTriggersDir(); + fs.mkdirSync(path.join(env.SWITCHBOARD_SANDBOX_TRIGGERS_DIR, 'processed'), { recursive: true }); cmd = quoteArgvForShell(shell, ['bash', sandboxScriptPath()]) + ' ' + quoteArgvForShell(shell, claudeArgv); const bindEnv = sandboxBindEnv(addDirs); if (bindEnv) env.SWITCHBOARD_SANDBOX_BINDS = bindEnv; diff --git a/scripts/claude-sandbox.sh b/scripts/claude-sandbox.sh index 1c4090d64..002cb331d 100755 --- a/scripts/claude-sandbox.sh +++ b/scripts/claude-sandbox.sh @@ -302,6 +302,16 @@ WRITABLE_REALS=() for _r in "${WRITABLE_ROOTS[@]}"; do WRITABLE_REALS+=("$(readlink -f "$_r" 2>/dev/null || true)") done +BASE_WRITABLE_ROOTS=("${WRITABLE_ROOTS[@]}") + +CONTROL_DIR="${SWITCHBOARD_SANDBOX_TRIGGERS_DIR:-${SWITCHBOARD_TRIGGERS_DIR:-$HOME/.switchboard/triggers}}" +case "$CONTROL_DIR" in + *$'\n'*|*$'\r'*) fail "unrepresentable control path: $CONTROL_DIR" ;; +esac +if [ -n "${SWITCHBOARD_SANDBOX_TRIGGERS_DIR:-}" ]; then + case "$CONTROL_DIR" in /*) ;; *) fail "control path must be absolute: $CONTROL_DIR" ;; esac +fi +case "$CONTROL_DIR" in /*) ;; *) CONTROL_DIR="$PWD/$CONTROL_DIR" ;; esac # Sets SV_VIEW to where the resolved host path $1 appears inside the sandbox, # and SV_IDX to its writable root, when it lies under one; fails otherwise. @@ -368,8 +378,8 @@ check_chain() { esac if [ -L "$cur/$comp" ]; then hops=$((hops + 1)) - [ "$hops" -le 40 ] || return 0 - if sandbox_view "${cur:-/}" && ! inside_ro_area "$SV_VIEW/$comp"; then + [ "$hops" -le 40 ] || fail "cannot resolve protected path: $orig" + if chain_link_writable "${cur:-/}" "$comp" "${2:-}"; then fail "refusing to launch: $cur/$comp is a symbolic link in a directory the sandbox can write, on the way to $orig, which it protects; the session could re-point it. Replace the link with what it points to (for git hooks, set core.hooksPath to that directory instead), or turn Sandbox off for this session." fi link="$(readlink "$cur/$comp")" @@ -389,6 +399,7 @@ protect_resolved() { [ -n "${PROTECTED_SEEN[$real]:-}" ] && return 1 PROTECTED_SEEN["$real"]=1 for i in "${!WRITABLE_ROOTS[@]}"; do + [ "$i" -ge "${2:-0}" ] || continue root="${WRITABLE_REALS[i]}" [ -n "$root" ] || continue case "$real" in @@ -404,6 +415,121 @@ protect_resolved() { return "$found" } +control_path_forms() { + local p="$1" forms real ancestor + forms="$(path_forms "$p")" || fail "cannot resolve control path: $p" + real="$(readlink -e -- "$p" 2>/dev/null || true)" + [ "$real" != / ] || fail "control path covers the filesystem root: $p" + case "$real" in *$'\n'*|*$'\r'*) fail "unrepresentable control path: $p" ;; esac + if [ -z "$real" ]; then + check_chain "$p" control + ancestor="$p" + while [ ! -e "$ancestor" ] && [ ! -L "$ancestor" ] && [ "$ancestor" != / ]; do + ancestor="${ancestor%/*}" + [ -n "$ancestor" ] || ancestor=/ + done + [ -d "$ancestor" ] && [ -x "$ancestor" ] || fail "cannot resolve control path: $p" + [ ! -e "$p" ] && [ ! -L "$p" ] || fail "cannot resolve control path: $p" + elif [ ! -d "$real" ]; then + fail "control path is not a directory: $p" + fi + CONTROL_FORMS="$forms" + CONTROL_REAL="$real" +} + +chain_link_writable() { + local parent="$1" comp="$2" i root view + if [ "$3" != control ]; then + sandbox_view "$parent" && ! inside_ro_area "$SV_VIEW/$comp" + return + fi + for ((i = CONTROL_ROOT_START; i < ${#WRITABLE_REALS[@]}; i++)); do + root="${WRITABLE_REALS[i]}" + case "$parent" in + "$root") view="${WRITABLE_ROOTS[i]}" ;; + "$root"/*) view="${WRITABLE_ROOTS[i]}${parent#"$root"}" ;; + *) continue ;; + esac + inside_ro_area "$view/$comp" || return 0 + done + return 1 +} + +control_visible() { + local i root + for ((i = CONTROL_ROOT_START; i < ${#WRITABLE_REALS[@]}; i++)); do + root="${WRITABLE_REALS[i]}" + case "$1/" in "$root"/*) return 0 ;; esac + done + return 1 +} + +protect_control_tree() { + local i real p forms control requested request_form request_lexical request_real root pin_start + local -a controls=() control_forms=() + CONTROL_ROOT_START="${#WRITABLE_ROOTS[@]}" + for i in "${!M_OP[@]}"; do + [ "${M_OP[i]}" = --bind ] || continue + real="$(readlink -e -- "${M_SRC[i]}" 2>/dev/null)" && [ -n "$real" ] || fail "cannot resolve writable mount for control path $CONTROL_DIR: ${M_SRC[i]}" + WRITABLE_ROOTS+=("${M_DEST[i]}"); WRITABLE_REALS+=("$real") + done + control_path_forms "$CONTROL_DIR" + debug "control: $CONTROL_DIR -> $CONTROL_REAL ($CONTROL_FORMS)" + forms="$CONTROL_FORMS" + controls+=("$CONTROL_REAL"); control_forms+=("$forms") + control_path_forms "$CONTROL_DIR/processed" + controls+=("$CONTROL_REAL"); control_forms+=("$CONTROL_FORMS") + for i in "${!control_forms[@]}"; do + forms="${control_forms[i]}"; real="${controls[i]}" + while IFS= read -r control; do + for requested in "${RW_DIRS[@]}"; do + request_lexical="$(realpath -m -s -- "$requested" 2>/dev/null)" && [ -n "$request_lexical" ] && + request_real="$(readlink -m -- "$requested" 2>/dev/null)" && [ -n "$request_real" ] || fail "cannot resolve requested mount for control path: $CONTROL_DIR" + for request_form in "$request_lexical" "$request_real"; do + case "$request_form/" in "$control"/*) fail "refusing writable mount '$requested' at or below control path: $CONTROL_DIR" ;; esac + done + done + if [ -z "$real" ] && control_visible "$control"; then + fail "missing visible control path: $CONTROL_DIR${i:+ (including processed)}" + fi + done <<<"$forms" + [ -n "$real" ] || continue + for ((p = CONTROL_ROOT_START; p < ${#WRITABLE_REALS[@]}; p++)); do + root="${WRITABLE_REALS[p]}" + case "$root/" in "$real"/*) fail "writable mount '${WRITABLE_ROOTS[p]}' overlaps control path: $CONTROL_DIR" ;; esac + case "$real/" in "$root"/*) + case "$root${WRITABLE_ROOTS[p]}" in *$'\n'*|*$'\r'*) fail "unrepresentable overlapping mount for control path: $CONTROL_DIR" ;; esac ;; + esac + done + p="$CONTROL_DIR" + [ "$i" = 0 ] || p="$CONTROL_DIR/processed" + check_chain "$p" control + unset 'PROTECTED_SEEN[$real]' + pin_start="${#PIN_VIEWS[@]}" + protect_resolved "$real" "$CONTROL_ROOT_START" || true + CONTROL_VIEWS+=("${PIN_VIEWS[@]:pin_start}") + done +} + +verify_control_mounts() { + local k i view dest best depth best_depth + for k in "${!CONTROL_VIEWS[@]}"; do + view="${CONTROL_VIEWS[k]}" + best=""; best_depth=-1 + for i in "${!M_OP[@]}"; do + dest="${M_DEST[i]}" + case "$view/" in "$dest"/*) + depth="${dest//[!\/]/}" + if [ "${#depth}" -ge "$best_depth" ]; then best="$i"; best_depth="${#depth}"; fi ;; + esac + case "$dest/" in "$view"/*) + [ "${M_OP[i]}" = --ro-bind ] || fail "mount '$dest' reopens control path: $CONTROL_DIR" ;; + esac + done + [ -n "$best" ] && [ "${M_OP[best]}" = --ro-bind ] || fail "unprotected control path: $CONTROL_DIR ($view)" + done +} + # Link $1, resolved to $2: its target read-only, and every link below that # target when it is a directory. protect_resolved_link() { @@ -796,6 +922,10 @@ build_bwrap_args() { GIT_WORKTREES=() OPT_IN_BINDS=() PRIVATE_LINKS=() + CONTROL_VIEWS=() + WRITABLE_ROOTS=("${BASE_WRITABLE_ROOTS[@]}") + WRITABLE_REALS=() + for d in "${WRITABLE_ROOTS[@]}"; do WRITABLE_REALS+=("$(readlink -m -- "$d" 2>/dev/null || true)"); done local d name i mount_op --dev "" /dev mount_op --proc "" /proc @@ -842,7 +972,9 @@ build_bwrap_args() { for d in ${GIT_WORKTREES[@]+"${GIT_WORKTREES[@]}"}; do protect_git "$d" done + protect_control_tree pin_protected_paths + verify_control_mounts check_opt_in_binds BWRAP_ARGS=(--unshare-all --share-net --die-with-parent) @@ -908,5 +1040,6 @@ for _pass in 1 2 3; do done build_bwrap_args done +build_bwrap_args [ "${#MISSING_DIRS[@]}" -eq 0 ] || fail "could not prepare the mount points: ${MISSING_DIRS[*]}" exec bwrap "${BWRAP_ARGS[@]}" "$CLAUDE_REAL" "$@" 9< <(claude_json_source) diff --git a/src/main/triggers/trigger-watcher.js b/src/main/triggers/trigger-watcher.js index ea606bf90..10356b01c 100644 --- a/src/main/triggers/trigger-watcher.js +++ b/src/main/triggers/trigger-watcher.js @@ -932,7 +932,7 @@ function waitForBusyFall(sessionId, ctx, deadlineMs, enterAtMs, turnObserved = f } function getTriggersDir() { - return process.env.SWITCHBOARD_TRIGGERS_DIR || DEFAULT_TRIGGERS_DIR; + return path.resolve(process.env.SWITCHBOARD_TRIGGERS_DIR || DEFAULT_TRIGGERS_DIR); } function getIdleTimeout() { @@ -2171,4 +2171,4 @@ function start(ctx) { }; } -module.exports = { start, weakestSubmitted, SUBMITTED_RANK, normalizeCwd, submitWithVerify, waitForCliIdleAfter, waitForBusyFall, isCompactCommand, REASON_REMOTE_NO_FRESH_PULL, REASON_REMOTE_SHELL }; +module.exports = { start, getTriggersDir, weakestSubmitted, SUBMITTED_RANK, normalizeCwd, submitWithVerify, waitForCliIdleAfter, waitForBusyFall, isCompactCommand, REASON_REMOTE_NO_FRESH_PULL, REASON_REMOTE_SHELL }; diff --git a/test/run-schedule-now-result.test.js b/test/run-schedule-now-result.test.js index 1bf413943..9ce8e106f 100644 --- a/test/run-schedule-now-result.test.js +++ b/test/run-schedule-now-result.test.js @@ -48,12 +48,19 @@ test('VM loader rejects an unused lazy stub key after the operation', () => { function setup(t, { sandbox = false, platform = 'linux', refused = false, spawnError, syncError, archived = false, noReply = false, deferSpawn = false, windowState = 'open' } = {}) { const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sb-run-result-'))); t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const previousTriggers = process.env.SWITCHBOARD_TRIGGERS_DIR; + process.env.SWITCHBOARD_TRIGGERS_DIR = path.join(root, 'triggers'); + t.after(() => { + if (previousTriggers === undefined) delete process.env.SWITCHBOARD_TRIGGERS_DIR; + else process.env.SWITCHBOARD_TRIGGERS_DIR = previousTriggers; + }); const file = path.join(root, '.claude', 'commands', 'schedule-test.md'); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, '---\nname: test\n---\nDo the task'); const handlers = new Map(), children = [], events = [], messages = [], logs = [], activities = []; const log = { info: (...args) => logs.push(args), warn: (...args) => logs.push(args), error: (...args) => logs.push(args) }; const ctx = vm.createContext({ + require: createRequire(path.join(__dirname, '..', 'main.js')), fs, path, getSetting: () => ({}), isProjectSettingsArchived: () => archived, Date, mainWindow: windowState === 'absent' ? null : { isDestroyed: () => windowState === 'destroyed', diff --git a/test/sandbox-control-launch.test.js b/test/sandbox-control-launch.test.js new file mode 100644 index 000000000..9b02dc53c --- /dev/null +++ b/test/sandbox-control-launch.test.js @@ -0,0 +1,113 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); +const espree = require('espree'); +const { EventEmitter } = require('node:events'); +const watcher = require('../src/main/triggers/trigger-watcher'); + +const filename = path.join(__dirname, '..', 'main.js'); +const source = fs.readFileSync(filename, 'utf8'); +const ast = espree.parse(source, { ecmaVersion: 'latest', sourceType: 'commonjs', range: true }); + +function shippedLaunch(mode) { + function find(node) { + if (!node || typeof node !== 'object') return null; + if (mode === 'schedule' + ? node.type === 'FunctionDeclaration' && node.id.name === 'runScheduleCommand' + : node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' + && node.expression.callee.object?.name === 'ipcMain' + && node.expression.arguments[0]?.value === 'open-terminal') return node; + for (const value of Object.values(node)) { + const found = Array.isArray(value) ? value.map(find).find(Boolean) : find(value); + if (found) return found; + } + return null; + } + const node = find(ast); + assert.ok(node, 'shipped launch entry point exists'); + return source.slice(...node.range); +} + +for (const mode of ['new', 'resume', 'fork', 'schedule']) { + test(`GIVEN a relative trigger override WHEN ${mode} launches THEN the watcher path reaches its spawn`, async t => { + const previous = process.env.SWITCHBOARD_TRIGGERS_DIR; + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-control-launch-')); + t.after(() => { + if (previous === undefined) delete process.env.SWITCHBOARD_TRIGGERS_DIR; + else process.env.SWITCHBOARD_TRIGGERS_DIR = previous; + fs.rmSync(base, { recursive: true, force: true }); + }); + const expected = path.join(base, 'control with spaces'); + process.env.SWITCHBOARD_TRIGGERS_DIR = path.relative(process.cwd(), expected); + assert.equal(watcher.getTriggersDir(), expected); + const cwd = path.join(base, 'other-cwd'); + fs.mkdirSync(cwd); + const calls = []; + const handlers = new Map(); + const spawn = (shell, args, options) => { + calls.push({ shell, args, options }); + const child = new EventEmitter(); + child.stderr = new EventEmitter(); + queueMicrotask(() => child.emit('spawn')); + return child; + }; + const ctx = vm.createContext({ + require: createRequire(filename), fs, path, os, process: { platform: 'linux' }, + ipcMain: { handle: (name, fn) => handlers.set(name, fn) }, + mainWindow: {}, unsavedGuard: { launchAllowed: () => true }, activeSessions: new Map(), + getCachedFolder: () => null, isRemoteFolder: () => false, + remoteIndexer: { findSessionAlias: () => null }, resolveSessionRealCwd: () => cwd, + PROJECTS_DIR: path.join(base, 'transcripts'), + normalizePtySize: require('../src/main/terminals/pty-size').normalizePtySize, + isWslShell: () => false, encodeProjectPath: () => 'fixture', + getSetting: () => ({}), SETTING_DEFAULTS: {}, resolveShell: () => ({ path: '/bin/bash' }), + sessionTransitions: { snapshotContinuationsBeforeSpawn: async () => null }, + sessionTranscriptExists: () => true, parseAddDirs: () => [], sandboxBindEnv: () => '', + quoteArgvForShell: (_shell, args) => args.join(' '), shellArgs: (_shell, command) => ['-c', command], + sandboxScriptPath: () => '/sandbox.sh', cleanPtyEnv: {}, + scheduleProjects: () => ({ add: async () => {}, list: async () => [] }), + resolveScheduleSandbox: () => true, + scheduleLaunchRefusal: require('../src/main/schedules/schedule-runner').scheduleLaunchRefusal, + getHarness: require('../harnesses').getHarness, + isProjectSettingsArchived: () => false, + log: { info() {}, warn() {}, error() {} }, + cliSessionState: { trackScheduleRun() {}, ensureWatching: () => true }, + activityReporter: { sessionStarted() {} }, trackWindowsPty() {}, + createComposerState: require('../src/main/terminals/composer-state').createComposerState, + revealIfSdkSession() {}, wireSessionPty() {}, getMcpState: () => null, + spawnPty: spawn, cpSpawn: spawn, + }); + vm.runInContext(shippedLaunch(mode), ctx, { filename }); + const result = mode === 'schedule' + ? await ctx.runScheduleCommand([], cwd, 'test', null) + : await handlers.get('open-terminal')(null, 'session-id', base, mode !== 'resume', { + sandbox: true, mcpEmulation: false, ...(mode === 'fork' ? { forkFrom: 'source-id' } : {}), + }); + assert.equal(result.ok, true, result.error); + assert.equal(calls.length, 1, `${mode} must reach exactly one spawn`); + const call = calls[0]; + assert.equal(call.options.env.SWITCHBOARD_SANDBOX_TRIGGERS_DIR, expected, `${mode} spawn receives the watcher path`); + assert.equal(call.options.cwd, mode === 'new' ? base : cwd); + assert.ok(call.args.join(' ').includes('/sandbox.sh'), 'spawn executes the sandbox wrapper'); + if (mode === 'new') assert.match(call.args.join(' '), /--session-id session-id/); + if (mode === 'resume') assert.match(call.args.join(' '), /--resume session-id/); + if (mode === 'fork') assert.match(call.args.join(' '), /--resume source-id --fork-session/); + assert.ok(fs.statSync(path.join(expected, 'processed')).isDirectory()); + }); +} + +test('GIVEN an empty override WHEN the watcher resolves its directory THEN it uses the absolute default', t => { + const previous = process.env.SWITCHBOARD_TRIGGERS_DIR; + t.after(() => { + if (previous === undefined) delete process.env.SWITCHBOARD_TRIGGERS_DIR; + else process.env.SWITCHBOARD_TRIGGERS_DIR = previous; + }); + process.env.SWITCHBOARD_TRIGGERS_DIR = ''; + assert.equal(watcher.getTriggersDir(), path.resolve(os.homedir(), '.switchboard', 'triggers')); +}); diff --git a/test/sandbox-wrapper.test.js b/test/sandbox-wrapper.test.js index edfa2afc1..278b9c326 100644 --- a/test/sandbox-wrapper.test.js +++ b/test/sandbox-wrapper.test.js @@ -14,6 +14,138 @@ const { spawnSync } = require('node:child_process'); const SCRIPT = path.join(__dirname, '..', 'scripts', 'claude-sandbox.sh'); const LINUX = process.platform === 'linux'; +const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'; +const FAKE_BWRAP_SUPPORTED = LINUX; +const bashPath = p => process.platform === 'win32' + ? p.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, drive) => '/' + drive.toLowerCase()) : p; + +function controlRig(t) { + const rig = makeRig({ recordArgs: true }); + t.after(() => rig.cleanup()); + fs.mkdirSync(path.join(rig.home, '.claude')); + if (!LINUX) { + for (const d of ['.config/claude', '.cache/claude', '.local/share/claude', + '.claude/projects/fixture', '.claude/todos', '.claude/statsig', '.claude/file-history', + '.claude/sessions', '.claude/plans', '.claude/tasks', '.claude/ide']) { + fs.mkdirSync(path.join(rig.home, d), { recursive: true }); + } + fs.mkdirSync(path.join(rig.proj, '.claude')); + } + return rig; +} + +for (const placement of ['project', 'extra', 'state', 'opt-in']) { + test(`control mounts: ${placement} protects the whole tree and pins ancestors`, + { skip: !FAKE_BWRAP_SUPPORTED && 'requires bash and POSIX tools' }, t => { + const rig = controlRig(t); + const parent = placement === 'state' ? path.join(rig.home, '.cache', 'claude') + : placement === 'opt-in' ? path.join(rig.home, '.claude', 'skills') + : placement === 'extra' ? path.join(rig.root, 'extra') : rig.proj; + const control = path.join(parent, 'ancestor', 'control with spaces'); + fs.mkdirSync(path.join(control, 'processed'), { recursive: true }); + if (!LINUX && placement === 'extra') fs.mkdirSync(path.join(parent, '.claude')); + const env = { SWITCHBOARD_SANDBOX_TRIGGERS_DIR: bashPath(control) }; + if (placement === 'extra') env.SWITCHBOARD_SANDBOX_BINDS = bashPath(parent); + if (placement === 'opt-in') env.SWITCHBOARD_SANDBOX_RW_SKILLS = '1'; + const res = rig.run(['--version'], env); + assert.equal(res.status, 0, res.stderr); + const mounts = parseMounts(rig.lastBwrapArgs()); + for (const p of [control, path.join(control, 'processed', 'result.json'), path.join(control, 'action.marker')]) { + assert.equal(accessAt(mounts, bashPath(p)), '--ro-bind', `control path stays read-only: ${p}`); + } + const pin = mountAt(mounts, bashPath(path.dirname(control))); + assert.equal(pin?.op, '--bind', 'intervening ancestor is pinned'); + assert.ok(pin.index < mountAt(mounts, bashPath(control)).index, 'pin is emitted before protection'); + }); +} + +for (const kind of ['cwd', 'extra', 'missing root', 'missing processed', 'relative internal', 'filesystem root', 'non-directory ancestor', 'newline', 'dangling', 'loop']) { + test(`control refusal: ${kind} fails closed before bwrap`, + { skip: !FAKE_BWRAP_SUPPORTED || (!LINUX && ['dangling', 'loop'].includes(kind)) + ? 'requires Linux symlinks or bash and POSIX tools' : false }, t => { + const rig = controlRig(t); + let control = path.join(rig.proj, 'control'); + fs.mkdirSync(path.join(control, 'processed'), { recursive: true }); + const env = {}; + let cwd = rig.proj; + if (kind === 'cwd') cwd = path.join(control, 'processed'); + if (kind === 'extra') env.SWITCHBOARD_SANDBOX_BINDS = bashPath(path.join(control, 'processed')); + if (kind === 'missing root') fs.rmSync(control, { recursive: true }); + if (kind === 'missing processed') fs.rmdirSync(path.join(control, 'processed')); + if (kind === 'relative internal') control = 'control'; + if (kind === 'filesystem root') control = '/'; + if (kind === 'non-directory ancestor') { + const ancestor = path.join(rig.root, 'outside-file'); + fs.writeFileSync(ancestor, 'file'); + control = path.join(ancestor, 'control'); + } + if (kind === 'newline') control += '\nother'; + if (kind === 'dangling' || kind === 'loop') { + fs.rmSync(control, { recursive: true }); + fs.symlinkSync(kind === 'loop' ? control : path.join(rig.proj, 'absent'), control); + } + env.SWITCHBOARD_SANDBOX_TRIGGERS_DIR = kind === 'relative internal' ? control : bashPath(control); + const homeBefore = fs.readdirSync(rig.home); + const res = rig.run(['--version'], env, cwd); + assert.equal(res.status, 125, 'unsafe control path must refuse'); + assert.match(res.stderr, /claude-sandbox:.*control/i, 'diagnostic identifies the control path'); + assert.deepEqual(fs.readdirSync(path.join(rig.root, 'bwrap-args')), [], 'no sandbox or CLI execution'); + assert.deepEqual(fs.readdirSync(rig.home), homeBefore, 'preflight refusal has no host state effects'); + }); +} + +test('control mounts: standalone override and default outside writable mounts stay unexposed', + { skip: !FAKE_BWRAP_SUPPORTED && 'requires bash and POSIX tools' }, t => { + const rig = controlRig(t); + for (const env of [{}, { SWITCHBOARD_TRIGGERS_DIR: bashPath(path.join(rig.root, 'outside')) }]) { + const res = rig.run(['--version'], env); + assert.equal(res.status, 0, res.stderr); + const access = accessAt(parseMounts(rig.lastBwrapArgs()), bashPath(path.join(rig.root, 'outside'))); + assert.ok(access === undefined || access === '--tmpfs', `outside control tree must not be bound, got ${access}`); + } + const control = path.join(rig.proj, 'relative-control'); + fs.mkdirSync(path.join(control, 'processed'), { recursive: true }); + const res = rig.run(['--version'], { SWITCHBOARD_TRIGGERS_DIR: 'relative-control', SWITCHBOARD_SANDBOX_DEBUG: '1' }); + assert.equal(res.status, 0, res.stderr); + assert.equal(accessAt(parseMounts(rig.lastBwrapArgs()), bashPath(control)), '--ro-bind', res.stderr); + }); + +test('control mounts: private CLI trees stay unexposed and the visible default is protected', + { skip: !FAKE_BWRAP_SUPPORTED && 'requires bash and POSIX tools' }, t => { + const rig = controlRig(t); + const privateControl = path.join(rig.home, '.claude', 'shell-snapshots', 'control'); + fs.mkdirSync(path.join(privateControl, 'processed'), { recursive: true }); + let res = rig.run(['--version'], { SWITCHBOARD_SANDBOX_TRIGGERS_DIR: bashPath(privateControl) }); + assert.equal(res.status, 0, res.stderr); + assert.ok(!parseMounts(rig.lastBwrapArgs()).some(op => op.src === bashPath(privateControl)), 'a private CLI tree must not expose host control files'); + const defaultControl = path.join(rig.home, '.switchboard', 'triggers'); + fs.mkdirSync(path.join(defaultControl, 'processed'), { recursive: true }); + if (!LINUX) fs.mkdirSync(path.join(rig.home, '.switchboard', '.claude')); + res = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: bashPath(path.dirname(defaultControl)), SWITCHBOARD_TRIGGERS_DIR: '' }); + assert.equal(res.status, 0, res.stderr); + assert.equal(accessAt(parseMounts(rig.lastBwrapArgs()), bashPath(defaultControl)), '--ro-bind'); + }); + +test('control mounts: final plan rechecks a control tree moved after preflight', + { skip: !FAKE_BWRAP_SUPPORTED && 'requires bash and POSIX tools' }, t => { + const rig = controlRig(t); + const control = path.join(rig.proj, 'control'); + fs.mkdirSync(path.join(control, 'processed'), { recursive: true }); + if (LINUX) { + for (const d of ['.config/claude', '.cache/claude', '.local/share/claude', + '.claude/projects/fixture', '.claude/todos', '.claude/statsig', '.claude/file-history', + '.claude/sessions', '.claude/plans', '.claude/tasks', '.claude/ide']) { + fs.mkdirSync(path.join(rig.home, d), { recursive: true }); + } + fs.mkdirSync(path.join(rig.proj, '.claude')); + } + const fake = path.join(rig.root, 'bin', 'bwrap'); + fs.writeFileSync(fake, read0(fake).replace(/\nexit 0\n$/, `\n[ "$n" != 0 ] || mv -- "${bashPath(control)}" "${bashPath(control)}.gone"\nexit 0\n`)); + const res = rig.run(['--version'], { SWITCHBOARD_SANDBOX_TRIGGERS_DIR: bashPath(control), SWITCHBOARD_SANDBOX_PROJECT_FOLDER: 'fixture' }); + assert.equal(res.status, 125, 'a control path lost after preflight refuses the CLI launch'); + assert.match(res.stderr, /missing visible control path/); + assert.equal(fs.readdirSync(path.join(rig.root, 'bwrap-args')).length, 1, 'only the preflight ran'); + }); /** * The environment every child of these tests runs with: no inherited GIT_* or @@ -68,8 +200,8 @@ function makeRig({ bwrapExit = null, recordArgs = false, claudeRunsArg = false } fs.writeFileSync(path.join(bin, 'bwrap'), [ '#!/usr/bin/env bash', '[ "${1:-}" = "--version" ] && { echo "bubblewrap 0.11.1"; exit 0; }', - `n=$(ls "${argsDir}" | wc -l)`, - `printf '%s\\0' "$@" > "${argsDir}/$n"`, + `n=$(ls "${bashPath(argsDir)}" | wc -l)`, + `printf '%s\\0' "$@" > "${bashPath(argsDir)}/$n"`, 'exit 0', ].join('\n') + '\n'); fs.chmodSync(path.join(bin, 'bwrap'), 0o755); @@ -89,13 +221,15 @@ function makeRig({ bwrapExit = null, recordArgs = false, claudeRunsArg = false } root, home, proj, /** Run the wrapper; returns { status, stdout, stderr }. */ run(args = ['--version'], env = {}, cwd = proj) { - const res = spawnSync('bash', [SCRIPT, ...args], { + const res = spawnSync(process.platform === 'win32' ? GIT_BASH : 'bash', [bashPath(SCRIPT), ...args], { cwd, encoding: 'utf8', env: cleanEnv({ - HOME: home, - PATH: `${bin}:${process.env.PATH}`, + HOME: bashPath(home), + PWD: bashPath(cwd), + PATH: process.platform === 'win32' ? `${bashPath(bin)}:/usr/bin:/bin` : `${bin}:${process.env.PATH}`, SWITCHBOARD_SANDBOX_DEBUG: '0', + ...(!LINUX ? { SWITCHBOARD_SANDBOX_PROJECT_FOLDER: 'fixture' } : {}), GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', ...env, @@ -189,7 +323,7 @@ test('sandbox wrapper: restricted-userns failure points at the sysctl and AppArm }); test('sandbox wrapper: extra binds survive a newline in a path and missing ones are reported', { skip: !LINUX && 'linux only' }, () => { - const rig = makeRig({ bwrapExit: 1 }); + const rig = makeRig({ recordArgs: true }); try { fs.mkdirSync(path.join(rig.home, '.claude')); // 'IFS=: read -a' without -d '' stops at the first newline, silently @@ -199,10 +333,11 @@ test('sandbox wrapper: extra binds survive a newline in a path and missing ones fs.mkdirSync(weird); fs.mkdirSync(after); - const { stderr } = rig.run(['--version'], { + const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_DEBUG: '1', SWITCHBOARD_SANDBOX_BINDS: `${weird}:${after}:${path.join(rig.root, 'gone')}`, }); + assert.equal(status, 0, 'unrelated newline bind must launch: ' + stderr); assert.ok(stderr.includes(`rw-bind ${weird}`), 'a path containing a newline must be bound whole'); assert.ok(stderr.includes(`rw-bind ${after}`), 'binds after a newline-containing one must not be dropped'); assert.match(stderr, /skipping bind — does not exist/, 'a missing extra bind must be reported, not mkdir\'d'); @@ -212,6 +347,26 @@ test('sandbox wrapper: extra binds survive a newline in a path and missing ones } }); +test('control mounts: an unrelated newline project launches but an overlapping newline alias refuses', + { skip: !LINUX && 'requires Linux newline filenames and symlinks' }, t => { + const rig = controlRig(t); + const project = path.join(rig.root, 'project\nname'); + fs.mkdirSync(project); + let res = rig.run(['--version'], {}, project); + assert.equal(res.status, 0, 'unrelated newline project must launch: ' + res.stderr); + const parent = path.join(rig.root, 'control-parent'); + const control = path.join(parent, 'control'); + fs.mkdirSync(path.join(control, 'processed'), { recursive: true }); + const alias = path.join(rig.root, 'alias\nname'); + fs.symlinkSync(parent, alias); + res = rig.run(['--version'], { + SWITCHBOARD_SANDBOX_TRIGGERS_DIR: control, + SWITCHBOARD_SANDBOX_BINDS: alias, + }); + assert.equal(res.status, 125, 'unrepresentable overlapping alias must refuse'); + assert.match(res.stderr, /unrepresentable overlapping mount for control path/); + }); + test('sandbox wrapper: refuses an extra bind at or under a .claude or .git, however the path is spelled', { skip: !LINUX && 'linux only' }, () => { const rig = makeRig({ bwrapExit: 1 }); try { @@ -1805,3 +1960,123 @@ test('sandbox wrapper: from inside a real sandbox, an opted-in skills linked to rig.cleanup(); } }); + +function aliasedControlRig(t, real = false) { + const rig = real ? makeRig({ claudeRunsArg: true }) : makeRig({ recordArgs: true }); + t.after(() => rig.cleanup()); + fs.mkdirSync(path.join(rig.home, '.claude')); + const control = path.join(rig.proj, 'ancestor', 'control with spaces'); + const results = path.join(rig.root, 'results', 'ancestor', 'store'); + const alias = path.join(rig.root, 'stable-project-alias'); + const lexical = path.join(rig.root, 'stable-control-link'); + const cwd = path.join(rig.proj, 'worktree'); + fs.mkdirSync(control, { recursive: true }); + fs.mkdirSync(results, { recursive: true }); + fs.mkdirSync(cwd); + fs.symlinkSync(rig.proj, alias); + fs.symlinkSync(control, lexical); + fs.symlinkSync(results, path.join(control, 'processed')); + fs.writeFileSync(path.join(control, 'action.marker'), 'marker'); + fs.writeFileSync(path.join(results, 'result.json'), 'result'); + const env = { + SWITCHBOARD_SANDBOX_TRIGGERS_DIR: lexical, + SWITCHBOARD_SANDBOX_BINDS: [rig.proj, alias, path.dirname(path.dirname(results))].join(':'), + }; + return { rig, control, results, alias, lexical, cwd, env }; +} + +test('control aliases: every overlapping project/worktree alias and separate processed target is protected', + { skip: !LINUX && 'requires Linux symlinks' }, t => { + const { rig, control, results, alias, cwd, env } = aliasedControlRig(t); + const res = rig.run(['--version'], env, cwd); + assert.equal(res.status, 0, res.stderr); + const ops = parseMounts(rig.lastBwrapArgs()); + for (const view of [control, path.join(alias, 'ancestor', 'control with spaces'), results]) { + assert.equal(accessAt(ops, path.join(view, 'new.json')), '--ro-bind', view); + const pin = mountAt(ops, path.dirname(view)); + assert.equal(pin?.op, '--bind'); + assert.ok(pin.index < mountAt(ops, view).index); + assert.ok(!ops.some(op => op.op === '--bind' && op.dest.startsWith(view + '/')), 'no writable descendant reopens protection'); + } + for (const [extra, spawnCwd] of [[control, cwd], [path.join(alias, 'ancestor', 'control with spaces'), cwd], + [results, cwd], [rig.proj, control], [rig.proj, path.join(alias, 'ancestor', 'control with spaces')]]) { + const refused = rig.run(['--version'], { ...env, SWITCHBOARD_SANDBOX_BINDS: extra }, spawnCwd); + assert.equal(refused.status, 125, `requested writable control alias refuses: ${refused.stderr}`); + assert.match(refused.stderr, /control path/); + } + }); + +test('control aliases: mutable links and writable state descendants refuse before any bwrap execution', + { skip: !LINUX && 'requires Linux symlinks' }, t => { + const rig = controlRig(t); + const control = path.join(rig.proj, 'control'); + fs.mkdirSync(path.join(control, 'processed'), { recursive: true }); + fs.symlinkSync(control, path.join(rig.proj, 'mutable')); + let res = rig.run(['--version'], { SWITCHBOARD_SANDBOX_TRIGGERS_DIR: path.join(rig.proj, 'mutable') }); + assert.equal(res.status, 125, res.stderr); + assert.match(res.stderr, /symbolic link.*sandbox can write/); + const cache = path.join(rig.home, '.cache', 'claude'); + fs.mkdirSync(path.dirname(cache), { recursive: true }); + fs.mkdirSync(path.join(control, 'descendant')); + fs.symlinkSync(path.join(control, 'descendant'), cache); + res = rig.run(['--version'], { SWITCHBOARD_SANDBOX_TRIGGERS_DIR: control }); + assert.equal(res.status, 125, res.stderr); + assert.match(res.stderr, /overlaps control path|reopens control path/); + assert.deepEqual(fs.readdirSync(path.join(rig.root, 'bwrap-args')), []); + }); + +test('control filesystem: real bwrap denies writes and replacements through all aliases while host publication and normal writes work', + { skip: !realBwrapWorks && 'requires a usable Linux unprivileged user namespace and real bwrap' }, t => { + const { rig, control, results, alias, cwd, env } = aliasedControlRig(t, true); + const views = [control, path.join(alias, 'ancestor', 'control with spaces'), results]; + const attempts = []; + const reads = []; + const siblings = []; + for (const [i, view] of views.entries()) { + const target = i < 2 ? 'action.marker' : 'result.json'; + reads.push(`test "$(cat '${view}/${target}')" = '${i < 2 ? 'marker' : 'result'}' || exit 81`); + siblings.push(path.join(path.dirname(view), `sibling-${i}.txt`)); + reads.push(`echo sibling-${i} > '${siblings.at(-1)}' || exit 82`); + siblings.push(path.join(path.dirname(path.dirname(view)), `ancestor-sibling-${i}.txt`)); + reads.push(`echo ancestor-sibling-${i} > '${siblings.at(-1)}' || exit 83`); + if (i < 2) reads.push(`test "$(cat '${view}/processed/result.json')" = 'result' || exit 84`); + attempts.push(`attempt create-${i} 'echo evil > "${view}/new.json"'`); + attempts.push(`attempt publish-${i} 'echo staged > "$PWD/staged-${i}" && mv "$PWD/staged-${i}" "${view}/new.json"'`); + attempts.push(`attempt overwrite-${i} 'echo evil > "${view}/${target}"'`); + attempts.push(`attempt unlink-${i} 'rm "${view}/${target}"'`); + attempts.push(`attempt replace-${i} 'mv "${view}" "${view}-old" && mkdir "${view}"'`); + const ancestor = path.dirname(view); + attempts.push(`attempt ancestor-${i} 'mv "${ancestor}" "${ancestor}-old" && mkdir "${ancestor}"'`); + if (i < 2) { + attempts.push(`attempt result-overwrite-${i} 'echo evil > "${view}/processed/result.json"'`); + attempts.push(`attempt result-unlink-${i} 'rm "${view}/processed/result.json"'`); + attempts.push(`attempt processed-${i} 'mv "${view}/processed" "${view}/processed-old" && mkdir "${view}/processed"'`); + } + } + const payload = [ + 'attempt() { if bash -c "$2" >/dev/null 2>&1; then echo "UNEXPECTED:$1"; else echo "DENIED:$1"; fi; }', + ...reads, + ...attempts, + 'echo ordinary > "$PWD/ordinary.txt"', + 'echo transcript >> "$HOME/.claude/projects/$SWITCHBOARD_SANDBOX_PROJECT_FOLDER/session.jsonl"', + ].join('\n'); + const res = rig.run([payload], { ...env, SWITCHBOARD_SANDBOX_PROJECT_FOLDER: 'fixture' }, cwd); + assert.equal(res.status, 0, res.stderr); + assert.doesNotMatch(res.stdout, /UNEXPECTED:/); + for (const [i, sibling] of siblings.entries()) { + const label = i % 2 === 0 ? `sibling-${Math.floor(i / 2)}` : `ancestor-sibling-${Math.floor(i / 2)}`; + assert.equal(read0(sibling), `${label}\n`, 'pinned ancestors and their parents still allow sibling writes'); + } + assert.equal(res.stdout.split('\n').filter(line => line.startsWith('DENIED:')).length, attempts.length); + assert.equal(read0(path.join(control, 'action.marker')), 'marker'); + assert.equal(read0(path.join(results, 'result.json')), 'result'); + assert.equal(fs.existsSync(path.join(control, 'new.json')), false); + assert.equal(read0(path.join(cwd, 'ordinary.txt')), 'ordinary\n'); + assert.equal(read0(path.join(rig.home, '.claude/projects/fixture/session.jsonl')), 'transcript\n'); + fs.writeFileSync(path.join(control, 'host.tmp'), 'host-trigger'); + fs.renameSync(path.join(control, 'host.tmp'), path.join(control, 'host.json')); + fs.writeFileSync(path.join(results, 'host.tmp'), 'host-result'); + fs.renameSync(path.join(results, 'host.tmp'), path.join(control, 'processed', 'host.result.json')); + assert.equal(read0(path.join(control, 'host.json')), 'host-trigger'); + assert.equal(read0(path.join(results, 'host.result.json')), 'host-result'); + });