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
35 changes: 35 additions & 0 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,41 @@ Main-process modules `trigger-watcher.js` and `trigger-context.js` live in

## Public surface

### Sandbox control directory

`getTriggersDir()` is exported and resolves the nonempty
`SWITCHBOARD_TRIGGERS_DIR` override or `~/.switchboard/triggers` to an absolute
main-process path. The manual sandbox launch path (new/resume/fork) and the
schedule launcher prepare its `processed/` directory and pass that same path
as `SWITCHBOARD_SANDBOX_TRIGGERS_DIR`; a relative app override is never resolved
against a different session cwd. Legacy dispatch and result paths are unchanged.

The wrapper protects the whole control tree and separately resolved `processed`
target read-only through every visible writable mount alias, pins ancestors,
and refuses unsafe overlaps, mutable symlinks and missing visible paths with
status 125 before CLI execution. Final mount-plan rebuilding rechecks protection.
A control directory inside the project can launch protected read-only. Unrelated
newline-containing writable paths remain allowed; unrepresentable overlapping
mounts refuse. The full final rebuild is retained: rechecking only control
paths would need to validate cached mount sources and ancestor pins after
symlink changes as well as newly materialised state mounts.
Host watcher publication stays writable. Only fresh sandbox launches acquire
these mounts; existing sessions are not terminated or remounted.

Launch coverage evaluates the shipped complete `open-terminal` registration and
`runScheduleCommand` with injected spawn ports. Each new/resume/fork case asserts
its CLI arguments, spawn cwd and control path at `spawnPty`; the schedule case
checks `cpSpawn`. Wrapper cases use Linux-only skips, including fake-bwrap cases,
so Windows CI does not pay Git Bash startup and mount-plan costs. Real-bwrap
coverage reads a sentinel through every view and writes beside pinned ancestors
before checking denied control writes, ruling out absent or broadly read-only views.

Protection of triggers, results and markers **does not hold when the Podman
socket is exposed** by `SWITCHBOARD_SANDBOX_PODMAN=1`: that service can mount host
paths writable in another container. The Podman opt-in and successful launch
behavior remain unchanged, with no refusal solely for exposing the socket and
no retry without Sandbox. See [sandbox.md](../../docs/sandbox.md#file-trigger-control-directory).

Result publication uses the synchronous Windows rename retry policy documented
in [session-cache.md](session-cache.md#atomic-rename-retries-on-windows-900).
After retry exhaustion, the existing log-and-delete-trigger behaviour still
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ What changes for you in each release of Switchboard. How to write an entry: [doc

- Images opened from a link or from the Touched list now show in the panel. Other files the panel can't show offer **Open with system viewer** for pictures and PDFs, and **Show in folder** for the rest. (#1025)

### Changed
- New sandboxed sessions protect the triggers directory even when it sits inside a project or another writable directory, and refuse layouts that cannot protect it safely. This protection does not apply when Podman access is enabled. (#1022)

### Fixed
- A conversation's message count in the sidebar now grows as the conversation does, including remote conversations without recounting their entire transcript on each fetch. Conversations sharing copied history keep duplicate messages out of their counts. (#1017)

Expand Down
35 changes: 35 additions & 0 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,41 @@ whatever they hold.
nvm installs the binary's directory is read-only.
- **`/etc`** is readable, like on the host.

## File-trigger control directory

Fresh sandbox launches cannot write, rename, replace or publish files in the
effective triggers directory, including `processed/` results and action markers,
through their filesystem mounts. Manual new, resumed and forked sessions and
schedules use the same absolute directory as the trigger watcher: a nonempty
`SWITCHBOARD_TRIGGERS_DIR`, or `~/.switchboard/triggers`. Switchboard prepares it
and passes it as the internal single-path `SWITCHBOARD_SANDBOX_TRIGGERS_DIR`.
Standalone wrapper launches use the same override/default policy, resolving a
relative override from their own working directory.

The wrapper protects the entire tree at every visible writable-mount alias,
including project/worktree, additional-directory and CLI-state mounts. It also
protects a separately resolved `processed` target and pins intervening ancestor
directories against rename and replacement. A control path outside the sandbox
stays unexposed. A control tree inside a project or another writable directory
can launch with read-only protection; only unsafe layouts are refused. A cwd or
explicit extra bind at or below a control path, mutable
symlink chain, missing visible control directory, unresolved or unrepresentable
control path, an unrepresentable overlapping mount, or a writable mount that
would reopen the tree refuses launch with status 125 and a `claude-sandbox:` diagnostic. Paths are checked again when the final
mount plan is built. Unrelated writable paths containing newlines remain allowed.
There is no retry without Sandbox or saved-option change.
The wrapper creates no host state before its pre-flight succeeds; Switchboard's
host-side directory preparation and watcher publication remain writable.

**This protection does not hold when `SWITCHBOARD_SANDBOX_PODMAN=1` exposes the
Podman socket.** The service can mount host paths writable in another container,
including triggers, results and markers. The existing Podman opt-in and successful
socket-exposing launch behavior are unchanged; exposing the socket alone does
not refuse launch. The sandbox's own mount-plan checks still apply.

Existing sandbox namespaces retain their original mounts. This protection takes
effect on fresh launches; it does not terminate already-running sessions.

## Limits

- `git commit` has no identity unless the repository sets `user.name` and
Expand Down
4 changes: 4 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./src/main/terminals/shell-profiles');

Check warning on line 84 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleLaunchRefusal, resolveScheduleSandbox, initialScheduleProjects, scanSchedules, setScheduleEnabled } = require('./src/main/schedules/schedule-runner');
const { buildScheduleList } = require('./src/main/schedules/schedule-list');
const { archivePlanForGroups, validArchiveGroups } = require('./archived-projects');
Expand Down Expand Up @@ -591,8 +591,8 @@
getMainWindow: () => mainWindow,
log,
});
const { readSessionFile, readFolderFromFilesystem,

Check warning on line 594 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 594 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
backfillEntrypoints, backfillCustomTitles, revealIfSdkSession, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 595 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache;
// see .ai/contexts/session-cache.md ("Projects query worker")
const { createProjectsWorkerClient } = require('./projects-worker-client');
Expand Down Expand Up @@ -2998,7 +2998,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 3001 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down Expand Up @@ -3151,6 +3151,8 @@
// see docs/sandbox.md ("Schedules")
if (projectPath) await scheduleProjects().add(projectPath);
if (!isAttach && sessionOptions?.sandbox) {
ptyEnv.SWITCHBOARD_SANDBOX_TRIGGERS_DIR = require('./src/main/triggers/trigger-watcher').getTriggersDir();
fs.mkdirSync(path.join(ptyEnv.SWITCHBOARD_SANDBOX_TRIGGERS_DIR, 'processed'), { recursive: true });
// Directories the sandboxed claude must still reach beyond the cwd:
// the project root when resuming inside a worktree (git metadata lives
// there), and any user-configured Additional Directories.
Expand Down Expand Up @@ -3936,6 +3938,8 @@
if (onDone) onDone();
return { ok: false, error: refusal.error };
}
env.SWITCHBOARD_SANDBOX_TRIGGERS_DIR = require('./src/main/triggers/trigger-watcher').getTriggersDir();
fs.mkdirSync(path.join(env.SWITCHBOARD_SANDBOX_TRIGGERS_DIR, 'processed'), { recursive: true });
cmd = quoteArgvForShell(shell, ['bash', sandboxScriptPath()]) + ' ' + quoteArgvForShell(shell, claudeArgv);
const bindEnv = sandboxBindEnv(addDirs);
if (bindEnv) env.SWITCHBOARD_SANDBOX_BINDS = bindEnv;
Expand Down
137 changes: 135 additions & 2 deletions scripts/claude-sandbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,16 @@ WRITABLE_REALS=()
for _r in "${WRITABLE_ROOTS[@]}"; do
WRITABLE_REALS+=("$(readlink -f "$_r" 2>/dev/null || true)")
done
BASE_WRITABLE_ROOTS=("${WRITABLE_ROOTS[@]}")

CONTROL_DIR="${SWITCHBOARD_SANDBOX_TRIGGERS_DIR:-${SWITCHBOARD_TRIGGERS_DIR:-$HOME/.switchboard/triggers}}"
case "$CONTROL_DIR" in
*$'\n'*|*$'\r'*) fail "unrepresentable control path: $CONTROL_DIR" ;;
esac
if [ -n "${SWITCHBOARD_SANDBOX_TRIGGERS_DIR:-}" ]; then
case "$CONTROL_DIR" in /*) ;; *) fail "control path must be absolute: $CONTROL_DIR" ;; esac
fi
case "$CONTROL_DIR" in /*) ;; *) CONTROL_DIR="$PWD/$CONTROL_DIR" ;; esac

# Sets SV_VIEW to where the resolved host path $1 appears inside the sandbox,
# and SV_IDX to its writable root, when it lies under one; fails otherwise.
Expand Down Expand Up @@ -368,8 +378,8 @@ check_chain() {
esac
if [ -L "$cur/$comp" ]; then
hops=$((hops + 1))
[ "$hops" -le 40 ] || return 0
if sandbox_view "${cur:-/}" && ! inside_ro_area "$SV_VIEW/$comp"; then
[ "$hops" -le 40 ] || fail "cannot resolve protected path: $orig"
if chain_link_writable "${cur:-/}" "$comp" "${2:-}"; then
fail "refusing to launch: $cur/$comp is a symbolic link in a directory the sandbox can write, on the way to $orig, which it protects; the session could re-point it. Replace the link with what it points to (for git hooks, set core.hooksPath to that directory instead), or turn Sandbox off for this session."
fi
link="$(readlink "$cur/$comp")"
Expand All @@ -389,6 +399,7 @@ protect_resolved() {
[ -n "${PROTECTED_SEEN[$real]:-}" ] && return 1
PROTECTED_SEEN["$real"]=1
for i in "${!WRITABLE_ROOTS[@]}"; do
[ "$i" -ge "${2:-0}" ] || continue
root="${WRITABLE_REALS[i]}"
[ -n "$root" ] || continue
case "$real" in
Expand All @@ -404,6 +415,121 @@ protect_resolved() {
return "$found"
}

control_path_forms() {
local p="$1" forms real ancestor
forms="$(path_forms "$p")" || fail "cannot resolve control path: $p"
real="$(readlink -e -- "$p" 2>/dev/null || true)"
[ "$real" != / ] || fail "control path covers the filesystem root: $p"
case "$real" in *$'\n'*|*$'\r'*) fail "unrepresentable control path: $p" ;; esac
if [ -z "$real" ]; then
check_chain "$p" control
ancestor="$p"
while [ ! -e "$ancestor" ] && [ ! -L "$ancestor" ] && [ "$ancestor" != / ]; do
ancestor="${ancestor%/*}"
[ -n "$ancestor" ] || ancestor=/
done
[ -d "$ancestor" ] && [ -x "$ancestor" ] || fail "cannot resolve control path: $p"
[ ! -e "$p" ] && [ ! -L "$p" ] || fail "cannot resolve control path: $p"
elif [ ! -d "$real" ]; then
fail "control path is not a directory: $p"
fi
CONTROL_FORMS="$forms"
CONTROL_REAL="$real"
}

chain_link_writable() {
local parent="$1" comp="$2" i root view
if [ "$3" != control ]; then
sandbox_view "$parent" && ! inside_ro_area "$SV_VIEW/$comp"
return
fi
for ((i = CONTROL_ROOT_START; i < ${#WRITABLE_REALS[@]}; i++)); do
root="${WRITABLE_REALS[i]}"
case "$parent" in
"$root") view="${WRITABLE_ROOTS[i]}" ;;
"$root"/*) view="${WRITABLE_ROOTS[i]}${parent#"$root"}" ;;
*) continue ;;
esac
inside_ro_area "$view/$comp" || return 0
done
return 1
}

control_visible() {
local i root
for ((i = CONTROL_ROOT_START; i < ${#WRITABLE_REALS[@]}; i++)); do
root="${WRITABLE_REALS[i]}"
case "$1/" in "$root"/*) return 0 ;; esac
done
return 1
}

protect_control_tree() {
local i real p forms control requested request_form request_lexical request_real root pin_start
local -a controls=() control_forms=()
CONTROL_ROOT_START="${#WRITABLE_ROOTS[@]}"
for i in "${!M_OP[@]}"; do
[ "${M_OP[i]}" = --bind ] || continue
real="$(readlink -e -- "${M_SRC[i]}" 2>/dev/null)" && [ -n "$real" ] || fail "cannot resolve writable mount for control path $CONTROL_DIR: ${M_SRC[i]}"
WRITABLE_ROOTS+=("${M_DEST[i]}"); WRITABLE_REALS+=("$real")
done
control_path_forms "$CONTROL_DIR"
debug "control: $CONTROL_DIR -> $CONTROL_REAL ($CONTROL_FORMS)"
forms="$CONTROL_FORMS"
controls+=("$CONTROL_REAL"); control_forms+=("$forms")
control_path_forms "$CONTROL_DIR/processed"
controls+=("$CONTROL_REAL"); control_forms+=("$CONTROL_FORMS")
for i in "${!control_forms[@]}"; do
forms="${control_forms[i]}"; real="${controls[i]}"
while IFS= read -r control; do
for requested in "${RW_DIRS[@]}"; do
request_lexical="$(realpath -m -s -- "$requested" 2>/dev/null)" && [ -n "$request_lexical" ] &&
request_real="$(readlink -m -- "$requested" 2>/dev/null)" && [ -n "$request_real" ] || fail "cannot resolve requested mount for control path: $CONTROL_DIR"
for request_form in "$request_lexical" "$request_real"; do
case "$request_form/" in "$control"/*) fail "refusing writable mount '$requested' at or below control path: $CONTROL_DIR" ;; esac
done
done
if [ -z "$real" ] && control_visible "$control"; then
fail "missing visible control path: $CONTROL_DIR${i:+ (including processed)}"
fi
done <<<"$forms"
[ -n "$real" ] || continue
for ((p = CONTROL_ROOT_START; p < ${#WRITABLE_REALS[@]}; p++)); do
root="${WRITABLE_REALS[p]}"
case "$root/" in "$real"/*) fail "writable mount '${WRITABLE_ROOTS[p]}' overlaps control path: $CONTROL_DIR" ;; esac
case "$real/" in "$root"/*)
case "$root${WRITABLE_ROOTS[p]}" in *$'\n'*|*$'\r'*) fail "unrepresentable overlapping mount for control path: $CONTROL_DIR" ;; esac ;;
esac
done
p="$CONTROL_DIR"
[ "$i" = 0 ] || p="$CONTROL_DIR/processed"
check_chain "$p" control
unset 'PROTECTED_SEEN[$real]'
pin_start="${#PIN_VIEWS[@]}"
protect_resolved "$real" "$CONTROL_ROOT_START" || true
CONTROL_VIEWS+=("${PIN_VIEWS[@]:pin_start}")
done
}

verify_control_mounts() {
local k i view dest best depth best_depth
for k in "${!CONTROL_VIEWS[@]}"; do
view="${CONTROL_VIEWS[k]}"
best=""; best_depth=-1
for i in "${!M_OP[@]}"; do
dest="${M_DEST[i]}"
case "$view/" in "$dest"/*)
depth="${dest//[!\/]/}"
if [ "${#depth}" -ge "$best_depth" ]; then best="$i"; best_depth="${#depth}"; fi ;;
esac
case "$dest/" in "$view"/*)
[ "${M_OP[i]}" = --ro-bind ] || fail "mount '$dest' reopens control path: $CONTROL_DIR" ;;
esac
done
[ -n "$best" ] && [ "${M_OP[best]}" = --ro-bind ] || fail "unprotected control path: $CONTROL_DIR ($view)"
done
}

# Link $1, resolved to $2: its target read-only, and every link below that
# target when it is a directory.
protect_resolved_link() {
Expand Down Expand Up @@ -796,6 +922,10 @@ build_bwrap_args() {
GIT_WORKTREES=()
OPT_IN_BINDS=()
PRIVATE_LINKS=()
CONTROL_VIEWS=()
WRITABLE_ROOTS=("${BASE_WRITABLE_ROOTS[@]}")
WRITABLE_REALS=()
for d in "${WRITABLE_ROOTS[@]}"; do WRITABLE_REALS+=("$(readlink -m -- "$d" 2>/dev/null || true)"); done
local d name i
mount_op --dev "" /dev
mount_op --proc "" /proc
Expand Down Expand Up @@ -842,7 +972,9 @@ build_bwrap_args() {
for d in ${GIT_WORKTREES[@]+"${GIT_WORKTREES[@]}"}; do
protect_git "$d"
done
protect_control_tree
pin_protected_paths
verify_control_mounts
check_opt_in_binds

BWRAP_ARGS=(--unshare-all --share-net --die-with-parent)
Expand Down Expand Up @@ -908,5 +1040,6 @@ for _pass in 1 2 3; do
done
build_bwrap_args
done
build_bwrap_args
[ "${#MISSING_DIRS[@]}" -eq 0 ] || fail "could not prepare the mount points: ${MISSING_DIRS[*]}"
exec bwrap "${BWRAP_ARGS[@]}" "$CLAUDE_REAL" "$@" 9< <(claude_json_source)
4 changes: 2 additions & 2 deletions src/main/triggers/trigger-watcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -932,7 +932,7 @@ function waitForBusyFall(sessionId, ctx, deadlineMs, enterAtMs, turnObserved = f
}

function getTriggersDir() {
return process.env.SWITCHBOARD_TRIGGERS_DIR || DEFAULT_TRIGGERS_DIR;
return path.resolve(process.env.SWITCHBOARD_TRIGGERS_DIR || DEFAULT_TRIGGERS_DIR);
}

function getIdleTimeout() {
Expand Down Expand Up @@ -2171,4 +2171,4 @@ function start(ctx) {
};
}

module.exports = { start, weakestSubmitted, SUBMITTED_RANK, normalizeCwd, submitWithVerify, waitForCliIdleAfter, waitForBusyFall, isCompactCommand, REASON_REMOTE_NO_FRESH_PULL, REASON_REMOTE_SHELL };
module.exports = { start, getTriggersDir, weakestSubmitted, SUBMITTED_RANK, normalizeCwd, submitWithVerify, waitForCliIdleAfter, waitForBusyFall, isCompactCommand, REASON_REMOTE_NO_FRESH_PULL, REASON_REMOTE_SHELL };
7 changes: 7 additions & 0 deletions test/run-schedule-now-result.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,19 @@ test('VM loader rejects an unused lazy stub key after the operation', () => {
function setup(t, { sandbox = false, platform = 'linux', refused = false, spawnError, syncError, archived = false, noReply = false, deferSpawn = false, windowState = 'open' } = {}) {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sb-run-result-')));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const previousTriggers = process.env.SWITCHBOARD_TRIGGERS_DIR;
process.env.SWITCHBOARD_TRIGGERS_DIR = path.join(root, 'triggers');
t.after(() => {
if (previousTriggers === undefined) delete process.env.SWITCHBOARD_TRIGGERS_DIR;
else process.env.SWITCHBOARD_TRIGGERS_DIR = previousTriggers;
});
const file = path.join(root, '.claude', 'commands', 'schedule-test.md');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '---\nname: test\n---\nDo the task');
const handlers = new Map(), children = [], events = [], messages = [], logs = [], activities = [];
const log = { info: (...args) => logs.push(args), warn: (...args) => logs.push(args), error: (...args) => logs.push(args) };
const ctx = vm.createContext({
require: createRequire(path.join(__dirname, '..', 'main.js')), fs, path,
getSetting: () => ({}), isProjectSettingsArchived: () => archived, Date,
mainWindow: windowState === 'absent' ? null : {
isDestroyed: () => windowState === 'destroyed',
Expand Down
Loading
Loading