From 40aa8026ca221721ba8a29efdb245015086f7517 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 22:03:37 +0200 Subject: [PATCH 1/2] (sandbox): refuse extra binds at or under .claude and .git An extra bind inside a .claude or .git was mounted read-write after, and over, the read-only protection of the same path. The wrapper now refuses such a bind (as spelled and by real path), refusedScheduleBinds applies the same rule and compares real paths, the worktree search's limits are documented, and resolveScheduleSandbox's path.resolve is pinned by a test. Refs #385 --- .ai/contexts/schedule-runner.md | 2 +- CHANGELOG.md | 3 ++ docs/sandbox.md | 16 +++++++ schedule-runner.js | 40 ++++++++++++---- scripts/claude-sandbox.sh | 12 +++++ test/sandbox-wrapper.test.js | 48 ++++++++++++++++++- test/schedule-project-provenance.test.js | 61 ++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 12 deletions(-) diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index 47ea5a44..fc6e8051 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -19,7 +19,7 @@ From `schedule-runner.js`: - `scheduleRegistry(getSetting, setSetting)` — the `scheduleProjects` setting: `list()`, `add(path)`, `remove(path)`. `main.js` adds a project when it spawns a Claude session in it and on `add-project`, removes it on `remove-project`. Transcripts never add one: their `cwd`, and a folder name derived from it, are written by whoever ran claude, a sandboxed session included (see [docs/sandbox.md](../../docs/sandbox.md), "Schedules"). - `initialScheduleProjects()` — the seed, read once while the setting has never been written: `~/.claude/projects` folders whose recorded path encodes back to the folder's name and holds a schedule. - `resolveScheduleSandbox(cwd, getSetting, default)` — the `project:` setting of `cwd` or of the nearest directory above it that has one, then `global`, then the default. -- `refusedScheduleBinds(addDirs, projects, home)` — the `add-dirs` under `home` that are neither a registered project nor inside one. `main.js` skips a sandboxed run when it is not empty. +- `refusedScheduleBinds(addDirs, projects, home)` — the `add-dirs` that are at or inside a `.claude` or `.git` (judged as spelled and by real path, anywhere), or under `home` and neither a registered project nor inside one (real paths). `main.js` skips a sandboxed run when it is not empty. - `createScheduleSession(schedule, dueMs)` — write a pre-seeded JSONL into `~/.claude/projects//.jsonl` with the schedule's prompt as the first user message, prefixed `Scheduled Task (catch-up: due …, started …): ` when `dueMs` is set. Returns the session UUID. - `buildScheduleCommand(sessionId, schedule)` — assemble the shell command (`claude --resume "" -p "..." --permission-mode acceptEdits --allowedTools "..."`). - `parseFrontmatter(content)`, `cronMatches(cronExpr, now)` — utilities, exported for tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dae2a5..f7019f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased +### Fixed +- A sandboxed session, or a sandboxed schedule, whose Additional Directories include a `.claude` or `.git` directory, or a path inside one, is now refused instead of binding it read-write over its read-only protection; add the project directory instead. (#385) + ## v0.0.86 — 2026-10-01 ### New diff --git a/docs/sandbox.md b/docs/sandbox.md index 2eef95e1..a531a3ac 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -210,6 +210,10 @@ The paths come from `git rev-parse --git-dir --git-common-dir --git-path hooks` run in the directory before launch; a repository it cannot read is refused rather than guessed at. +The search below a bound directory does not enter `node_modules`, and does not +cross into another filesystem (`find -xdev`): a `.git` or `.claude` inside a +`node_modules`, or on a mount or a bind inside the project, is not protected. + ### The way to a protected path A read-only mount protects one path, not the directories leading to it. Every @@ -225,6 +229,18 @@ The paths are resolved when the wrapper builds the sandbox and mounted when bwrap starts it; a second sandboxed session on the same project that swaps a directory in between is not detected. +### Additional directories + +An Additional Directory, or a schedule's `add-dirs` entry, is bound +read-write, so one at or inside a `.claude` or `.git` directory is refused: the +wrapper stops with status 125 and names it, and a sandboxed schedule with such +an entry is skipped with the reason in the main log. The path is judged both as +spelled (`..` and a trailing slash resolved) and by its real path, so a link +to a `.claude`, or a `.claude` that is itself a link, does not get through. Add +the project directory instead: its `.claude` and `.git` are then protected as +above. The session's own working directory is not checked this way, because a +session in a worktree under `.claude/worktrees` is legitimate. + ### Schedules The [scheduler](automation.md#schedules) runs the schedules of the projects in diff --git a/schedule-runner.js b/schedule-runner.js index dc4dd22d..d74f975c 100644 --- a/schedule-runner.js +++ b/schedule-runner.js @@ -238,21 +238,41 @@ function resolveScheduleSandbox(cwd, getSetting, defaultValue) { return !!defaultValue; } +/** The real path of `p`; for a path that does not exist, its nearest existing ancestor's real path plus the rest. */ +function canonicalPath(p) { + const resolved = path.resolve(p); + const rest = []; + let dir = resolved; + for (;;) { + try { + return path.join(fs.realpathSync(dir), ...rest.reverse()); + } catch { + const parent = path.dirname(dir); + if (parent === dir) return resolved; + rest.push(path.basename(dir)); + dir = parent; + } + } +} + /** - * The add-dirs of a sandboxed schedule that lie under $HOME without being a - * known project or inside one: binding them read-write would hand the run - * whatever they hold. + * The add-dirs of a sandboxed schedule that the wrapper must not bind: any at + * or inside a `.claude` or `.git` directory, and any under $HOME that is + * neither a known project nor inside one. Each is judged both as spelled + * (normalised) and by its real path. + * see docs/sandbox.md ("Additional directories") */ function refusedScheduleBinds(addDirs, knownProjects, home) { const inside = (p, dir) => p === dir || p.startsWith(dir + path.sep); - const homeDir = path.resolve(home); + const homeDir = canonicalPath(home); + const projects = knownProjects.map(canonicalPath); + const protectedName = (p) => p.split(path.sep).some(c => c === '.claude' || c === '.git'); return addDirs.filter((dir) => { - const p = path.resolve(dir); - if (!inside(p, homeDir)) return false; - for (const project of knownProjects) { - if (inside(p, path.resolve(project))) return false; - } - return true; + const lexical = path.resolve(dir); + const real = canonicalPath(dir); + if (protectedName(lexical) || protectedName(real)) return true; + if (!inside(real, homeDir)) return false; + return !projects.some(project => inside(real, project)); }); } diff --git a/scripts/claude-sandbox.sh b/scripts/claude-sandbox.sh index b440ed92..265b026e 100755 --- a/scripts/claude-sandbox.sh +++ b/scripts/claude-sandbox.sh @@ -140,6 +140,18 @@ for d in ${RW_DIRS[@]+"${RW_DIRS[@]}"}; do fi done +# see docs/sandbox.md ("Additional directories") +for d in ${EXTRA_BINDS[@]+"${EXTRA_BINDS[@]}"}; do + [ -n "$d" ] || continue + for _form in "$(realpath -m -s -- "$d")" "$(readlink -m -- "$d")"; do + case "/$_form/" in + */.claude/*|*/.git/*) + fail "refusing to bind '$d' — it is at or inside a .claude or .git directory, which the sandbox keeps read-only. Bind the project directory instead." + ;; + esac + done +done + # True when $1 is at or below one of the read-write state dirs. under_rw_state() { local candidate="$1/" d diff --git a/test/sandbox-wrapper.test.js b/test/sandbox-wrapper.test.js index 8f08a7b1..b490e4c3 100644 --- a/test/sandbox-wrapper.test.js +++ b/test/sandbox-wrapper.test.js @@ -212,7 +212,53 @@ test('sandbox wrapper: extra binds survive a newline in a path and missing ones } }); -test('sandbox wrapper: resolves the real binary when "claude" is also a shell function', { skip: !LINUX && 'linux only' }, () => { +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 { + fs.mkdirSync(path.join(rig.home, '.claude')); + const other = path.join(rig.root, 'other'); + fs.mkdirSync(path.join(other, '.claude', 'commands'), { recursive: true }); + fs.mkdirSync(path.join(other, '.git', 'hooks'), { recursive: true }); + fs.mkdirSync(path.join(other, 'sub')); + fs.symlinkSync(path.join(other, '.claude'), path.join(rig.root, 'lnk')); + fs.symlinkSync(path.join(other, 'sub'), path.join(other, '.git', 'back')); + + const refused = [ + path.join(other, '.claude'), + path.join(other, '.claude') + '/', + path.join(other, '.claude', 'commands'), + path.join(other, '.claude', 'missing'), + path.join(other, '.git'), + path.join(other, '.git', 'hooks'), + path.join(other, 'sub', '..', '.claude'), + path.join(rig.root, 'lnk'), + path.join(rig.root, 'lnk', 'commands'), + path.join(other, 'sub', '.claude'), + path.join(other, '.git', 'back'), + ]; + for (const bind of refused) { + const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: bind }); + assert.equal(status, 125, `${bind} must be refused`); + assert.match(stderr, /refusing to bind .* \.claude or \.git/, bind); + } + const allowed = [ + other, + other + '/', + path.join(other, 'sub'), + path.join(other, '.claude', '..'), + path.join(other, '.claude-notes'), + path.join(other, 'x.git'), + ]; + for (const bind of allowed) { + const { stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: bind }); + assert.doesNotMatch(stderr, /\.claude or \.git/, `${bind} must not be refused`); + } + } finally { + rig.cleanup(); + } +}); + +test('sandbox wrapper: resolves the real binary when "claude" is also a shell function',{ skip: !LINUX && 'linux only' }, () => { const rig = makeRig({ bwrapExit: 1 }); try { fs.mkdirSync(path.join(rig.home, '.claude')); diff --git a/test/schedule-project-provenance.test.js b/test/schedule-project-provenance.test.js index 6be881b6..81b80124 100644 --- a/test/schedule-project-provenance.test.js +++ b/test/schedule-project-provenance.test.js @@ -126,3 +126,64 @@ test('schedules: sandboxed add-dirs under $HOME must be a registered project or assert.deepEqual(refusedScheduleBinds(['/home/u/work/app/../../.ssh'], known, home), ['/home/u/work/app/../../.ssh'], 'the path is judged normalised'); }); + +test('schedules: an add-dir at or under a .claude or .git is refused, even inside a registered project', () => { + reset(); + const home = ROOT; + const app = path.join(ROOT, 'work', 'app'); + fs.mkdirSync(path.join(app, '.claude', 'commands'), { recursive: true }); + fs.mkdirSync(path.join(app, '.git', 'hooks'), { recursive: true }); + fs.mkdirSync(path.join(app, 'sub'), { recursive: true }); + const known = [app]; + const refused = [ + path.join(app, '.claude'), + path.join(app, '.claude') + path.sep, + path.join(app, '.claude', 'commands'), + path.join(app, '.claude', 'missing'), + path.join(app, '.git'), + path.join(app, '.git', 'hooks'), + path.join(app, 'sub', '..', '.claude'), + path.join(app, 'sub', '.claude'), + ]; + assert.deepEqual(refusedScheduleBinds(refused, known, home), refused); + const allowed = [app, path.join(app, 'sub'), path.join(app, '.claude', '..'), path.join(app, '.claude-notes'), path.join(app, 'x.git')]; + assert.deepEqual(refusedScheduleBinds(allowed, known, home), []); + assert.deepEqual(refusedScheduleBinds([path.join(path.dirname(ROOT), 'elsewhere', '.claude')], known, home), + [path.join(path.dirname(ROOT), 'elsewhere', '.claude')], 'outside $HOME too'); +}); + +test('schedules: an add-dir that is a symbolic link to a .claude, or out of a project into $HOME, is judged by its target', (t) => { + reset(); + const home = ROOT; + const app = path.join(ROOT, 'work', 'app'); + fs.mkdirSync(path.join(app, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(ROOT, '.ssh'), { recursive: true }); + const toClaude = path.join(app, 'link-claude'); + const toSsh = path.join(app, 'link-ssh'); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-link-')); + t.after(() => fs.rmSync(outside, { recursive: true, force: true })); + const viaOutside = path.join(outside, 'to-ssh'); + try { + fs.symlinkSync(path.join(app, '.claude'), toClaude, 'junction'); + fs.symlinkSync(path.join(ROOT, '.ssh'), toSsh, 'junction'); + fs.symlinkSync(path.join(ROOT, '.ssh'), viaOutside, 'junction'); + } catch (err) { + t.skip(`cannot create links here: ${err.code}`); + return; + } + const known = [app]; + assert.deepEqual(refusedScheduleBinds([toClaude, toSsh], known, home), [toClaude, toSsh]); + assert.deepEqual(refusedScheduleBinds([viaOutside], known, home), [viaOutside], 'a link from outside $HOME into it'); + const movedClaude = path.join(ROOT, 'work', 'app2'); + fs.mkdirSync(movedClaude, { recursive: true }); + fs.symlinkSync(outside, path.join(movedClaude, '.claude'), 'junction'); + const asSpelled = path.join(movedClaude, '.claude'); + assert.deepEqual(refusedScheduleBinds([asSpelled], known, home), [asSpelled], 'a .claude that links elsewhere is refused as spelled'); +}); + +test('schedules: a relative cwd is judged from its resolved path', () => { + const proj = path.resolve('rel-proj-385'); + const get = (key) => (key === 'project:' + proj ? { sandbox: true } : undefined); + assert.equal(resolveScheduleSandbox('rel-proj-385', get, false), true); + assert.equal(resolveScheduleSandbox(path.join('rel-proj-385', 'sub', '..', 'sub'), get, false), true); +}); From 8b9b3f6fabc6d97d5c3530d9bd0ffbbc4a95c6b4 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 23:28:12 +0200 Subject: [PATCH 2/2] (sandbox): hold the cwd, $HOME and relative add-dirs to the same checks The working directory was exempt from the .claude/.git refusal, so a session in /.claude with bound got a read-write bind over the read-only one. It is now refused except below .claude/worktrees. The $HOME/parent check compared text, so $HOME/ or a link to $HOME passed; it now compares the normalised and real forms, and a path that cannot be resolved is refused. A schedule's relative add-dirs are resolved against the schedule's directory, and the skip message names the reason. Refs #385 --- .ai/contexts/schedule-runner.md | 2 +- CHANGELOG.md | 2 +- docs/sandbox.md | 14 +++-- main.js | 6 +- schedule-runner.js | 29 ++++++---- scripts/claude-sandbox.sh | 67 ++++++++++++++++----- test/sandbox-wrapper.test.js | 74 ++++++++++++++++++++++++ test/schedule-project-provenance.test.js | 17 +++++- 8 files changed, 177 insertions(+), 34 deletions(-) diff --git a/.ai/contexts/schedule-runner.md b/.ai/contexts/schedule-runner.md index fc6e8051..3d38ccb7 100644 --- a/.ai/contexts/schedule-runner.md +++ b/.ai/contexts/schedule-runner.md @@ -19,7 +19,7 @@ From `schedule-runner.js`: - `scheduleRegistry(getSetting, setSetting)` — the `scheduleProjects` setting: `list()`, `add(path)`, `remove(path)`. `main.js` adds a project when it spawns a Claude session in it and on `add-project`, removes it on `remove-project`. Transcripts never add one: their `cwd`, and a folder name derived from it, are written by whoever ran claude, a sandboxed session included (see [docs/sandbox.md](../../docs/sandbox.md), "Schedules"). - `initialScheduleProjects()` — the seed, read once while the setting has never been written: `~/.claude/projects` folders whose recorded path encodes back to the folder's name and holds a schedule. - `resolveScheduleSandbox(cwd, getSetting, default)` — the `project:` setting of `cwd` or of the nearest directory above it that has one, then `global`, then the default. -- `refusedScheduleBinds(addDirs, projects, home)` — the `add-dirs` that are at or inside a `.claude` or `.git` (judged as spelled and by real path, anywhere), or under `home` and neither a registered project nor inside one (real paths). `main.js` skips a sandboxed run when it is not empty. +- `refusedScheduleBinds(addDirs, projects, home, baseDir)` / `scheduleBindRefusals` (with the reason, used by `main.js`) — the `add-dirs` that are at or inside a `.claude` or `.git` (judged as spelled and by real path, anywhere), or under `home` and neither a registered project nor inside one (real paths). `main.js` skips a sandboxed run when it is not empty. - `createScheduleSession(schedule, dueMs)` — write a pre-seeded JSONL into `~/.claude/projects//.jsonl` with the schedule's prompt as the first user message, prefixed `Scheduled Task (catch-up: due …, started …): ` when `dueMs` is set. Returns the session UUID. - `buildScheduleCommand(sessionId, schedule)` — assemble the shell command (`claude --resume "" -p "..." --permission-mode acceptEdits --allowedTools "..."`). - `parseFrontmatter(content)`, `cronMatches(cronExpr, now)` — utilities, exported for tests. diff --git a/CHANGELOG.md b/CHANGELOG.md index f7019f55..90ec42f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed -- A sandboxed session, or a sandboxed schedule, whose Additional Directories include a `.claude` or `.git` directory, or a path inside one, is now refused instead of binding it read-write over its read-only protection; add the project directory instead. (#385) +- A sandboxed session, or a sandboxed schedule, whose Additional Directories include a `.claude` or `.git` directory, or a path inside one, is now refused instead of binding it read-write over its read-only protection; add the project directory instead. A session started in a `.claude` or `.git` directory is refused too, except below `.claude/worktrees`, and Additional Directories naming your home directory or a parent of it are refused however the path is written. A relative `add-dirs` entry in a schedule is taken from the schedule's directory. (#385) ## v0.0.86 — 2026-10-01 diff --git a/docs/sandbox.md b/docs/sandbox.md index a531a3ac..96270120 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -225,8 +225,9 @@ in a directory the sandbox can write — `core.hooksPath=linked/hooks` with instead, so the launch is refused; point `core.hooksPath` at a hooks directory instead of linking `.git/hooks` to it. -The paths are resolved when the wrapper builds the sandbox and mounted when -bwrap starts it; a second sandboxed session on the same project that swaps a +The paths, and the Additional Directories (below), are resolved when the +wrapper builds the sandbox and mounted when bwrap starts it; a second sandboxed +session on the same project that swaps a directory in between is not detected. ### Additional directories @@ -238,8 +239,13 @@ an entry is skipped with the reason in the main log. The path is judged both as spelled (`..` and a trailing slash resolved) and by its real path, so a link to a `.claude`, or a `.claude` that is itself a link, does not get through. Add the project directory instead: its `.claude` and `.git` are then protected as -above. The session's own working directory is not checked this way, because a -session in a worktree under `.claude/worktrees` is legitimate. +above. The session's working directory is held to the same rule, with one exception: +a directory below `.claude/worktrees` is allowed, because Claude Code's +worktrees live there (a `.claude` or `.git` further down is still refused). A +path the wrapper cannot resolve is refused. The same check refuses `$HOME` and +its parents however they are spelled (`$HOME/`, `$HOME/.`, a link to it). +A relative `add-dirs` entry of a schedule is taken from the schedule's +directory. ### Schedules diff --git a/main.js b/main.js index 795ca5e8..7beaa179 100644 --- a/main.js +++ b/main.js @@ -67,7 +67,7 @@ function spawnPty(file, args, opts) { // Shell profiles → shell-profiles.js const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles'); -const { startScheduler, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner'); +const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner'); const { encodeProjectPath } = require('./encode-project-path'); const { SETTING_DEFAULTS } = require('./public/setting-defaults'); const { scanMdFiles, acceptMdFile } = require('./scan-md-files'); @@ -3047,9 +3047,9 @@ if (!gotSingleInstanceLock) { if (claudeArgv[i] === '--add-dir') addDirs.push(claudeArgv[i + 1]); } // see docs/sandbox.md ("Schedules") - const refused = refusedScheduleBinds(addDirs, scheduleProjects().list(), os.homedir()); + const refused = scheduleBindRefusals(addDirs, scheduleProjects().list(), os.homedir(), cwd); if (refused.length) { - log.error(`[schedule] ${name}: skipped — add-dirs under the home directory that are not Switchboard projects: ${refused.join(', ')}`); + log.error(`[schedule] ${name}: skipped — add-dirs refused: ${refused.map(r => `${r.dir} (${r.reason})`).join(', ')}`); if (onDone) onDone(); return; } diff --git a/schedule-runner.js b/schedule-runner.js index d74f975c..84ab0e64 100644 --- a/schedule-runner.js +++ b/schedule-runner.js @@ -258,22 +258,31 @@ function canonicalPath(p) { /** * The add-dirs of a sandboxed schedule that the wrapper must not bind: any at * or inside a `.claude` or `.git` directory, and any under $HOME that is - * neither a known project nor inside one. Each is judged both as spelled + * neither a known project nor inside one. A relative one is taken from the + * schedule's directory, `baseDir`. Each is judged both as spelled * (normalised) and by its real path. * see docs/sandbox.md ("Additional directories") */ -function refusedScheduleBinds(addDirs, knownProjects, home) { +function scheduleBindRefusals(addDirs, knownProjects, home, baseDir = process.cwd()) { const inside = (p, dir) => p === dir || p.startsWith(dir + path.sep); const homeDir = canonicalPath(home); const projects = knownProjects.map(canonicalPath); const protectedName = (p) => p.split(path.sep).some(c => c === '.claude' || c === '.git'); - return addDirs.filter((dir) => { - const lexical = path.resolve(dir); - const real = canonicalPath(dir); - if (protectedName(lexical) || protectedName(real)) return true; - if (!inside(real, homeDir)) return false; - return !projects.some(project => inside(real, project)); - }); + const refusals = []; + for (const dir of addDirs) { + const lexical = path.resolve(baseDir, dir); + const real = canonicalPath(lexical); + if (protectedName(lexical) || protectedName(real)) { + refusals.push({ dir, reason: 'at or inside a .claude or .git directory' }); + } else if (inside(real, homeDir) && !projects.some(project => inside(real, project))) { + refusals.push({ dir, reason: 'under the home directory and not a Switchboard project' }); + } + } + return refusals; +} + +function refusedScheduleBinds(addDirs, knownProjects, home, baseDir) { + return scheduleBindRefusals(addDirs, knownProjects, home, baseDir).map(r => r.dir); } /** @@ -528,4 +537,4 @@ function startScheduler(log, runCommand, { resumeSource, stateDir, projects } = }; } -module.exports = { parseFrontmatter, cronMatches, scanSchedules, startScheduler, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry }; +module.exports = { parseFrontmatter, cronMatches, scanSchedules, startScheduler, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry }; diff --git a/scripts/claude-sandbox.sh b/scripts/claude-sandbox.sh index 265b026e..a0848ee0 100755 --- a/scripts/claude-sandbox.sh +++ b/scripts/claude-sandbox.sh @@ -126,30 +126,69 @@ fi # session was launched with the wrong working directory. Fail closed and say so: # a sandbox that silently hands out the whole home directory is worse than none, # because the user believes they are protected. -for d in ${RW_DIRS[@]+"${RW_DIRS[@]}"}; do - _bad="" - case "$d" in - /) _bad="the filesystem root" ;; +# The forms of a path the checks below compare: as spelled but normalised +# (.. and trailing slashes resolved), and with every link followed. An empty +# answer from either is a refusal, never a pass. +path_forms() { + local lexical real + lexical="$(realpath -m -s -- "$1" 2>/dev/null)" && [ -n "$lexical" ] && + real="$(readlink -m -- "$1" 2>/dev/null)" && [ -n "$real" ] || return 1 + printf '%s\n%s\n' "$lexical" "$real" +} + +has_protected_component() { + case "/$1/" in + */.claude/*|*/.git/*) return 0 ;; esac - case "$HOME" in - "$d") _bad="\$HOME itself" ;; - "$d"/*) _bad="a parent of \$HOME" ;; + return 1 +} + +# A cwd inside a .claude or .git is legitimate only below .claude/worktrees: the +# part after that, and the part before it, must not hold another one. +cwd_in_protected_dir() { + local form="$1" + case "$form/" in + */.claude/worktrees/*) + has_protected_component "${form%%/.claude/worktrees/*}" && return 0 + has_protected_component "${form#*/.claude/worktrees/}" && return 0 + return 1 ;; esac + has_protected_component "$form" +} + +_home_forms="$(path_forms "$HOME")" || fail "refusing to launch: cannot resolve \$HOME ($HOME)." +for d in ${RW_DIRS[@]+"${RW_DIRS[@]}"}; do + _d_forms="$(path_forms "$d")" || fail "refusing to bind '$d' — its real path cannot be resolved." + _bad="" + while IFS= read -r _df; do + [ "$_df" = / ] && _bad="the filesystem root" + while IFS= read -r _hf; do + case "$_hf/" in + "$_df/") _bad="\$HOME itself" ;; + "$_df"/*) [ -n "$_bad" ] || _bad="a parent of \$HOME" ;; + esac + done <<<"$_home_forms" + done <<<"$_d_forms" if [ -n "$_bad" ]; then fail "refusing to bind '$d' — it is $_bad, so the sandbox would expose everything it is meant to hide. Expected a project directory; the session's working directory is '$PWD'." fi done # see docs/sandbox.md ("Additional directories") +_pwd_forms="$(path_forms "$PWD")" || fail "refusing to launch: cannot resolve the working directory '$PWD'." +while IFS= read -r _form; do + if cwd_in_protected_dir "$_form"; then + fail "refusing to launch in '$PWD' — it is at or inside a .claude or .git directory, which the sandbox keeps read-only. Only a worktree below .claude/worktrees is allowed." + fi +done <<<"$_pwd_forms" for d in ${EXTRA_BINDS[@]+"${EXTRA_BINDS[@]}"}; do [ -n "$d" ] || continue - for _form in "$(realpath -m -s -- "$d")" "$(readlink -m -- "$d")"; do - case "/$_form/" in - */.claude/*|*/.git/*) - fail "refusing to bind '$d' — it is at or inside a .claude or .git directory, which the sandbox keeps read-only. Bind the project directory instead." - ;; - esac - done + _d_forms="$(path_forms "$d")" || fail "refusing to bind '$d' — its real path cannot be resolved." + while IFS= read -r _form; do + if has_protected_component "$_form"; then + fail "refusing to bind '$d' — it is at or inside a .claude or .git directory, which the sandbox keeps read-only. Bind the project directory instead." + fi + done <<<"$_d_forms" done # True when $1 is at or below one of the read-write state dirs. diff --git a/test/sandbox-wrapper.test.js b/test/sandbox-wrapper.test.js index b490e4c3..46b794e0 100644 --- a/test/sandbox-wrapper.test.js +++ b/test/sandbox-wrapper.test.js @@ -258,6 +258,80 @@ test('sandbox wrapper: refuses an extra bind at or under a .claude or .git, howe } }); +test('sandbox wrapper: refuses a working directory at or inside a .claude or .git, except below .claude/worktrees', { skip: !LINUX && 'linux only' }, () => { + const rig = makeRig({ bwrapExit: 1 }); + try { + fs.mkdirSync(path.join(rig.home, '.claude')); + const plain = path.join(rig.root, 'plain'); + fs.mkdirSync(path.join(plain, '.claude', 'worktrees', 'w', '.claude'), { recursive: true }); + fs.mkdirSync(path.join(plain, '.git', 'hooks'), { recursive: true }); + fs.symlinkSync(path.join(plain, '.claude'), path.join(rig.root, 'lnk')); + const refused = [ + path.join(plain, '.claude'), + path.join(plain, '.claude', 'commands'), + path.join(plain, '.git'), + path.join(plain, '.git', 'hooks'), + path.join(rig.root, 'lnk'), + path.join(plain, '.claude', 'worktrees', 'w', '.claude'), + path.join(plain, '.claude', 'worktrees', '..', 'commands'), + ]; + for (const cwd of refused) { + fs.mkdirSync(cwd, { recursive: true }); + const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: plain }, cwd); + assert.equal(status, 125, `a session in ${cwd} must be refused`); + assert.match(stderr, /refusing to launch in .* \.claude or \.git/, cwd); + } + for (const cwd of [plain, path.join(plain, '.claude', 'worktrees', 'w')]) { + const { stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: plain }, cwd); + assert.doesNotMatch(stderr, /refusing to launch in/, `${cwd} must not be refused`); + } + } finally { + rig.cleanup(); + } +}); + +test('sandbox wrapper: refuses $HOME or a parent however the path is spelled, links included', { skip: !LINUX && 'linux only' }, () => { + const rig = makeRig({ bwrapExit: 1 }); + try { + fs.mkdirSync(path.join(rig.home, '.claude')); + fs.mkdirSync(path.join(rig.home, 'sub')); + fs.symlinkSync(rig.home, path.join(rig.root, 'homelink')); + fs.symlinkSync(rig.root, path.join(rig.proj, 'rootlink')); + const refused = [ + rig.home + '/', + rig.home + '/.', + path.join(rig.home, 'sub', '..'), + path.join(rig.root, 'homelink'), + rig.root + '/', + path.join(rig.proj, 'rootlink'), + path.join(rig.home, '..') + '/', + ]; + for (const bind of refused) { + const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: bind }); + assert.equal(status, 125, `${bind} must be refused`); + assert.match(stderr, /refusing to bind .* (\$HOME itself|a parent of \$HOME)/, bind); + } + const { stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: path.join(rig.home, 'sub') }); + assert.doesNotMatch(stderr, /refusing to bind/, 'a directory below $HOME is not $HOME'); + } finally { + rig.cleanup(); + } +}); + +test('sandbox wrapper: a path whose forms cannot be resolved is refused, not passed', { skip: !LINUX && 'linux only' }, () => { + const rig = makeRig({ bwrapExit: 1 }); + try { + fs.mkdirSync(path.join(rig.home, '.claude')); + fs.writeFileSync(path.join(rig.root, 'bin', 'realpath'), '#!/usr/bin/env bash\nexit 0\n'); + fs.chmodSync(path.join(rig.root, 'bin', 'realpath'), 0o755); + const { status, stderr } = rig.run(['--version']); + assert.equal(status, 125); + assert.match(stderr, /cannot be resolved|cannot resolve/); + } finally { + rig.cleanup(); + } +}); + test('sandbox wrapper: resolves the real binary when "claude" is also a shell function',{ skip: !LINUX && 'linux only' }, () => { const rig = makeRig({ bwrapExit: 1 }); try { diff --git a/test/schedule-project-provenance.test.js b/test/schedule-project-provenance.test.js index 81b80124..4f2f2546 100644 --- a/test/schedule-project-provenance.test.js +++ b/test/schedule-project-provenance.test.js @@ -16,7 +16,7 @@ process.env.USERPROFILE = ROOT; delete process.env.SWITCHBOARD_DATA_DIR; const { - scanSchedules, initialScheduleProjects, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry, + scanSchedules, initialScheduleProjects, refusedScheduleBinds, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry, } = require('../schedule-runner'); const { encodeProjectPath } = require('../encode-project-path'); @@ -187,3 +187,18 @@ test('schedules: a relative cwd is judged from its resolved path', () => { assert.equal(resolveScheduleSandbox('rel-proj-385', get, false), true); assert.equal(resolveScheduleSandbox(path.join('rel-proj-385', 'sub', '..', 'sub'), get, false), true); }); + +test('schedules: a relative add-dir is taken from the schedule\'s directory, and the refusal says why', () => { + const home = path.resolve('/home/u'); + const app = path.resolve('/home/u/work/app'); + const known = [app]; + assert.deepEqual(refusedScheduleBinds(['sub', './docs'], known, home, app), []); + assert.deepEqual(refusedScheduleBinds(['../../.ssh', '../lib'], known, home, app), ['../../.ssh', '../lib']); + assert.deepEqual(refusedScheduleBinds(['../app/sub'], known, home, path.resolve('/home/u/work/other')), []); + assert.deepEqual( + scheduleBindRefusals(['.claude', '../../.ssh', 'sub'], known, home, app), + [ + { dir: '.claude', reason: 'at or inside a .claude or .git directory' }, + { dir: '../../.ssh', reason: 'under the home directory and not a Switchboard project' }, + ]); +});