Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 21 additions & 4 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`

Expand Down
42 changes: 35 additions & 7 deletions scripts/claude-sandbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading