diff --git a/CHANGELOG.md b/CHANGELOG.md index 8485b6c99..bf4194cd5 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 +- In a sandboxed session, a repository or a `.claude` folder below an Additional Directory that is a symbolic link now has its Git config, hooks and Claude settings read-only, like one below a plain directory. Before, they were writable from inside the sandbox. Such a directory holding a `.claude` that is itself a symbolic link, or a repository Git cannot read, now refuses to start the session (status 125), as a plain directory already did, instead of starting it unprotected. (#538) + ## v0.0.92 — 2026-10-10 ### Fixed diff --git a/scripts/claude-sandbox.sh b/scripts/claude-sandbox.sh index a0848ee06..e44600222 100755 --- a/scripts/claude-sandbox.sh +++ b/scripts/claude-sandbox.sh @@ -612,7 +612,7 @@ bind_project_dir() { .claude) protect_claude_dir "$n" ;; .git) protect_repo_root "$(dirname "$n")" ;; esac - done < <(find "$d" -xdev -mindepth 2 \( -name node_modules -prune \) -o \ + done < <(find "$d/" -xdev -mindepth 2 \( -name node_modules -prune \) -o \ \( -name .git -print0 -prune \) -o \( -name .claude -type d -print0 \) -o \ \( -name .claude -type l -print0 \) 2>/dev/null) } diff --git a/test/sandbox-wrapper.test.js b/test/sandbox-wrapper.test.js index 46b794e0c..c9532375b 100644 --- a/test/sandbox-wrapper.test.js +++ b/test/sandbox-wrapper.test.js @@ -1072,6 +1072,29 @@ test('sandbox wrapper: every .claude and repository below a bound directory is p } }); +test('sandbox wrapper: a repository and a .claude below an Additional Directory that is a link are protected', + { skip: !LINUX && 'linux only' }, () => { + const rig = makeRig({ recordArgs: true }); + try { + fs.mkdirSync(path.join(rig.home, '.claude')); + const other = path.join(rig.root, 'other'); + const sub = path.join(other, 'sub'); + fs.mkdirSync(path.join(sub, '.claude'), { recursive: true }); + git(sub, 'init', '-q'); + const link = path.join(rig.root, 'link'); + fs.symlinkSync(other, link); + const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: link }); + assert.equal(status, 0, stderr); + const ops = parseMounts(rig.lastBwrapArgs()); + assert.equal(accessAt(ops, path.join(link, 'sub', 'file')), '--bind'); + for (const p of [path.join(link, 'sub', '.git', 'config'), path.join(link, 'sub', '.claude', 'settings.json')]) { + assert.equal(accessAt(ops, p), '--ro-bind', `${p} must stay read-only`); + } + } finally { + rig.cleanup(); + } + }); + test('sandbox wrapper: refuses a repository git cannot read, rather than guessing which paths to protect', { skip: !LINUX && 'linux only' }, () => { const rig = makeRig({ recordArgs: true }); @@ -1252,8 +1275,21 @@ test('sandbox wrapper: from inside a real sandbox, every persistence write is re fs.mkdirSync(path.join(C, 'shell-snapshots')); fs.writeFileSync(path.join(C, 'shell-snapshots', 'snapshot-bash-live.sh'), 'export A=1\n'); fs.mkdirSync(path.join(C, 'session-env')); + const other = path.join(rig.root, 'other'); + const sub = path.join(other, 'sub'); + fs.mkdirSync(path.join(sub, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(sub, '.claude', 'settings.json'), '{}\n'); + git(sub, 'init', '-q'); + const linked = path.join(rig.root, 'linked'); + fs.symlinkSync(other, linked); + const linkedAttempts = String.raw` +L='${linked}/sub' +attempt linked-dir-git-config 'git -C "$L" config core.fsmonitor evil' +attempt linked-dir-settings 'echo evil > "$L/.claude/settings.json"' +attempt linked-dir-file 'echo ok > "$L/file"' +`; - const res = rig.run([PERSISTENCE_ATTEMPTS]); + const res = rig.run([PERSISTENCE_ATTEMPTS + linkedAttempts], { SWITCHBOARD_SANDBOX_BINDS: linked }); assert.equal(res.status, 0, res.stderr); const said = res.stdout; const read = (p) => fs.readFileSync(p, 'utf8'); @@ -1301,6 +1337,11 @@ test('sandbox wrapper: from inside a real sandbox, every persistence write is re assert.equal(read(path.join(rig.proj, 'src.js')), 'export {}\n', `a source file must be written\n${said}`); assert.equal(git(rig.proj, 'log', '--format=%s', '-1'), 'src', `a commit must land\n${said}`); assert.match(said, /resolv-conf: written/, 'the resolver configuration must be readable'); + assert.equal(spawnSync('git', ['config', '--get', 'core.fsmonitor'], { cwd: sub, env: cleanEnv() }).status, 1, + `a repository below a linked Additional Directory must keep its config\n${said}`); + assert.equal(read(path.join(sub, '.claude', 'settings.json')), '{}\n', + `a .claude below a linked Additional Directory must be read-only\n${said}`); + assert.equal(read(path.join(sub, 'file')), 'ok\n', `a file below a linked Additional Directory must be written\n${said}`); } finally { rig.cleanup(); }