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
2 changes: 1 addition & 1 deletion .ai/contexts/schedule-runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ From `schedule-runner.js`:
- `scheduleRegistry(getSetting, setSetting)` — the `scheduleProjects` setting: `list()`, `add(path)`, `remove(path)`. `main.js` adds a project when it spawns a Claude session in it and on `add-project`, removes it on `remove-project`. Transcripts never add one: their `cwd`, and a folder name derived from it, are written by whoever ran claude, a sandboxed session included (see [docs/sandbox.md](../../docs/sandbox.md), "Schedules").
- `initialScheduleProjects()` — the seed, read once while the setting has never been written: `~/.claude/projects` folders whose recorded path encodes back to the folder's name and holds a schedule.
- `resolveScheduleSandbox(cwd, getSetting, default)` — the `project:` setting of `cwd` or of the nearest directory above it that has one, then `global`, then the default.
- `refusedScheduleBinds(addDirs, projects, home)` — the `add-dirs` under `home` that are neither a registered project nor inside one. `main.js` skips a sandboxed run when it is not empty.
- `refusedScheduleBinds(addDirs, projects, home, baseDir)` / `scheduleBindRefusals` (with the reason, used by `main.js`) — the `add-dirs` that are at or inside a `.claude` or `.git` (judged as spelled and by real path, anywhere), or under `home` and neither a registered project nor inside one (real paths). `main.js` skips a sandboxed run when it is not empty.
- `createScheduleSession(schedule, dueMs)` — write a pre-seeded JSONL into `~/.claude/projects/<encoded>/<uuid>.jsonl` with the schedule's prompt as the first user message, prefixed `Scheduled Task (catch-up: due …, started …): ` when `dueMs` is set. Returns the session UUID.
- `buildScheduleCommand(sessionId, schedule)` — assemble the shell command (`claude --resume "<sid>" -p "..." --permission-mode acceptEdits --allowedTools "..."`).
- `parseFrontmatter(content)`, `cronMatches(cronExpr, now)` — utilities, exported for tests.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
## Unreleased

### Fixed
- A sandboxed session, or a sandboxed schedule, whose Additional Directories include a `.claude` or `.git` directory, or a path inside one, is now refused instead of binding it read-write over its read-only protection; add the project directory instead. A session started in a `.claude` or `.git` directory is refused too, except below `.claude/worktrees`, and Additional Directories naming your home directory or a parent of it are refused however the path is written. A relative `add-dirs` entry in a schedule is taken from the schedule's directory. (#385)
- A session that has exited no longer keeps a busy dot in the sidebar, and the status bar's running count drops as soon as the session ends instead of waiting for the next refresh. (#375)
- Quitting, closing the window or reloading while a file in the file panel has unsaved edits now asks first, in any session, kept-aside tabs included: Save writes them (a file that changed on disk is not overwritten), Discard drops them, Cancel stays. If Switchboard does not answer within a few seconds, it closes anyway. (#373)
- The IDE Emulation label in a session's terminal header now says whether the CLI is connected: it reads "IDE Emulation" only while it is, "IDE Emulation: waiting for CLI" when Switchboard is listening but the CLI has not connected, and "IDE Emulation: failed" when it could not start for that session, with the reason in its tooltip. A session whose IDE Emulation port was already taken no longer shows the label as if it worked. (#320)
Expand Down
26 changes: 24 additions & 2 deletions docs/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ The paths come from `git rev-parse --git-dir --git-common-dir --git-path hooks`
run in the directory before launch; a repository it cannot read is refused
rather than guessed at.

The search below a bound directory does not enter `node_modules`, and does not
cross into another filesystem (`find -xdev`): a `.git` or `.claude` inside a
`node_modules`, or on a mount or a bind inside the project, is not protected.

### The way to a protected path

A read-only mount protects one path, not the directories leading to it. Every
Expand All @@ -221,10 +225,28 @@ in a directory the sandbox can write — `core.hooksPath=linked/hooks` with
instead, so the launch is refused; point `core.hooksPath` at a hooks directory
instead of linking `.git/hooks` to it.

The paths are resolved when the wrapper builds the sandbox and mounted when
bwrap starts it; a second sandboxed session on the same project that swaps a
The paths, and the Additional Directories (below), are resolved when the
wrapper builds the sandbox and mounted when bwrap starts it; a second sandboxed
session on the same project that swaps a
directory in between is not detected.

### Additional directories

An Additional Directory, or a schedule's `add-dirs` entry, is bound
read-write, so one at or inside a `.claude` or `.git` directory is refused: the
wrapper stops with status 125 and names it, and a sandboxed schedule with such
an entry is skipped with the reason in the main log. The path is judged both as
spelled (`..` and a trailing slash resolved) and by its real path, so a link
to a `.claude`, or a `.claude` that is itself a link, does not get through. Add
the project directory instead: its `.claude` and `.git` are then protected as
above. The session's working directory is held to the same rule, with one exception:
a directory below `.claude/worktrees` is allowed, because Claude Code's
worktrees live there (a `.claude` or `.git` further down is still refused). A
path the wrapper cannot resolve is refused. The same check refuses `$HOME` and
its parents however they are spelled (`$HOME/`, `$HOME/.`, a link to it).
A relative `add-dirs` entry of a schedule is taken from the schedule's
directory.

### Schedules

The [scheduler](automation.md#schedules) runs the schedules of the projects in
Expand Down
6 changes: 3 additions & 3 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@
}

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

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

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

Check warning on line 71 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, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
const { scanMdFiles, acceptMdFile } = require('./scan-md-files');
Expand Down Expand Up @@ -478,8 +478,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 481 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 481 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
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 482 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;
const { resolveJsonlPath, enumerateSessionFiles, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -2446,7 +2446,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 2449 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 @@ -3130,9 +3130,9 @@
if (claudeArgv[i] === '--add-dir') addDirs.push(claudeArgv[i + 1]);
}
// see docs/sandbox.md ("Schedules")
const refused = refusedScheduleBinds(addDirs, scheduleProjects().list(), os.homedir());
const refused = scheduleBindRefusals(addDirs, scheduleProjects().list(), os.homedir(), cwd);
if (refused.length) {
log.error(`[schedule] ${name}: skipped — add-dirs under the home directory that are not Switchboard projects: ${refused.join(', ')}`);
log.error(`[schedule] ${name}: skipped — add-dirs refused: ${refused.map(r => `${r.dir} (${r.reason})`).join(', ')}`);
if (onDone) onDone();
return;
}
Expand Down
55 changes: 42 additions & 13 deletions schedule-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,22 +238,51 @@ function resolveScheduleSandbox(cwd, getSetting, defaultValue) {
return !!defaultValue;
}

/** The real path of `p`; for a path that does not exist, its nearest existing ancestor's real path plus the rest. */
function canonicalPath(p) {
const resolved = path.resolve(p);
const rest = [];
let dir = resolved;
for (;;) {
try {
return path.join(fs.realpathSync(dir), ...rest.reverse());
} catch {
const parent = path.dirname(dir);
if (parent === dir) return resolved;
rest.push(path.basename(dir));
dir = parent;
}
}
}

/**
* The add-dirs of a sandboxed schedule that lie under $HOME without being a
* known project or inside one: binding them read-write would hand the run
* whatever they hold.
* The add-dirs of a sandboxed schedule that the wrapper must not bind: any at
* or inside a `.claude` or `.git` directory, and any under $HOME that is
* neither a known project nor inside one. A relative one is taken from the
* schedule's directory, `baseDir`. Each is judged both as spelled
* (normalised) and by its real path.
* see docs/sandbox.md ("Additional directories")
*/
function refusedScheduleBinds(addDirs, knownProjects, home) {
function scheduleBindRefusals(addDirs, knownProjects, home, baseDir = process.cwd()) {
const inside = (p, dir) => p === dir || p.startsWith(dir + path.sep);
const homeDir = path.resolve(home);
return addDirs.filter((dir) => {
const p = path.resolve(dir);
if (!inside(p, homeDir)) return false;
for (const project of knownProjects) {
if (inside(p, path.resolve(project))) return false;
const homeDir = canonicalPath(home);
const projects = knownProjects.map(canonicalPath);
const protectedName = (p) => p.split(path.sep).some(c => c === '.claude' || c === '.git');
const refusals = [];
for (const dir of addDirs) {
const lexical = path.resolve(baseDir, dir);
const real = canonicalPath(lexical);
if (protectedName(lexical) || protectedName(real)) {
refusals.push({ dir, reason: 'at or inside a .claude or .git directory' });
} else if (inside(real, homeDir) && !projects.some(project => inside(real, project))) {
refusals.push({ dir, reason: 'under the home directory and not a Switchboard project' });
}
return true;
});
}
return refusals;
}

function refusedScheduleBinds(addDirs, knownProjects, home, baseDir) {
return scheduleBindRefusals(addDirs, knownProjects, home, baseDir).map(r => r.dir);
}

/**
Expand Down Expand Up @@ -508,4 +537,4 @@ function startScheduler(log, runCommand, { resumeSource, stateDir, projects } =
};
}

module.exports = { parseFrontmatter, cronMatches, scanSchedules, startScheduler, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry };
module.exports = { parseFrontmatter, cronMatches, scanSchedules, startScheduler, createScheduleSession, buildScheduleCommand, claimScheduleMinute, initialScheduleProjects, refusedScheduleBinds, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry };
65 changes: 58 additions & 7 deletions scripts/claude-sandbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,71 @@ fi
# session was launched with the wrong working directory. Fail closed and say so:
# a sandbox that silently hands out the whole home directory is worse than none,
# because the user believes they are protected.
for d in ${RW_DIRS[@]+"${RW_DIRS[@]}"}; do
_bad=""
case "$d" in
/) _bad="the filesystem root" ;;
# The forms of a path the checks below compare: as spelled but normalised
# (.. and trailing slashes resolved), and with every link followed. An empty
# answer from either is a refusal, never a pass.
path_forms() {
local lexical real
lexical="$(realpath -m -s -- "$1" 2>/dev/null)" && [ -n "$lexical" ] &&
real="$(readlink -m -- "$1" 2>/dev/null)" && [ -n "$real" ] || return 1
printf '%s\n%s\n' "$lexical" "$real"
}

has_protected_component() {
case "/$1/" in
*/.claude/*|*/.git/*) return 0 ;;
esac
case "$HOME" in
"$d") _bad="\$HOME itself" ;;
"$d"/*) _bad="a parent of \$HOME" ;;
return 1
}

# A cwd inside a .claude or .git is legitimate only below .claude/worktrees: the
# part after that, and the part before it, must not hold another one.
cwd_in_protected_dir() {
local form="$1"
case "$form/" in
*/.claude/worktrees/*)
has_protected_component "${form%%/.claude/worktrees/*}" && return 0
has_protected_component "${form#*/.claude/worktrees/}" && return 0
return 1 ;;
esac
has_protected_component "$form"
}

_home_forms="$(path_forms "$HOME")" || fail "refusing to launch: cannot resolve \$HOME ($HOME)."
for d in ${RW_DIRS[@]+"${RW_DIRS[@]}"}; do
_d_forms="$(path_forms "$d")" || fail "refusing to bind '$d' — its real path cannot be resolved."
_bad=""
while IFS= read -r _df; do
[ "$_df" = / ] && _bad="the filesystem root"
while IFS= read -r _hf; do
case "$_hf/" in
"$_df/") _bad="\$HOME itself" ;;
"$_df"/*) [ -n "$_bad" ] || _bad="a parent of \$HOME" ;;
esac
done <<<"$_home_forms"
done <<<"$_d_forms"
if [ -n "$_bad" ]; then
fail "refusing to bind '$d' — it is $_bad, so the sandbox would expose everything it is meant to hide. Expected a project directory; the session's working directory is '$PWD'."
fi
done

# see docs/sandbox.md ("Additional directories")
_pwd_forms="$(path_forms "$PWD")" || fail "refusing to launch: cannot resolve the working directory '$PWD'."
while IFS= read -r _form; do
if cwd_in_protected_dir "$_form"; then
fail "refusing to launch in '$PWD' — it is at or inside a .claude or .git directory, which the sandbox keeps read-only. Only a worktree below .claude/worktrees is allowed."
fi
done <<<"$_pwd_forms"
for d in ${EXTRA_BINDS[@]+"${EXTRA_BINDS[@]}"}; do
[ -n "$d" ] || continue
_d_forms="$(path_forms "$d")" || fail "refusing to bind '$d' — its real path cannot be resolved."
while IFS= read -r _form; do
if has_protected_component "$_form"; then
fail "refusing to bind '$d' — it is at or inside a .claude or .git directory, which the sandbox keeps read-only. Bind the project directory instead."
fi
done <<<"$_d_forms"
done

# True when $1 is at or below one of the read-write state dirs.
under_rw_state() {
local candidate="$1/" d
Expand Down
122 changes: 121 additions & 1 deletion test/sandbox-wrapper.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,127 @@ test('sandbox wrapper: extra binds survive a newline in a path and missing ones
}
});

test('sandbox wrapper: resolves the real binary when "claude" is also a shell function', { skip: !LINUX && 'linux only' }, () => {
test('sandbox wrapper: refuses an extra bind at or under a .claude or .git, however the path is spelled', { skip: !LINUX && 'linux only' }, () => {
const rig = makeRig({ bwrapExit: 1 });
try {
fs.mkdirSync(path.join(rig.home, '.claude'));
const other = path.join(rig.root, 'other');
fs.mkdirSync(path.join(other, '.claude', 'commands'), { recursive: true });
fs.mkdirSync(path.join(other, '.git', 'hooks'), { recursive: true });
fs.mkdirSync(path.join(other, 'sub'));
fs.symlinkSync(path.join(other, '.claude'), path.join(rig.root, 'lnk'));
fs.symlinkSync(path.join(other, 'sub'), path.join(other, '.git', 'back'));

const refused = [
path.join(other, '.claude'),
path.join(other, '.claude') + '/',
path.join(other, '.claude', 'commands'),
path.join(other, '.claude', 'missing'),
path.join(other, '.git'),
path.join(other, '.git', 'hooks'),
path.join(other, 'sub', '..', '.claude'),
path.join(rig.root, 'lnk'),
path.join(rig.root, 'lnk', 'commands'),
path.join(other, 'sub', '.claude'),
path.join(other, '.git', 'back'),
];
for (const bind of refused) {
const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: bind });
assert.equal(status, 125, `${bind} must be refused`);
assert.match(stderr, /refusing to bind .* \.claude or \.git/, bind);
}
const allowed = [
other,
other + '/',
path.join(other, 'sub'),
path.join(other, '.claude', '..'),
path.join(other, '.claude-notes'),
path.join(other, 'x.git'),
];
for (const bind of allowed) {
const { stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: bind });
assert.doesNotMatch(stderr, /\.claude or \.git/, `${bind} must not be refused`);
}
} finally {
rig.cleanup();
}
});

test('sandbox wrapper: refuses a working directory at or inside a .claude or .git, except below .claude/worktrees', { skip: !LINUX && 'linux only' }, () => {
const rig = makeRig({ bwrapExit: 1 });
try {
fs.mkdirSync(path.join(rig.home, '.claude'));
const plain = path.join(rig.root, 'plain');
fs.mkdirSync(path.join(plain, '.claude', 'worktrees', 'w', '.claude'), { recursive: true });
fs.mkdirSync(path.join(plain, '.git', 'hooks'), { recursive: true });
fs.symlinkSync(path.join(plain, '.claude'), path.join(rig.root, 'lnk'));
const refused = [
path.join(plain, '.claude'),
path.join(plain, '.claude', 'commands'),
path.join(plain, '.git'),
path.join(plain, '.git', 'hooks'),
path.join(rig.root, 'lnk'),
path.join(plain, '.claude', 'worktrees', 'w', '.claude'),
path.join(plain, '.claude', 'worktrees', '..', 'commands'),
];
for (const cwd of refused) {
fs.mkdirSync(cwd, { recursive: true });
const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: plain }, cwd);
assert.equal(status, 125, `a session in ${cwd} must be refused`);
assert.match(stderr, /refusing to launch in .* \.claude or \.git/, cwd);
}
for (const cwd of [plain, path.join(plain, '.claude', 'worktrees', 'w')]) {
const { stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: plain }, cwd);
assert.doesNotMatch(stderr, /refusing to launch in/, `${cwd} must not be refused`);
}
} finally {
rig.cleanup();
}
});

test('sandbox wrapper: refuses $HOME or a parent however the path is spelled, links included', { skip: !LINUX && 'linux only' }, () => {
const rig = makeRig({ bwrapExit: 1 });
try {
fs.mkdirSync(path.join(rig.home, '.claude'));
fs.mkdirSync(path.join(rig.home, 'sub'));
fs.symlinkSync(rig.home, path.join(rig.root, 'homelink'));
fs.symlinkSync(rig.root, path.join(rig.proj, 'rootlink'));
const refused = [
rig.home + '/',
rig.home + '/.',
path.join(rig.home, 'sub', '..'),
path.join(rig.root, 'homelink'),
rig.root + '/',
path.join(rig.proj, 'rootlink'),
path.join(rig.home, '..') + '/',
];
for (const bind of refused) {
const { status, stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: bind });
assert.equal(status, 125, `${bind} must be refused`);
assert.match(stderr, /refusing to bind .* (\$HOME itself|a parent of \$HOME)/, bind);
}
const { stderr } = rig.run(['--version'], { SWITCHBOARD_SANDBOX_BINDS: path.join(rig.home, 'sub') });
assert.doesNotMatch(stderr, /refusing to bind/, 'a directory below $HOME is not $HOME');
} finally {
rig.cleanup();
}
});

test('sandbox wrapper: a path whose forms cannot be resolved is refused, not passed', { skip: !LINUX && 'linux only' }, () => {
const rig = makeRig({ bwrapExit: 1 });
try {
fs.mkdirSync(path.join(rig.home, '.claude'));
fs.writeFileSync(path.join(rig.root, 'bin', 'realpath'), '#!/usr/bin/env bash\nexit 0\n');
fs.chmodSync(path.join(rig.root, 'bin', 'realpath'), 0o755);
const { status, stderr } = rig.run(['--version']);
assert.equal(status, 125);
assert.match(stderr, /cannot be resolved|cannot resolve/);
} finally {
rig.cleanup();
}
});

test('sandbox wrapper: resolves the real binary when "claude" is also a shell function',{ skip: !LINUX && 'linux only' }, () => {
const rig = makeRig({ bwrapExit: 1 });
try {
fs.mkdirSync(path.join(rig.home, '.claude'));
Expand Down
Loading
Loading