From cdc61d090ce0b2080349fef2ea63a445e3de3cf8 Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 15:02:09 +0200 Subject: [PATCH 1/2] test(sandbox): opted-in skills and agents overlapping a private entry, a linked dir, or a repository's .claude Tests for the last review round of #483, which #548 did not merge: a repository inside an opted-in directory keeps a read-only .claude, a read-only directory bound under another spelling is refused, a private ~/.claude entry or ~/.claude.json linked into an opted-in directory is refused, and the real-sandbox test covers a project .claude/skills opt-in. --- test/sandbox-wrapper.test.js | 141 ++++++++++++++++++++++++++++++----- 1 file changed, 124 insertions(+), 17 deletions(-) diff --git a/test/sandbox-wrapper.test.js b/test/sandbox-wrapper.test.js index 4bf57881f..edfa2afc1 100644 --- a/test/sandbox-wrapper.test.js +++ b/test/sandbox-wrapper.test.js @@ -541,7 +541,7 @@ test('sandbox wrapper: an opted-in skills linked to a repository elsewhere keeps } }); -test('sandbox wrapper: a repository inside an opted-in ~/.claude/agents keeps its git config and hooks read-only, and no .claude is created there', +test('sandbox wrapper: a repository inside an opted-in ~/.claude/agents keeps its git config and hooks read-only, and gets a read-only .claude', { skip: !LINUX && 'linux only' }, () => { const rig = makeRig({ recordArgs: true }); try { @@ -557,8 +557,10 @@ test('sandbox wrapper: a repository inside an opted-in ~/.claude/agents keeps it for (const p of [path.join(vendored, '.git', 'config'), path.join(vendored, '.git', 'hooks')]) { assert.equal(accessAt(ops, p), '--ro-bind', `${p} must stay read-only`); } - assert.equal(fs.existsSync(path.join(vendored, '.claude')), false, 'no .claude is planted in a skill or agent repository'); - assert.equal(fs.existsSync(path.join(dir, 'agents', '.claude')), false); + assert.equal(fs.statSync(path.join(vendored, '.claude')).isDirectory(), true, + 'a repository root gets a .claude, so the session cannot create one with settings'); + assert.equal(accessAt(ops, path.join(vendored, '.claude', 'settings.json')), '--ro-bind'); + assert.equal(fs.existsSync(path.join(dir, 'agents', '.claude')), false, 'a folder that is not a repository gets none'); } finally { rig.cleanup(); } @@ -584,6 +586,53 @@ test('sandbox wrapper: an opted-in skills in a project\'s .claude that links out } }); +test('sandbox wrapper: refuses an opted-in skills or agents that overlaps the target of a private ~/.claude entry or of ~/.claude.json', + { skip: !LINUX && 'linux only' }, () => { + const rig = makeRig({ recordArgs: true }); + try { + const dir = path.join(rig.home, '.claude'); + const flags = { SWITCHBOARD_SANDBOX_RW_SKILLS: '1' }; + fs.mkdirSync(path.join(dir, 'skills', 'snaps'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'skills', 'snaps', 'snap.sh'), 'export A=1\n'); + fs.symlinkSync(path.join(dir, 'skills', 'snaps'), path.join(dir, 'shell-snapshots')); + let res = rig.run(['--version'], flags); + assert.equal(res.status, 125, 'a private entry linked into skills must be refused'); + assert.match(res.stderr, /shell-snapshots links to .*skills\/snaps, which overlaps .*skills/); + assert.equal(rig.run(['--version'], { ...flags, SWITCHBOARD_SANDBOX_RW_SKILLS: '' }).status, 0, 'flag off: launches'); + + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir); + const dot = path.join(rig.home, 'dot'); + fs.mkdirSync(path.join(dot, 'skills', 'se'), { recursive: true }); + fs.writeFileSync(path.join(dot, 'skills', 'claude.json'), '{}\n'); + fs.symlinkSync('../dot/skills', path.join(dir, 'skills')); + fs.symlinkSync('../dot/skills/se', path.join(dir, 'session-env')); + res = rig.run(['--version'], flags); + assert.equal(res.status, 125, 'a relative private link into the linked skills must be refused'); + assert.match(res.stderr, /session-env links to/); + + fs.rmSync(path.join(dir, 'session-env')); + fs.symlinkSync('dot/skills/claude.json', path.join(rig.home, '.claude.json')); + res = rig.run(['--version'], flags); + assert.equal(res.status, 125, 'a ~/.claude.json linked into skills must be refused'); + assert.match(res.stderr, /\.claude\.json links to/); + + fs.rmSync(path.join(rig.home, '.claude.json')); + fs.symlinkSync(rig.home + '/dot', path.join(dir, 'state')); + res = rig.run(['--version'], flags); + assert.equal(res.status, 125, 'a private entry whose target contains skills must be refused'); + assert.match(res.stderr, /state links to/); + + fs.rmSync(path.join(dir, 'state')); + fs.mkdirSync(path.join(dot, 'state')); + fs.symlinkSync(path.join(dot, 'state'), path.join(dir, 'state')); + res = rig.run(['--version'], flags); + assert.equal(res.status, 0, `a private link beside skills is fine\n${res.stderr}`); + } finally { + rig.cleanup(); + } + }); + test('sandbox wrapper: refuses an opted-in skills or agents that is, or is inside, a path another entry keeps read-only', { skip: !LINUX && 'linux only' }, () => { const rig = makeRig({ recordArgs: true }); @@ -731,24 +780,21 @@ test('sandbox wrapper: a repository the sandbox sees at two paths keeps its git } }); -test('sandbox wrapper: a repository and a .claude below an Additional Directory that is a link are protected', +test('sandbox wrapper: refuses an opted-in skills that contains a read-only directory bound under another spelling', { skip: !LINUX && 'linux only' }, () => { const rig = makeRig({ recordArgs: true }); try { + const target = path.join(rig.root, 'x', 'skills'); + fs.mkdirSync(path.join(target, 'nvm'), { recursive: true }); 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`); - } + fs.symlinkSync(target, path.join(rig.home, '.claude', 'skills')); + const alias = path.join(rig.root, 'alias'); + fs.symlinkSync(path.join(rig.root, 'x'), alias); + const env = { SWITCHBOARD_SANDBOX_RW_SKILLS: '1', NVM_DIR: path.join(alias, 'skills', 'nvm') }; + const { status, stderr } = rig.run(['--version'], env); + assert.equal(status, 125, 'must fail closed'); + assert.match(stderr, /nvm, which the sandbox keeps read-only, lies inside .*skills/); + assert.equal(rig.run(['--version'], { ...env, SWITCHBOARD_SANDBOX_RW_SKILLS: '' }).status, 0, 'flag off: launches'); } finally { rig.cleanup(); } @@ -1698,3 +1744,64 @@ attempt linked-dir-file 'echo ok > "$L/file"' rig.cleanup(); } }); + +const OPT_IN_ATTEMPTS = String.raw` +attempt() { local name="$1"; shift; if ( eval "$*" ) 2>/dev/null; then echo "$name: written"; else echo "$name: refused"; fi; } +C="$HOME/.claude" +attempt skill-write 'mkdir -p "$C/skills/new" && echo ok > "$C/skills/new/SKILL.md"' +attempt skill-git-hook 'echo evil > "$C/skills/.git/hooks/pre-commit"' +attempt skill-git-config 'echo "[core] hooksPath = evil" >> "$C/skills/.git/config"' +attempt skill-claude-settings 'echo evil > "$C/skills/.claude/settings.json"' +attempt skill-repo-new-claude 'mkdir -p "$C/skills/vendored/.claude" && echo evil > "$C/skills/vendored/.claude/settings.json"' +attempt user-hook 'echo evil > "$C/hooks/evil.sh"' +attempt user-command 'echo evil > "$C/commands/evil.md"' +attempt user-agent 'echo evil > "$C/agents/evil.md"' +attempt project-skill-write 'mkdir -p .claude/skills/p && echo ok > .claude/skills/p/SKILL.md' +attempt project-settings 'echo evil > .claude/settings.json' +`; + +test('sandbox wrapper: from inside a real sandbox, an opted-in skills linked to a repository is writable, and its git and .claude are not', + { skip: !realBwrapWorks && 'requires a usable unprivileged user namespace' }, () => { + const rig = makeRig({ claudeRunsArg: true }); + try { + const C = path.join(rig.home, '.claude'); + for (const d of ['hooks', 'commands', 'agents']) fs.mkdirSync(path.join(C, d), { recursive: true }); + const repo = path.join(rig.root, 'src', 'skills'); + fs.mkdirSync(path.join(repo, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.claude', 'settings.json'), '{}\n'); + git(repo, 'init', '-q'); + fs.writeFileSync(path.join(repo, '.git', 'hooks', 'pre-commit'), '#!/bin/sh\n'); + const config = read0(path.join(repo, '.git', 'config')); + const vendored = path.join(repo, 'vendored'); + fs.mkdirSync(vendored); + git(vendored, 'init', '-q'); + fs.symlinkSync(repo, path.join(C, 'skills')); + fs.mkdirSync(path.join(rig.proj, '.claude', 'skills'), { recursive: true }); + fs.writeFileSync(path.join(rig.proj, '.claude', 'settings.json'), '{}\n'); + + const res = rig.run([OPT_IN_ATTEMPTS], { SWITCHBOARD_SANDBOX_RW_SKILLS: '1' }); + assert.equal(res.status, 0, res.stderr); + const said = res.stdout; + const gone = (p) => !fs.existsSync(p); + + assert.equal(read0(path.join(repo, 'new', 'SKILL.md')), 'ok\n', `a skill write must reach the host\n${said}`); + assert.equal(read0(path.join(repo, '.git', 'hooks', 'pre-commit')), '#!/bin/sh\n', `the repository's hook must be unchanged\n${said}`); + assert.equal(read0(path.join(repo, '.git', 'config')), config, `the repository's config must be unchanged\n${said}`); + assert.equal(read0(path.join(repo, '.claude', 'settings.json')), '{}\n', `the repository's settings must be unchanged\n${said}`); + assert.ok(gone(path.join(vendored, '.claude', 'settings.json')), `a repository in skills must not get settings\n${said}`); + assert.ok(gone(path.join(C, 'hooks', 'evil.sh')), `hooks must stay read-only\n${said}`); + assert.ok(gone(path.join(C, 'commands', 'evil.md')), `commands must stay read-only\n${said}`); + assert.ok(gone(path.join(C, 'agents', 'evil.md')), `agents must stay read-only without its flag\n${said}`); + assert.equal(read0(path.join(rig.proj, '.claude', 'skills', 'p', 'SKILL.md')), 'ok\n', `a project skill write must reach the host\n${said}`); + assert.equal(read0(path.join(rig.proj, '.claude', 'settings.json')), '{}\n', `project settings must stay read-only\n${said}`); + + fs.mkdirSync(path.join(repo, 'snaps')); + fs.writeFileSync(path.join(repo, 'snaps', 'snap.sh'), 'export A=1\n'); + fs.symlinkSync(path.join(repo, 'snaps'), path.join(C, 'shell-snapshots')); + const refused = rig.run(['echo evil > "$HOME/.claude/shell-snapshots/snap.sh"'], { SWITCHBOARD_SANDBOX_RW_SKILLS: '1' }); + assert.equal(refused.status, 125, `a private entry linked into skills must refuse the launch\n${refused.stdout}`); + assert.equal(read0(path.join(repo, 'snaps', 'snap.sh')), 'export A=1\n', 'the linked snapshot must be unchanged'); + } finally { + rig.cleanup(); + } + }); From 060d0a07d03be4e117309be6c3ddbebac17fda40 Mon Sep 17 00:00:00 2001 From: abate Date: Sun, 11 Oct 2026 15:02:09 +0200 Subject: [PATCH 2/2] fix(sandbox): refuse an opted-in directory that overlaps a private entry, and protect a repository's .claude inside it A private ~/.claude entry (shell-snapshots, session-env, backups, state) or ~/.claude.json that links into an opted-in skills or agents would be writable from inside the sandbox; the launch is refused instead. A repository inside an opted-in directory gets a read-only .claude, and a read-only directory bound under another spelling is compared by its resolved path. The changelog entry moves to Unreleased. --- CHANGELOG.md | 4 +++- docs/sandbox.md | 25 +++++++++++++++++++---- scripts/claude-sandbox.sh | 42 ++++++++++++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f2a3db8..b67f97b9a 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 +### New +- With Sandbox on, `SWITCHBOARD_SANDBOX_RW_SKILLS=1` or `SWITCHBOARD_SANDBOX_RW_AGENTS=1` in the Pre-launch Command lets the session create and edit skills or agents, in `~/.claude` and the project's `.claude`. Both are off by default: what the session writes there is loaded, and run, by later sessions outside the sandbox. (#483) + ### Fixed - Switching terminal tabs no longer freezes the window for about two seconds, a regression introduced in v0.0.91. (#541) - Switching to a hidden terminal tab no longer disposes and recreates its graphics context every time, which blocked the window for several seconds. The three most recently shown terminals keep theirs, and a context that has to be recreated is made once the tab is shown. (#526) @@ -31,7 +34,6 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ### New - An Agents view lists the sessions the Claude daemon runs in the background (`claude --bg`) and the interactive sessions running outside Switchboard, grouped by state or by project and worktree. Open it from the people icon in the sidebar or with Ctrl+Shift+A (Cmd+Shift+A on macOS); attach to a live one by double click, stop, respawn or delete one, and start a new one with New agent. A background session that is running shows a `bg` badge in the sidebar and is attached instead of resumed. (#374) -- With Sandbox on, `SWITCHBOARD_SANDBOX_RW_SKILLS=1` or `SWITCHBOARD_SANDBOX_RW_AGENTS=1` in the Pre-launch Command lets the session create and edit skills or agents, in `~/.claude` and the project's `.claude`. Both are off by default: what the session writes there is loaded, and run, by later sessions outside the sandbox. (#483) - **Archive folder** on a project header opens a dialog that archives the folder's sessions and disables its enabled schedules, each optional, and hides the folder with its worktrees. The folder comes back, with its settings, when you add it again or a new session starts in it, and then offers to turn back on the schedules the archive disabled. (#473) ### Changed - New scheduled task runs appear under a header named after their schedule, even with only one visible run. Schedules that use the same prompt stay separate; older runs keep their existing grouping. An old expansion choice is inherited only when it identifies one schedule across projects and hosts. (#489) diff --git a/docs/sandbox.md b/docs/sandbox.md index f11f3fd0d..4cf8c8a94 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -204,8 +204,12 @@ directory's `.claude`, as session state: repository keeps its config and hooks read-only and every `.claude` stays read-only, as in a bound directory (see [Git](#git)); a file linked from `~/.claude` into it, such as a `settings.json` kept in the same dotfiles - repository, stays read-only too. No `.claude` is created there. Other links - inside it are not followed. + repository, stays read-only too. A repository root there that has no + `.claude` gets an empty one before launch, read-only like the others, so the + session cannot give the repository settings that a `claude` started there + later would load. No `.claude` is created in a folder that is not a + repository: one the session creates there is loaded by a `claude` started in + that folder later. Other links inside it are not followed. - The launch is refused when the writable directory is also, or lies inside, something the sandbox keeps read-only: the target of another `~/.claude` entry (`hooks` and `skills` linked to the same directory, or `skills` linked @@ -217,11 +221,24 @@ directory's `.claude`, as session state: the order in which the entries are bound does not matter, and it compares resolved paths: the directory is refused wherever the sandbox sees it read-only, also when the project, an Additional Directory or `$HOME` is - reached through a symbolic link. + reached through a symbolic link. It is also refused when a directory the + sandbox binds read-only (the node install, `$NVM_DIR`) lies inside the + writable directory under another spelling, where the writable mount would + cover it. +- The launch is also refused when the target of a private entry + (`shell-snapshots`, `session-env`, `backups`, `state`) or of a + `~/.claude.json` that is a symbolic link is the writable directory, lies + inside it or contains it. The sandbox gives those entries a private copy, so + no mount of theirs is there to compare, while other sessions outside the + sandbox source the snapshots and session hooks and load the MCP servers of + `~/.claude.json`: written through the opted-in directory, they would run + unsandboxed. This gives up part of the protection: what the session writes there is loaded, and its scripts run, by every later session outside the sandbox, other -projects' included for `~/.claude`. +projects' included for `~/.claude`. A hook in a read-only `settings.json` that +runs a script kept under `skills/` or `agents/` runs whatever the session wrote +there. ### `~/.claude.json` diff --git a/scripts/claude-sandbox.sh b/scripts/claude-sandbox.sh index 539ad900a..1c4090d64 100755 --- a/scripts/claude-sandbox.sh +++ b/scripts/claude-sandbox.sh @@ -490,6 +490,7 @@ bind_user_claude_dir() { bind_projects_dir "$e" elif in_list "$name" "${USER_PRIVATE_ENTRIES[@]}"; then mount_op --tmpfs "" "$e" + [ -L "$e" ] && PRIVATE_LINKS+=("$e") elif [ -L "$e" ]; then mount_op --symlink "$(readlink "$e")" "$e" if in_list "$name" "${USER_STATE_ENTRIES[@]}"; then @@ -503,7 +504,7 @@ bind_user_claude_dir() { bind_state_link_target "$e" "$kind" if [ -n "$STATE_LINK_TARGET" ] && in_list "$name" ${OPT_IN_ENTRIES[@]+"${OPT_IN_ENTRIES[@]}"}; then OPT_IN_BINDS+=("user:$((${#M_DEST[@]} - 1))") - protect_tree "$STATE_LINK_TARGET" nocreate + protect_tree "$STATE_LINK_TARGET" repo fi else protect_link_target "$e" @@ -512,7 +513,7 @@ bind_user_claude_dir() { mount_op --bind "$e" "$e" if [ -d "$e" ] && in_list "$name" ${OPT_IN_ENTRIES[@]+"${OPT_IN_ENTRIES[@]}"}; then OPT_IN_BINDS+=("user:$((${#M_DEST[@]} - 1))") - protect_tree "$e" nocreate + protect_tree "$e" repo fi else mount_op --ro-bind "$e" "$e" @@ -621,6 +622,9 @@ protect_repo_root() { mount_op --ro-bind "$d/.git" "$d/.git" GIT_WORKTREES+=("$d") fi + if [ "$create" = repo ]; then + if [ -e "$d/.git" ]; then create=create; else create=nocreate; fi + fi protect_claude_dir "$d/.claude" "$create" } @@ -715,13 +719,15 @@ pin_protected_paths() { # see docs/sandbox.md, "Writable skills and agents" check_opt_in_binds() { [ "${#OPT_IN_BINDS[@]}" -gt 0 ] || return 0 - local -a reals=() - local b k tr t i r v + local -a reals=() idx=() srcs=() out=() + local b k tr t i j r v for i in "${!M_DEST[@]}"; do - case "${M_OP[i]}" in - --bind|--ro-bind) reals[i]="$(readlink -m -- "${M_SRC[i]}")" ;; - esac + case "${M_OP[i]}" in --bind|--ro-bind) idx+=("$i"); srcs+=("${M_SRC[i]}") ;; esac done + [ "${#srcs[@]}" -gt 0 ] && mapfile -d '' out < <(readlink -m -z -- "${srcs[@]}") + for j in "${!idx[@]}"; do reals[idx[j]]="${out[j]}"; done + local -a privs=() + [ "${#PRIVATE_LINKS[@]}" -gt 0 ] && mapfile -d '' privs < <(readlink -m -z -- "${PRIVATE_LINKS[@]}") for b in ${OPT_IN_BINDS[@]+"${OPT_IN_BINDS[@]}"}; do k="${b#*:}"; t="${M_DEST[k]}"; tr="${reals[k]}" for i in "${!reals[@]}"; do @@ -735,6 +741,18 @@ check_opt_in_binds() { for r in "${!PROTECTED_SEEN[@]}"; do case "$tr/" in "$r"/*) opt_in_refuse "$t" "$r" ;; esac done + for j in "${!privs[@]}"; do + r="${privs[j]}" + case "$tr/" in "$r"/*) opt_in_refuse_private "$t" "${PRIVATE_LINKS[j]}" "$r" ;; esac + case "$r/" in "$tr"/*) opt_in_refuse_private "$t" "${PRIVATE_LINKS[j]}" "$r" ;; esac + done + for i in "${!reals[@]}"; do + [ "${M_OP[i]}" = --ro-bind ] || continue + r="${reals[i]}" + case "$r" in "$tr"/*) ;; *) continue ;; esac + opt_in_access_at "$t${r#"$tr"}" "" + [ "${M_OP[OA_IDX]}" = --ro-bind ] || opt_in_refuse_below "$t" "$r" + done done } @@ -759,6 +777,14 @@ opt_in_refuse() { fail "refusing to launch: $1, which SWITCHBOARD_SANDBOX_RW_$(printf '%s' "${1##*/}" | tr a-z A-Z)=1 makes writable, is $where $2, which the sandbox keeps read-only; the session could change it through ${1##*/}. Give ${1##*/} a directory of its own, or turn the flag off for this session." } +opt_in_refuse_private() { + fail "refusing to launch: $2 links to $3, which overlaps $1, which SWITCHBOARD_SANDBOX_RW_$(printf '%s' "${1##*/}" | tr a-z A-Z)=1 makes writable; the sandbox gives $2 a private copy, and the session could change the host's through ${1##*/}. Move $2's target out of ${1##*/}, or turn the flag off for this session." +} + +opt_in_refuse_below() { + fail "refusing to launch: $2, which the sandbox keeps read-only, lies inside $1, which SWITCHBOARD_SANDBOX_RW_$(printf '%s' "${1##*/}" | tr a-z A-Z)=1 makes writable; the session could change it through ${1##*/}. Move it out of ${1##*/}, or turn the flag off for this session." +} + # Fills BWRAP_ARGS from what exists now, and MISSING_DIRS with what must be # created before the real launch. Run once for the pre-flight, then again once # the missing directories exist. @@ -769,6 +795,7 @@ build_bwrap_args() { MISSING_DIRS=() GIT_WORKTREES=() OPT_IN_BINDS=() + PRIVATE_LINKS=() local d name i mount_op --dev "" /dev mount_op --proc "" /proc @@ -811,6 +838,7 @@ build_bwrap_args() { bind_user_claude_dir "$CLAUDE_DIR" # fd 9 carries the private copy; see claude_json_source. mount_op --file 9 "$CLAUDE_JSON" + [ -L "$CLAUDE_JSON" ] && PRIVATE_LINKS+=("$CLAUDE_JSON") for d in ${GIT_WORKTREES[@]+"${GIT_WORKTREES[@]}"}; do protect_git "$d" done