Skip to content
Open
43 changes: 43 additions & 0 deletions src/adapters/backend/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,49 @@ export function localSandboxApplies(
return true;
}

/**
* The worker's file-sandbox request decision, as ONE shared pure predicate.
*
* worker.ts computes `sandboxRequested` with this exact shape; the auto-worktree
* fail-closed gate (services/default-worktree.ts) reuses it so the two can never
* drift again — the riff exemption once only existed in the worker, which let a
* sandboxed riff bot fall back to the real default dir on a worktree failure (a
* sandbox escape), and the mojo backend (#803) then repeated the SAME drift for a
* provably-remote mojo session.
*
* Shape:
* localSandboxApplies(backend, mojoConfig) && (sandbox || readIsolation || BOTMUX_SANDBOX)
*
* - The REMOTE exemption (riff / provably-remote mojo) wraps the WHOLE union: a
* remote backend has no local CLI process, so even BOTMUX_SANDBOX=1 must not
* engage a local sandbox there.
* - mojo is NOT exempt by name alone: only a PROVABLY remote mojo session is
* (see localSandboxApplies / isMojoFullyRemote — cloud on, localDaemon off,
* no wrapperCli, no unprovable env). A local mojo session stays fail-closed.
* - The no-transport arm was REMOVED: forkWorker no longer force-isolates a
* no-transport session (#899 dropped the forced readIsolation), so the arm
* that mirrored it here is dead — the gate must not be stricter than the
* worker it tracks.
*/
export function localSandboxRequested(input: {
backendType: string;
mojoConfig?: {
cloud?: boolean;
localDaemon?: boolean;
wrapperCli?: string;
jwtEnv?: string;
env?: Record<string, string>;
};
sandbox?: boolean;
readIsolation?: boolean;
envSandboxEnabled?: boolean;
}): boolean {
if (!localSandboxApplies(input.backendType, input.mojoConfig)) return false;
return input.sandbox === true
|| input.readIsolation === true
|| input.envSandboxEnabled === true;
}


/** Top-level dirs that are symlinks on usrmerge distros (/bin → usr/bin …) —
* replicated inside the tmpfs root so `#!/bin/sh` etc. resolve. */
Expand Down
112 changes: 79 additions & 33 deletions src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { withBotTurnMutation } from './bot-turn-mutation-gate.js';
import { recordQuarantinedLauncherEnvKeys } from './mojo-launcher-env-quarantine.js';
import { freezeMojoIdentityForSession } from './mojo-session-identity.js';
import { getBot, getAllBots, loadBotConfigs, resolveBrandLabel, getLoadedConfigPath, getLoadedConfigProvenance, resolveUsageDisplay } from '../bot-registry.js';
import type { BotConfig } from '../bot-registry.js';
import { RestartCoordinator, type RestartObserver } from './restart-coordinator.js';
import { runtimeBuildIdentity } from '../utils/runtime-build-id.js';
import { scrubWorkflowWorkerEnv } from '../utils/child-env.js';
Expand Down Expand Up @@ -8077,6 +8078,62 @@ export function resolveQuarantinedForkPlan(
* staged behind an ACK, routed through a live owner, or spawn-deferred during
* device isolation — returns `true`.
*/
/**
* Freeze the worker's sandbox DECISION INPUTS on the session record — the SAME
* fields forkWorker's SpawnOpts consumes (`sandbox` / `readIsolation` + the
* path lists). Called at TWO edges:
* - pending-session establishment (runAutoWorktreeCommit), BEFORE any git /
* notice await, so the auto-worktree fail-closed gate and the later fork
* consume ONE snapshot; and
* - forkWorker itself (below), preserving the original "recorded at creation"
* semantics for sessions that never went through a pendingRepo phase.
*
* Without the early freeze, a dashboard `PUT /api/bot-sandbox` toggle landing
* while the worktree build is in flight made the gate degrade on the OLD value
* while the fork adopted the NEW one: the gate allowed the fallback to the
* real default dir and the worker engaged the local sandbox there — the exact
* write-escape the fail-closed gate exists to prevent. Re-reading live config
* only after the fallback would still leave the recheck→fork await window, so
* the decision is frozen ONCE, here, and both consumers read the session.
*
* Idempotent: an already-frozen session (`sandbox !== undefined`) keeps its
* recorded decision — a historical session is never retroactively re-frozen
* from a toggled live flag. A `resume` session pre-dating the field stays NOT
* sandboxed (same as the fork-time freeze this replaces), and its
* `readIsolation` stays undefined so the SpawnOpts live-config fallback
* preserves the legacy restore behavior.
*/
export function freezeSessionSandboxDecision(
ds: DaemonSession,
botCfg: Pick<BotConfig, 'sandbox' | 'sandboxPaths' | 'sandboxHidePaths' | 'sandboxReadonlyPaths' | 'sandboxNetwork' | 'readIsolation'>,
opts: { resume?: boolean } = {},
): void {
let mutated = false;
if (ds.session.sandbox === undefined) {
if (!opts.resume) {
ds.session.sandbox = botCfg.sandbox === true;
ds.session.sandboxPaths = botCfg.sandboxPaths;
ds.session.sandboxHidePaths = botCfg.sandboxHidePaths ?? [];
ds.session.sandboxReadonlyPaths = botCfg.sandboxReadonlyPaths ?? [];
ds.session.sandboxNetwork = botCfg.sandboxNetwork !== false;
} else {
ds.session.sandbox = false;
ds.session.sandboxHidePaths = [];
ds.session.sandboxReadonlyPaths = [];
ds.session.sandboxNetwork = true;
}
mutated = true;
}
// readIsolation is frozen symmetrically: forkWorker's SpawnOpts reads the
// session value (with a live-config fallback ONLY for sessions persisted
// before this field existed), so the gate and the fork agree on it too.
if (ds.session.readIsolation === undefined && !opts.resume) {
ds.session.readIsolation = botCfg.readIsolation === true;
mutated = true;
}
if (mutated) sessionStore.updateSession(ds.session);
}

export function forkWorker(
ds: DaemonSession,
promptInput: string | CliTurnPayload,
Expand Down Expand Up @@ -8375,22 +8432,11 @@ export function forkWorker(
// restore — so toggling the live bot flag never retroactively (un)sandboxes a
// historical session. A brand-new session (resume=false) with no recorded
// decision adopts the live bot flag; a restore (resume=true) with no recorded
// decision predates the sandbox feature → stays NOT sandboxed.
if (ds.session.sandbox === undefined) {
if (!resume) {
ds.session.sandbox = botCfg.sandbox === true;
ds.session.sandboxPaths = botCfg.sandboxPaths;
ds.session.sandboxHidePaths = botCfg.sandboxHidePaths ?? [];
ds.session.sandboxReadonlyPaths = botCfg.sandboxReadonlyPaths ?? [];
ds.session.sandboxNetwork = botCfg.sandboxNetwork !== false;
} else {
ds.session.sandbox = false;
ds.session.sandboxHidePaths = [];
ds.session.sandboxReadonlyPaths = [];
ds.session.sandboxNetwork = true;
}
sessionStore.updateSession(ds.session);
}
// decision predates the sandbox feature, so it stays NOT sandboxed. A
// pendingRepo session was already frozen at its establishment
// (runAutoWorktreeCommit), so this is a no-op there and the fork consumes
// the SAME snapshot the auto-worktree gate consumed.
freezeSessionSandboxDecision(ds, botCfg, { resume });

// Reserve and durably publish the replacement lifetime before killing an
// existing worker. A failed reservation leaves the old worker untouched;
Expand Down Expand Up @@ -8774,23 +8820,23 @@ export function forkWorker(
// Per-bot local read isolation (enforced worker-side; the worker gates it).
// Sibling data needs no app-id enumeration: per-bot dirs are denied wholesale
// and per-bot session files by filename pattern (see buildV2DenyPaths).
// Opt-in only, driven purely by explicit per-bot `readIsolation`. A
// no-transport session (apiOnly bot OR HTTP virtual chat) is NO LONGER
// force-isolated: disk read scope now follows the owner's own sandbox config,
// symmetric with a normal chat session (unset/false → not isolated). Accepted
// trade-off: a no-transport session with no sandbox config can read the full
// bots.json / sibling BOT_HOME on disk; protecting sibling creds from lateral
// read on a multi-bot host now depends on the owner explicitly enabling
// sandbox/readIsolation, not on this force. Two adjacent boundaries are
// unchanged and independent: (1) this bot's own transport secret is still
// withheld from the CLI env (gated on larkTransportEnabled below), so a
// no-transport session cannot drive Botmux's own send path even though it can
// read the file; (2) mandatory device-credential isolation (worker.ts) still
// masks the device authority dir / enrolled creds on enrolled hosts. Full-file
// sandbox stays independently driven worker-side by sandboxRequested
// (cfg.sandbox || cfg.readIsolation || BOTMUX_SANDBOX=1); session.sandbox is
// frozen from botCfg.sandbox at create time, so "follow local sandbox" holds.
readIsolation: botCfg.readIsolation === true,
// FROZEN at session creation (freezeSessionSandboxDecision) — same snapshot
// the auto-worktree fail-closed gate consumes — so a dashboard readIsolation
// toggle landing while a worktree build is in flight can't diverge the gate
// from the fork. The live-config fallback only covers sessions persisted
// before the freeze field existed. A no-transport session (apiOnly bot OR
// HTTP virtual chat) is NOT force-isolated: disk read scope follows the
// owner's own config, symmetric with a normal chat session. Two adjacent
// boundaries are unchanged and independent: (1) this bot's own transport
// secret is still withheld from the CLI env (gated on larkTransportEnabled
// below), so a no-transport session cannot drive Botmux's own send path even
// though it can read the file; (2) mandatory device-credential isolation
// (worker.ts) still masks the device authority dir / enrolled creds on
// enrolled hosts. Full-file sandbox stays independently driven worker-side by
// sandboxRequested (cfg.sandbox || cfg.readIsolation || BOTMUX_SANDBOX=1);
// session.sandbox is frozen from botCfg.sandbox at create time, so "follow
// local sandbox" holds.
readIsolation: ds.session.readIsolation ?? botCfg.readIsolation === true,
readDenyExtraPaths: botCfg.readDenyExtraPaths ?? [],
// Identifies THIS daemon lifetime. Stamped onto isolated panes so the worker
// can tell a suspend→resume reattach (same boot id, still isolated) from a
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export const messages: Record<string, string> = {
'worktree.auto_creating': '🌿 Creating an isolated worktree for this session (includes a git fetch, may take a few seconds)…',
'worktree.auto_created': '🌿 Auto-created an isolated worktree for this session: `{path}`\nBranch `{branch}`, based on `{base}`. Your default directory is untouched.',
'worktree.auto_fallback': '⚠️ Could not create a worktree in the default directory `{dir}` ({error}); fell back to starting the session directly in the default directory.',
'worktree.auto_fail_closed': '⛔ File sandbox is enabled, but an isolated worktree could not be created in the default directory `{dir}` ({error}). To keep the agent\'s writes out of the real directory, this session was not started. Configure the default directory as a git repository and start again, or use /repo to pick a working directory manually.',
'worktree.err_not_git': 'default directory is not a git repository (or could not be confirmed)',
'cmd.skip.opened': '▶️ Session started (working dir: {cwd})',
'cmd.status.running': 'running',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ export const messages: Record<string, string> = {
'worktree.auto_creating': '🌿 正在为本会话创建独立 worktree(含 git fetch,可能需要几秒)…',
'worktree.auto_created': '🌿 已为本会话自动创建独立 worktree:`{path}`\n分支 `{branch}`,基于 `{base}`。原默认目录不受影响。',
'worktree.auto_fallback': '⚠️ 无法在默认目录 `{dir}` 创建 worktree({error}),已回退到直接在默认目录启动会话。',
'worktree.auto_fail_closed': '⛔ 文件沙盒已开启,但默认目录 `{dir}` 无法创建独立 worktree({error})。为避免 Agent 写入污染真实目录,本次会话未启动。请将默认目录配置为 git 仓库后重新发起,或使用 /repo 手动选择工作目录。',
'worktree.err_not_git': '默认目录不是 git 仓库(或暂时无法确认)',
'cmd.skip.opened': '▶️ 已直接开启会话(工作目录:{cwd})',
'cmd.status.running': '运行中',
Expand Down
Loading