From 81cf86bf35cefe1e61daa13fb79be152e0c9c1e5 Mon Sep 17 00:00:00 2001 From: abate Date: Sat, 10 Oct 2026 11:00:03 +0200 Subject: [PATCH 1/3] test(sandbox): a repository below a symlinked Additional Directory is protected find does not descend into a starting point that is a symbolic link, so a repository or a .claude below an Additional Directory given as a link is never found, and its .git/config, hooks and settings stay writable. --- test/sandbox-wrapper.test.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/sandbox-wrapper.test.js b/test/sandbox-wrapper.test.js index 46b794e0c..d67ebc8d2 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 }); From 4fc768f2f8313e891e0822db6a95c8a73d71ad80 Mon Sep 17 00:00:00 2001 From: abate Date: Sat, 10 Oct 2026 11:00:40 +0200 Subject: [PATCH 2/3] fix(sandbox): enter an Additional Directory that is a symbolic link when protecting below it find does not descend into a starting point that is a link; a trailing slash makes it enter the directory the link points to. --- CHANGELOG.md | 3 +++ scripts/claude-sandbox.sh | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8485b6c99..75367011b 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. (#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) } From 5680feef4921845eb42d133dc734fa114fa27b94 Mon Sep 17 00:00:00 2001 From: abate Date: Sat, 10 Oct 2026 18:33:06 +0200 Subject: [PATCH 3/3] test(sandbox): write below a symlinked Additional Directory from inside a real sandbox Also name in the changelog the refusal a linked Additional Directory now shares with a plain one. --- CHANGELOG.md | 2 +- test/sandbox-wrapper.test.js | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75367011b..bf4194cd5 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 -- 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. (#538) +- 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 diff --git a/test/sandbox-wrapper.test.js b/test/sandbox-wrapper.test.js index d67ebc8d2..c9532375b 100644 --- a/test/sandbox-wrapper.test.js +++ b/test/sandbox-wrapper.test.js @@ -1275,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'); @@ -1324,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(); }