botmux 3.5.0 → 3.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/backend/sandbox.d.ts +99 -279
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +424 -826
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/cli/codex.d.ts.map +1 -1
- package/dist/adapters/cli/codex.js +4 -0
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/adapters/cli/fs-policy.d.ts +244 -0
- package/dist/adapters/cli/fs-policy.d.ts.map +1 -0
- package/dist/adapters/cli/fs-policy.js +539 -0
- package/dist/adapters/cli/fs-policy.js.map +1 -0
- package/dist/adapters/cli/read-isolation.d.ts +14 -200
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +18 -477
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/bot-registry.d.ts +26 -23
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +16 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -18
- package/dist/cli.js.map +1 -1
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +16 -58
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +26 -27
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/passthrough-commands.d.ts.map +1 -1
- package/dist/core/passthrough-commands.js +1 -1
- package/dist/core/passthrough-commands.js.map +1 -1
- package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
- package/dist/core/plugins/mcp/gateway-installer.js +14 -2
- package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +90 -60
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/worker-pool.d.ts +24 -0
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +50 -6
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +6 -0
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +5 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +7 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +16 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +225 -6
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +7 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +38 -22
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +1 -44
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-AUKV7P4L.js +10 -0
- package/dist/dashboard-web/chunks/{chunk-QCPPKCX3.js → chunk-KPQAUX7V.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-WXPMNEYV.js → chunk-NRL6CVAS.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-EJDYUINZ.js → chunk-R7IAKAYX.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-H7P5UV4W.js → chunk-X2L65IOM.js} +6 -6
- package/dist/dashboard-web/chunks/{connectors-page-IR54AOAI.js → connectors-page-HDQFEXZI.js} +1 -1
- package/dist/dashboard-web/chunks/{groups-page-3P4TLUMF.js → groups-page-6DKXBYOM.js} +1 -1
- package/dist/dashboard-web/chunks/{insights-page-TDCZV56A.js → insights-page-NWARTJ6J.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-QWCWL3SX.js → monitor-room-AGX3XRJX.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-IOWFSNVC.js → monitoring-page-GGGIWN4C.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-5AUPEHZP.js → office-page-6SIVKSK3.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-QJMI6NLW.js → overview-page-FVR3NKAF.js} +1 -1
- package/dist/dashboard-web/chunks/{roles-page-WJVB52ET.js → roles-page-WE2YB7KN.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-RVRWHP4V.js → schedules-page-TB3I24XN.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-KOOOASIL.js +2 -0
- package/dist/dashboard-web/chunks/{settings-page-TNZEPC7V.js → settings-page-IEBR7TGN.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-7YT5TIX5.js → skills-page-3QAT2IWD.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-NJZKTU7A.js → team-federation-page-K4VVAVAK.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-NQ73UA2Q.js → v3-page-V6IOJR2B.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-DSM3MDMN.js → whiteboards-page-V6CGKRNX.js} +1 -1
- package/dist/dashboard-web/style.css +20 -3
- package/dist/dashboard.js +86 -1
- package/dist/dashboard.js.map +1 -1
- package/dist/global-config.d.ts.map +1 -1
- package/dist/global-config.js +4 -1
- package/dist/global-config.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +1 -19
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +1 -19
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +0 -13
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +0 -34
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +1 -30
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +36 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +80 -0
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +28 -3
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/services/sandbox-migration.d.ts +14 -0
- package/dist/services/sandbox-migration.d.ts.map +1 -0
- package/dist/services/sandbox-migration.js +85 -0
- package/dist/services/sandbox-migration.js.map +1 -0
- package/dist/services/sandbox-store.d.ts +28 -0
- package/dist/services/sandbox-store.d.ts.map +1 -1
- package/dist/services/sandbox-store.js +82 -0
- package/dist/services/sandbox-store.js.map +1 -1
- package/dist/types.d.ts +13 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/child-env.d.ts +2 -2
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +14 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/worker.js +417 -498
- package/dist/worker.js.map +1 -1
- package/dist/workflows/events/payloads.d.ts +56 -0
- package/dist/workflows/events/payloads.d.ts.map +1 -1
- package/dist/workflows/events/payloads.js +9 -0
- package/dist/workflows/events/payloads.js.map +1 -1
- package/dist/workflows/events/schema.d.ts +86 -0
- package/dist/workflows/events/schema.d.ts.map +1 -1
- package/dist/workflows/shared/sandbox-policy.d.ts +10 -1
- package/dist/workflows/shared/sandbox-policy.d.ts.map +1 -1
- package/dist/workflows/shared/sandbox-policy.js +12 -0
- package/dist/workflows/shared/sandbox-policy.js.map +1 -1
- package/dist/workflows/v3/bot-resolve.d.ts.map +1 -1
- package/dist/workflows/v3/bot-resolve.js +36 -0
- package/dist/workflows/v3/bot-resolve.js.map +1 -1
- package/dist/workflows/v3/contract.d.ts +8 -0
- package/dist/workflows/v3/contract.d.ts.map +1 -1
- package/dist/workflows/v3/contract.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-O376WSER.js +0 -5
- package/dist/dashboard-web/chunks/sessions-page-KWLGBX6G.js +0 -3
- package/dist/services/sandbox-land.d.ts +0 -45
- package/dist/services/sandbox-land.d.ts.map +0 -1
- package/dist/services/sandbox-land.js +0 -394
- package/dist/services/sandbox-land.js.map +0 -1
package/dist/worker.js
CHANGED
|
@@ -18,7 +18,8 @@ import { atomicWriteFileSync } from './utils/atomic-write.js';
|
|
|
18
18
|
import { join, basename, dirname, delimiter } from 'node:path';
|
|
19
19
|
import { homedir, tmpdir } from 'node:os';
|
|
20
20
|
import { spawnSync } from 'node:child_process';
|
|
21
|
-
import {
|
|
21
|
+
import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
|
|
22
|
+
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields } from './adapters/cli/fs-policy.js';
|
|
22
23
|
import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
|
|
23
24
|
import { readProcessStartIdentity } from './core/session-marker.js';
|
|
24
25
|
import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
|
|
@@ -40,7 +41,7 @@ import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/
|
|
|
40
41
|
import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
|
|
41
42
|
import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
|
|
42
43
|
import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
|
|
43
|
-
import { loadBotConfigs } from './bot-registry.js';
|
|
44
|
+
import { loadBotConfigs, resolveBrandLabel } from './bot-registry.js';
|
|
44
45
|
import { readGlobalConfig } from './global-config.js';
|
|
45
46
|
import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
|
|
46
47
|
import { readPlatformBinding } from './platform/binding.js';
|
|
@@ -65,6 +66,7 @@ import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByCha
|
|
|
65
66
|
import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
|
|
66
67
|
import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
|
|
67
68
|
import { baselineJsonlCursor } from './services/jsonl-cursor.js';
|
|
69
|
+
import { fileURLToPath } from 'node:url';
|
|
68
70
|
import { createServer as createHttpServer } from 'node:http';
|
|
69
71
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
70
72
|
import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
|
|
@@ -92,7 +94,7 @@ import { isObserveBackend } from './adapters/backend/types.js';
|
|
|
92
94
|
import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
|
|
93
95
|
import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
|
|
94
96
|
import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
|
|
95
|
-
import {
|
|
97
|
+
import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
|
|
96
98
|
import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
|
|
97
99
|
import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
|
|
98
100
|
import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
|
|
@@ -6364,81 +6366,20 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6364
6366
|
// so a fork inherits the whole submit-confirm + bridge-fallback machinery.
|
|
6365
6367
|
let claudeDataDir = cliAdapter.claudeDataDir;
|
|
6366
6368
|
let effectiveReadyHookInstall = cliAdapter.hookInstall;
|
|
6367
|
-
//
|
|
6368
|
-
//
|
|
6369
|
-
//
|
|
6370
|
-
//
|
|
6371
|
-
//
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
!!process.env.SESSION_DATA_DIR;
|
|
6376
|
-
if (claudeDataDir && willFileSandbox) {
|
|
6377
|
-
const redirected = sandboxedClaudeDataDir(cfg.sessionId, claudeDataDir, {
|
|
6378
|
-
sourceWorkingDir: cfg.workingDir,
|
|
6379
|
-
dataDir: process.env.SESSION_DATA_DIR,
|
|
6380
|
-
});
|
|
6381
|
-
log(`[sandbox] redirecting Claude bridge dataDir → overlay upper: ${redirected}`);
|
|
6382
|
-
claudeDataDir = redirected;
|
|
6383
|
-
}
|
|
6384
|
-
// v2 read isolation: relocate the CLI's data root into the per-bot BOT_HOME
|
|
6385
|
-
// (`<BOTMUX_HOME>/bots/<appId>/{claude,codex}`) so each bot's transcripts/memory
|
|
6386
|
-
// land in its OWN (Seatbelt-allowed) dir, NOT the shared/global ~/.claude|~/.codex
|
|
6387
|
-
// (which v2 denies wholesale). Decided EARLY — like willFileSandbox above — so
|
|
6388
|
-
// every JSONL/bridge/resume path below already targets the per-bot dir. The
|
|
6389
|
-
// matching CLAUDE_CONFIG_DIR/CODEX_HOME env, per-bot provisioning and Seatbelt
|
|
6390
|
-
// wrapper are applied at spawn time further down. This gate is the SINGLE
|
|
6391
|
-
// decision point: configured-but-unenforceable fail-closes HERE (never run a
|
|
6392
|
-
// session unisolated that asked for isolation).
|
|
6393
|
-
// UNIFIED file sandbox: read isolation engages with the same toggle as write
|
|
6394
|
-
// isolation. On darwin the legacy standalone `readIsolation` flag ALSO engages it
|
|
6395
|
-
// (Seatbelt can read-deny without a write overlay). On Linux read isolation RIDES
|
|
6396
|
-
// the bwrap sandbox (its masks go into the same bwrap plan), so it requires the
|
|
6397
|
-
// sandbox to be on — hence the trigger is the sandbox toggle there, never the bare
|
|
6398
|
-
// legacy flag (which would redirect the data dir but apply no masks → a hole).
|
|
6399
|
-
// riff runs in its own REMOTE sandbox with no local CLI process — every
|
|
6400
|
-
// local isolation flavor (Linux bwrap, macOS Seatbelt write-sandbox, read
|
|
6401
|
-
// isolation incl. the legacy fail-closed flag) is meaningless for it and
|
|
6402
|
-
// must be bypassed on ALL platforms, or a sandbox/readIsolation-enabled bot
|
|
6403
|
-
// bricks the moment it switches to riff.
|
|
6404
|
-
const riffRemoteBackend = effectiveBackendType === 'riff';
|
|
6369
|
+
// ── UNIFIED file sandbox (fs-policy, 2026-07-16 refactor) ──
|
|
6370
|
+
// ONE toggle, BOTH platforms, identical three-tier deny-by-default semantics
|
|
6371
|
+
// (adapters/cli/fs-policy.ts). Legacy `readIsolation` is auto-migrated to
|
|
6372
|
+
// `sandbox` at daemon startup; honored here too for an unmigrated read-only
|
|
6373
|
+
// BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process —
|
|
6374
|
+
// local confinement is meaningless there and must be bypassed on ALL
|
|
6375
|
+
// platforms, or a sandbox-enabled bot bricks the moment it switches to riff.
|
|
6376
|
+
const riffRemoteBackend = !localSandboxApplies(effectiveBackendType);
|
|
6405
6377
|
if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) {
|
|
6406
|
-
log('Sandbox
|
|
6407
|
-
}
|
|
6408
|
-
const
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
// FAIL-CLOSED semantics: you asked for read isolation → refuse to start without it.
|
|
6412
|
-
const explicitLegacyReadIso = process.platform === 'darwin' && cfg.readIsolation === true && !riffRemoteBackend;
|
|
6413
|
-
const readIsolationGate = evaluateReadIsolationGate({
|
|
6414
|
-
configured: wantsFileSandbox || explicitLegacyReadIso,
|
|
6415
|
-
adapterSupports: cliAdapter.supportsReadIsolation === true,
|
|
6416
|
-
wrapperCliSet: !!cfg.wrapperCli,
|
|
6417
|
-
platform: process.platform,
|
|
6418
|
-
sessionDataDirSet: !!process.env.SESSION_DATA_DIR,
|
|
6419
|
-
});
|
|
6420
|
-
// Fail-closed ONLY for the explicit legacy flag. Under the UNIFIED sandbox toggle
|
|
6421
|
-
// read isolation is BEST-EFFORT: a CLI/platform that can't enforce the read-deny
|
|
6422
|
-
// (e.g. a non-supporting adapter) still runs WRITE-isolated — it just doesn't get
|
|
6423
|
-
// the read masks. Never brick a sandboxed session just because read-iso is N/A.
|
|
6424
|
-
if (readIsolationGate.failClosedReason && explicitLegacyReadIso) {
|
|
6425
|
-
throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${readIsolationGate.failClosedReason}`);
|
|
6426
|
-
}
|
|
6427
|
-
const willReadIsolate = readIsolationGate.enabled;
|
|
6428
|
-
// macOS FILE SANDBOX — the Seatbelt WRITE-isolation twin of the Linux bwrap
|
|
6429
|
-
// overlay. On darwin, `sandbox: true` (or the global sandboxEnabled()) confines
|
|
6430
|
-
// WRITES via the same `sandbox-exec` wrapper as read isolation instead of bwrap
|
|
6431
|
-
// (Linux-only). Reads stay open, matching the Linux "read-all / write-isolated"
|
|
6432
|
-
// model; if read isolation is ALSO on, its read-deny set layers into the SAME
|
|
6433
|
-
// profile. Like bwrap this is FAIL-SAFE: a requested sandbox that can't be
|
|
6434
|
-
// established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the
|
|
6435
|
-
// spawn site below, never a silent unconfined run.
|
|
6436
|
-
const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox;
|
|
6437
|
-
if (willWriteSandbox && !process.env.SESSION_DATA_DIR) {
|
|
6438
|
-
throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: missing SESSION_DATA_DIR`);
|
|
6439
|
-
}
|
|
6440
|
-
const fullIsolationCoversCredentials = !riffRemoteBackend && ((process.platform === 'darwin' && (willReadIsolate || willWriteSandbox))
|
|
6441
|
-
|| (process.platform === 'linux' && wantsFileSandbox));
|
|
6378
|
+
log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local sandbox bypassed');
|
|
6379
|
+
}
|
|
6380
|
+
const sandboxRequested = !riffRemoteBackend
|
|
6381
|
+
&& (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
|
|
6382
|
+
const fullIsolationCoversCredentials = sandboxRequested;
|
|
6442
6383
|
let credentialMechanismAvailable = true;
|
|
6443
6384
|
let credentialMechanismExecutable;
|
|
6444
6385
|
if (mandatoryCredentialIsolation && !riffRemoteBackend && !fullIsolationCoversCredentials) {
|
|
@@ -6471,16 +6412,11 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6471
6412
|
const credentialOnlySeatbelt = credentialIsolationGate.mode === 'seatbelt';
|
|
6472
6413
|
const credentialOnlyBwrap = credentialIsolationGate.mode === 'bwrap';
|
|
6473
6414
|
const appliedIsolationCapabilities = [];
|
|
6474
|
-
if (credentialBoundaryActive
|
|
6475
|
-
|| willReadIsolate
|
|
6476
|
-
|| (process.platform === 'linux' && wantsFileSandbox)) {
|
|
6415
|
+
if (credentialBoundaryActive || sandboxRequested) {
|
|
6477
6416
|
appliedIsolationCapabilities.push('credential');
|
|
6478
6417
|
}
|
|
6479
|
-
if (
|
|
6480
|
-
appliedIsolationCapabilities.push('read');
|
|
6481
|
-
if (willWriteSandbox || (process.platform === 'linux' && wantsFileSandbox)) {
|
|
6482
|
-
appliedIsolationCapabilities.push('write');
|
|
6483
|
-
}
|
|
6418
|
+
if (sandboxRequested)
|
|
6419
|
+
appliedIsolationCapabilities.push('read', 'write');
|
|
6484
6420
|
currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
|
|
6485
6421
|
const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
|
|
6486
6422
|
?? join(defaultBotmuxHome, 'data');
|
|
@@ -6495,8 +6431,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6495
6431
|
// Preserve that recorded target for compatibility unless this incarnation
|
|
6496
6432
|
// requires an isolation/MCP boundary that only a Botmux-owned session can
|
|
6497
6433
|
// safely provide. Fresh tasks use distinct agents in one machine-wide host.
|
|
6498
|
-
reuseRecordedHerdrTarget: !
|
|
6499
|
-
&& !willWriteSandbox
|
|
6434
|
+
reuseRecordedHerdrTarget: !sandboxRequested
|
|
6500
6435
|
&& !hasMcpRuntimeEntries,
|
|
6501
6436
|
});
|
|
6502
6437
|
let selectedBackend = selectBackend();
|
|
@@ -6504,6 +6439,18 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6504
6439
|
isPipeMode = selectedBackend.isPipeMode;
|
|
6505
6440
|
isZellijMode = selectedBackend.isZellijMode;
|
|
6506
6441
|
backend = selectedBackend.backend;
|
|
6442
|
+
// BOT_HOME CLI-data redirect: sandboxed bots whose adapter supports it keep
|
|
6443
|
+
// their CLI data (transcripts/memory/auth) in their own BOT_HOME via
|
|
6444
|
+
// CLAUDE_CONFIG_DIR/CODEX_HOME — under deny-by-default the global ~/.claude|
|
|
6445
|
+
// ~/.codex are simply not exposed. Best-effort: a non-supporting adapter
|
|
6446
|
+
// keeps its REAL data dirs instead, which the policy exposes readWrite (the
|
|
6447
|
+
// sandbox itself still applies). Decided EARLY so every JSONL/bridge/resume
|
|
6448
|
+
// path below already targets the right dir. wrapperCli strips spawn args, so
|
|
6449
|
+
// the redirect (and its env) can't be guaranteed there → not redirected.
|
|
6450
|
+
const willRedirectCliData = sandboxRequested
|
|
6451
|
+
&& cliAdapter.supportsReadIsolation === true
|
|
6452
|
+
&& !cfg.wrapperCli
|
|
6453
|
+
&& !!process.env.SESSION_DATA_DIR;
|
|
6507
6454
|
// Bump the CLI-lifetime nonce: any stuck-warning card posted by a previous
|
|
6508
6455
|
// backend instance (within this same worker) must not inject keys into the
|
|
6509
6456
|
// new one. The worker echoes this nonce in stuck_warning and re-checks it on
|
|
@@ -6527,7 +6474,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6527
6474
|
}
|
|
6528
6475
|
let isolationBotHome;
|
|
6529
6476
|
let isolatedCodexHome;
|
|
6530
|
-
if (
|
|
6477
|
+
if (willRedirectCliData) {
|
|
6531
6478
|
isolationBotHome = ownBotHome;
|
|
6532
6479
|
const isClaudeFam = !!claudeDataDir;
|
|
6533
6480
|
if (isClaudeFam)
|
|
@@ -6579,11 +6526,11 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6579
6526
|
// survive a daemon restart still running a CLI that may NOT be isolated (e.g.
|
|
6580
6527
|
// spawned before isolation was enabled, or by an old build). Isolation is only
|
|
6581
6528
|
// injectable at spawn time, so reattaching such a pane would silently run
|
|
6582
|
-
// unisolated. We stamp a
|
|
6583
|
-
//
|
|
6584
|
-
// so the probe below sees no pane and we cold-spawn fresh isolated. A
|
|
6585
|
-
//
|
|
6586
|
-
//
|
|
6529
|
+
// unisolated. We stamp a boot-id marker when we spawn an isolated pane; if this
|
|
6530
|
+
// isolated bot's existing pane is NOT stamped by THIS daemon lifetime, kill it
|
|
6531
|
+
// so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
|
|
6532
|
+
// this lifetime (suspend→resume) keeps its marker → reattaches normally (it is
|
|
6533
|
+
// still the isolated process). This lets isolated bots use tmux/zellij/herdr.
|
|
6587
6534
|
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
|
|
6588
6535
|
const persistentTarget = selectedBackend.persistentBackendTarget;
|
|
6589
6536
|
const paneLive = persistentTarget
|
|
@@ -6835,81 +6782,39 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6835
6782
|
lastSpawnDeferInitialPrompt = deferInitialPrompt;
|
|
6836
6783
|
kiroSessionIdCaptureArmed = cfg.cliId === 'kiro-cli' && !effectiveCliSessionId && !willReattachPersistent;
|
|
6837
6784
|
kiroSessionIdCaptureBuffer = '';
|
|
6838
|
-
//
|
|
6839
|
-
//
|
|
6840
|
-
//
|
|
6841
|
-
//
|
|
6842
|
-
//
|
|
6843
|
-
|
|
6844
|
-
// pane spawned isolated (still the confined process).
|
|
6845
|
-
let readIsolationCtx;
|
|
6846
|
-
if (willReadIsolate) {
|
|
6847
|
-
const sessionDataDir = process.env.SESSION_DATA_DIR;
|
|
6848
|
-
readIsolationCtx = {
|
|
6849
|
-
homeDir: homedir(),
|
|
6850
|
-
botmuxHome: dirname(sessionDataDir),
|
|
6851
|
-
defaultBotmuxHome: join(homedir(), '.botmux'),
|
|
6852
|
-
sessionDataDir,
|
|
6853
|
-
currentAppId: cfg.larkAppId,
|
|
6854
|
-
currentSessionId: cfg.sessionId,
|
|
6855
|
-
extraDenyPaths: cfg.readDenyExtraPaths,
|
|
6856
|
-
};
|
|
6857
|
-
readIsolationOriginCapabilityFile = process.platform === 'darwin'
|
|
6858
|
-
? managedOriginCapabilityPath(sessionDataDir, cfg.sessionId)
|
|
6859
|
-
: null;
|
|
6860
|
-
// Replace any legacy child-planted symlink before Seatbelt canonicalizes
|
|
6861
|
-
// carve-outs. A failure is fatal: spawning with a missing/ambiguous
|
|
6862
|
-
// capability transport would either break all IPC or reopen an attacker-
|
|
6863
|
-
// selected realpath in the generated profile.
|
|
6864
|
-
if (readIsolationOriginCapabilityFile) {
|
|
6865
|
-
publishSandboxRelayCapability({ failClosed: true });
|
|
6866
|
-
}
|
|
6867
|
-
// Write this bot's OWN send-credential into its BOT_HOME (the same per-bot
|
|
6868
|
-
// private storage as its CLI data; siblings' BOT_HOMEs are whole-denied).
|
|
6869
|
-
// `botmux send` reads the secret from here instead of bots.json — so the
|
|
6870
|
-
// secret never travels via env/argv (no cross-bot `ps aux` leak) and the CLI
|
|
6871
|
-
// never needs to escape the sandbox.
|
|
6785
|
+
// Sandboxed sessions: write this bot's OWN send-credential into its BOT_HOME.
|
|
6786
|
+
// `botmux send` reads the secret from here instead of bots.json (which
|
|
6787
|
+
// deny-by-default never exposes) — the secret never travels via env/argv (no
|
|
6788
|
+
// cross-bot `ps aux` leak) and the CLI never needs to escape the sandbox.
|
|
6789
|
+
// The worker itself runs on the host (NOT sandboxed) and keeps full access.
|
|
6790
|
+
if (sandboxRequested && process.env.SESSION_DATA_DIR) {
|
|
6872
6791
|
try {
|
|
6873
|
-
const credPath = sendCredFilePath(
|
|
6792
|
+
const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId);
|
|
6874
6793
|
mkdirSync(dirname(credPath), { recursive: true });
|
|
6875
6794
|
writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand }), { mode: 0o600 });
|
|
6876
6795
|
}
|
|
6877
6796
|
catch (e) {
|
|
6878
|
-
log(`[
|
|
6797
|
+
log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
|
|
6879
6798
|
}
|
|
6880
6799
|
}
|
|
6881
|
-
//
|
|
6882
|
-
//
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
};
|
|
6896
|
-
const sandboxHome = canonicalSandboxPath(homedir());
|
|
6897
|
-
writeSandboxRules = buildWriteSandboxRules({
|
|
6898
|
-
homeDir: sandboxHome,
|
|
6899
|
-
botmuxHome: canonicalSandboxPath(dirname(sessionDataDir)),
|
|
6900
|
-
defaultBotmuxHome: canonicalSandboxPath(join(homedir(), '.botmux')),
|
|
6901
|
-
sessionDataDir: canonicalSandboxPath(sessionDataDir),
|
|
6902
|
-
workingDir: cfg.workingDir,
|
|
6903
|
-
currentAppId: cfg.larkAppId,
|
|
6904
|
-
// TMPDIR on macOS resolves under /private/var/folders (already allowed), but
|
|
6905
|
-
// pass it explicitly so a customized TMPDIR is covered too.
|
|
6906
|
-
extraWritePaths: process.env.TMPDIR ? [process.env.TMPDIR] : [],
|
|
6907
|
-
});
|
|
6908
|
-
}
|
|
6800
|
+
// In the file sandbox, canonicalize the workingDir handed to buildArgs. CLIs
|
|
6801
|
+
// that pass it as a chdir arg (codex/traex `-C`) fail otherwise on a symlinked
|
|
6802
|
+
// $HOME host: the lexical /home/u/... prefix doesn't exist inside the bwrap
|
|
6803
|
+
// root (only canonical /data00/... is bound), so the CLI's chdir/readlink
|
|
6804
|
+
// ENOENTs and it aborts with "No such file or directory (os error 2)". Off
|
|
6805
|
+
// sandbox this is a no-op (same dir); best-effort if unresolvable.
|
|
6806
|
+
const buildArgsWorkingDir = sandboxRequested
|
|
6807
|
+
? (() => { try {
|
|
6808
|
+
return realpathSync(cfg.workingDir);
|
|
6809
|
+
}
|
|
6810
|
+
catch {
|
|
6811
|
+
return cfg.workingDir;
|
|
6812
|
+
} })()
|
|
6813
|
+
: cfg.workingDir;
|
|
6909
6814
|
const args = cliAdapter.buildArgs({
|
|
6910
6815
|
sessionId: effectiveAdapterSessionId,
|
|
6911
6816
|
resume: effectiveResume,
|
|
6912
|
-
workingDir:
|
|
6817
|
+
workingDir: buildArgsWorkingDir,
|
|
6913
6818
|
resumeSessionId: effectiveCliSessionId,
|
|
6914
6819
|
initialPrompt: preparedInitialPrompt,
|
|
6915
6820
|
botName: cfg.botName,
|
|
@@ -6919,12 +6824,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
6919
6824
|
model: ttadkGateway ? undefined : cfg.model,
|
|
6920
6825
|
disableCliBypass: cfg.disableCliBypass === true,
|
|
6921
6826
|
skillPluginDir: cfg.skillPluginDir,
|
|
6922
|
-
readIsolation:
|
|
6923
|
-
// Set (as a pair) by the init handler when hybrid RPC input is engaged;
|
|
6924
|
-
// makes buildArgs launch `codex --remote <ws> resume <thread>` instead of
|
|
6925
|
-
// the normal paste-mode codex. Undefined for every other bot/CLI.
|
|
6926
|
-
remoteWsUrl,
|
|
6927
|
-
remoteThreadId,
|
|
6827
|
+
readIsolation: willRedirectCliData,
|
|
6928
6828
|
});
|
|
6929
6829
|
// Pi's deferred long-first-prompt command is implemented by a session-scoped
|
|
6930
6830
|
// extension. Keep its launch args across owned process restarts while the
|
|
@@ -7023,15 +6923,18 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7023
6923
|
delete childEnv.BOTMUX_CHAT_TYPE;
|
|
7024
6924
|
childEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
|
|
7025
6925
|
childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
|
|
7026
|
-
//
|
|
7027
|
-
//
|
|
7028
|
-
//
|
|
7029
|
-
//
|
|
7030
|
-
//
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
6926
|
+
// This bot's resolved brandLabel template, injected so a SANDBOXED `botmux
|
|
6927
|
+
// send` renders the role-name footer without reading bots.json (deny-by-
|
|
6928
|
+
// default → EPERM → role footer would silently fall back to the default
|
|
6929
|
+
// [botmux] label). resolveBrandLabel honours this env first (gated on the
|
|
6930
|
+
// own appId). Only set the key when a brandLabel is configured (present-but-
|
|
6931
|
+
// empty '' = suppress is preserved; unset key → the CLI falls through). It is
|
|
6932
|
+
// a cosmetic markdown template, not a credential, so env-passing is safe.
|
|
6933
|
+
{
|
|
6934
|
+
const bl = resolveBrandLabel(cfg.larkAppId);
|
|
6935
|
+
if (typeof bl === 'string')
|
|
6936
|
+
childEnv.BOTMUX_BRAND_LABEL = bl;
|
|
6937
|
+
}
|
|
7035
6938
|
// NOTE: under read isolation `botmux send` gets this bot's secret from the worker-
|
|
7036
6939
|
// written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located
|
|
7037
6940
|
// via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT
|
|
@@ -7075,10 +6978,27 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7075
6978
|
// already guarantee the pane starts clean, and the CLI's built-in default
|
|
7076
6979
|
// preserves stock semantics.
|
|
7077
6980
|
if (isolationBotHome) {
|
|
6981
|
+
// In the file sandbox, bwrap binds only CANONICAL paths (the fs-policy
|
|
6982
|
+
// realpaths every rule). On a symlinked-$HOME host (/home/u → /data00/home/u)
|
|
6983
|
+
// the lexical BOT_HOME (/home/u/.botmux/bots/…/claude) does NOT exist in the
|
|
6984
|
+
// fresh bwrap root, so the CLI can't open its own CLAUDE_CONFIG_DIR/CODEX_HOME
|
|
6985
|
+
// → settings.json (with ANTHROPIC_BASE_URL/token) is unreadable → it falls
|
|
6986
|
+
// back to the public endpoint and fails to connect. Canonicalize so the env
|
|
6987
|
+
// points at the same path bwrap bound. Best-effort: keep lexical if unresolved.
|
|
6988
|
+
const canonicalizeForSandbox = (p) => {
|
|
6989
|
+
if (!sandboxRequested)
|
|
6990
|
+
return p;
|
|
6991
|
+
try {
|
|
6992
|
+
return realpathSync(p);
|
|
6993
|
+
}
|
|
6994
|
+
catch {
|
|
6995
|
+
return p;
|
|
6996
|
+
}
|
|
6997
|
+
};
|
|
7078
6998
|
if (claudeDataDir)
|
|
7079
|
-
childEnv.CLAUDE_CONFIG_DIR = claudeDataDir; // = <BOT_HOME>/claude
|
|
6999
|
+
childEnv.CLAUDE_CONFIG_DIR = canonicalizeForSandbox(claudeDataDir); // = <BOT_HOME>/claude
|
|
7080
7000
|
else
|
|
7081
|
-
childEnv.CODEX_HOME = isolatedCodexHome;
|
|
7001
|
+
childEnv.CODEX_HOME = canonicalizeForSandbox(isolatedCodexHome);
|
|
7082
7002
|
}
|
|
7083
7003
|
// Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
|
|
7084
7004
|
// ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
|
|
@@ -7108,356 +7028,355 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7108
7028
|
// reproduce-command.ts。这里只锁定"包装前的基础"这个事实。
|
|
7109
7029
|
const reproduceBaseBin = spawnBin;
|
|
7110
7030
|
const reproduceBaseArgs = [...spawnArgs];
|
|
7111
|
-
//
|
|
7112
|
-
//
|
|
7113
|
-
//
|
|
7114
|
-
//
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
//
|
|
7118
|
-
//
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7031
|
+
// ── UNIFIED file sandbox (fs-policy): ONE policy source, BOTH platforms. ──
|
|
7032
|
+
// Three-tier deny-by-default whitelist compiled to Seatbelt (darwin) or bwrap
|
|
7033
|
+
// (linux). Cross-bot read isolation is inherent (siblings' data simply isn't
|
|
7034
|
+
// exposed) — no enumeration, no deny-list to keep in sync.
|
|
7035
|
+
if (sandboxRequested) {
|
|
7036
|
+
const dataDir = process.env.SESSION_DATA_DIR;
|
|
7037
|
+
// FAIL-SAFE (not fail-open): a requested sandbox that can't be established
|
|
7038
|
+
// must be a HARD ERROR, never a silent unconfined run.
|
|
7039
|
+
if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
|
|
7040
|
+
const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
|
|
7041
|
+
log(msg);
|
|
7042
|
+
throw new Error(msg);
|
|
7043
|
+
}
|
|
7044
|
+
if (!dataDir) {
|
|
7045
|
+
const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
|
|
7046
|
+
log(msg);
|
|
7047
|
+
throw new Error(msg);
|
|
7048
|
+
}
|
|
7049
|
+
// Both engines match CANONICAL paths (Seatbelt resolves symlinks, bwrap
|
|
7050
|
+
// resolves mount sources) — realpath everything, or a symlinked prefix
|
|
7051
|
+
// (the /tmp→/private/tmp class) silently fail-opens.
|
|
7124
7052
|
const canonical = (p) => { try {
|
|
7125
7053
|
return realpathSync(p);
|
|
7126
7054
|
}
|
|
7127
7055
|
catch {
|
|
7128
7056
|
return p;
|
|
7129
7057
|
} };
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
denyPaths = buildV2DenyPaths(profileCtx).map(canonical);
|
|
7146
|
-
denyRegexes = buildV2DenyRegexes(profileCtx);
|
|
7147
|
-
const carve = buildV2CarveOuts(profileCtx);
|
|
7148
|
-
const capabilityCarvePath = managedOriginCapabilityPath(profileCtx.sessionDataDir, profileCtx.currentSessionId);
|
|
7149
|
-
allowPaths = [
|
|
7150
|
-
// Never realpath the capability leaf: another legacy sandbox can race
|
|
7151
|
-
// a symlink into that shared host directory while multiple persistent
|
|
7152
|
-
// sessions recover. Keeping the exact path may fail closed on a raced
|
|
7153
|
-
// symlink, but can never turn its target into a Seatbelt allow rule.
|
|
7154
|
-
...carve.allowPaths.map(path => path === capabilityCarvePath ? path : canonical(path)),
|
|
7155
|
-
...buildCliExecutableReadCarveOuts({
|
|
7156
|
-
homeDir: profileCtx.homeDir,
|
|
7157
|
-
cliId: cliAdapter.id,
|
|
7158
|
-
resolvedBin: canonical(cliAdapter.resolvedBin),
|
|
7159
|
-
}),
|
|
7160
|
-
// buildV2DenyPaths masks the shared pi-initial-prompts root. Re-open
|
|
7161
|
-
// only this session's private child directory for Pi's @file/extension.
|
|
7162
|
-
...piInitialPromptReadonlyRoots.map(canonical),
|
|
7163
|
-
];
|
|
7164
|
-
finalDenyPaths = carve.finalDenyPaths.map(canonical);
|
|
7165
|
-
traverseDirs = carve.traverseDirs.map(canonical);
|
|
7166
|
-
protectedWrites = buildReadIsolationProtectedWriteRules(profileCtx, {
|
|
7167
|
-
// Keep lexical roots as well as their canonical targets. Canonical
|
|
7168
|
-
// rules protect contents reached through a symlink; lexical literals
|
|
7169
|
-
// prevent replacing the symlink/root directory entry itself.
|
|
7170
|
-
dashboardRoots: [
|
|
7171
|
-
readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux'),
|
|
7172
|
-
readIsolationCtx.botmuxHome,
|
|
7173
|
-
],
|
|
7174
|
-
sessionDataDirs: [readIsolationCtx.sessionDataDir],
|
|
7175
|
-
});
|
|
7176
|
-
}
|
|
7177
|
-
if (credentialBoundaryActive) {
|
|
7178
|
-
const credentialRules = buildCredentialIsolationRules({
|
|
7179
|
-
homeDir: canonical(hostHomeDir),
|
|
7180
|
-
botmuxHome: canonical(configuredBotmuxHome),
|
|
7181
|
-
defaultBotmuxHome: canonical(defaultBotmuxHome),
|
|
7182
|
-
});
|
|
7183
|
-
denyPaths = [...new Set([...denyPaths, ...credentialRules.denyPaths.map(canonical)])];
|
|
7184
|
-
denyRegexes = [...new Set([...denyRegexes, ...credentialRules.denyRegexes])];
|
|
7185
|
-
protectedWrites = {
|
|
7186
|
-
denyWritePaths: [...new Set([
|
|
7187
|
-
...(protectedWrites?.denyWritePaths ?? []),
|
|
7188
|
-
...credentialRules.denyWritePaths.map(canonical),
|
|
7189
|
-
])],
|
|
7190
|
-
denyWriteRegexes: [...new Set([
|
|
7191
|
-
...(protectedWrites?.denyWriteRegexes ?? []),
|
|
7192
|
-
...credentialRules.denyWriteRegexes,
|
|
7193
|
-
])],
|
|
7194
|
-
denyWriteLiterals: [...new Set([
|
|
7195
|
-
...(protectedWrites?.denyWriteLiterals ?? []),
|
|
7196
|
-
...credentialRules.denyWriteLiterals.map(canonical),
|
|
7197
|
-
defaultBotmuxHome,
|
|
7198
|
-
configuredBotmuxHome,
|
|
7199
|
-
])],
|
|
7200
|
-
};
|
|
7201
|
-
}
|
|
7202
|
-
if (mcpRuntimeManifest) {
|
|
7203
|
-
finalDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
|
|
7204
|
-
}
|
|
7205
|
-
// Every isolated macOS CLI is denied access to all same-UID Gateway socket
|
|
7206
|
-
// directories. A session with its own Gateway gets one narrow, later READ
|
|
7207
|
-
// carve-out for its token; writes stay denied so the CLI cannot unlink or
|
|
7208
|
-
// replace even its own worker-managed socket. Sessions without plugin MCP
|
|
7209
|
-
// state get no read carve-out at all.
|
|
7210
|
-
const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
|
|
7211
|
-
const gatewaySocketRegex = sessionMcpGatewayPathRegex(gatewaySocketRoot);
|
|
7212
|
-
denyRegexes.push(gatewaySocketRegex);
|
|
7213
|
-
if (sessionMcpGatewayHost)
|
|
7214
|
-
allowPaths.push(canonical(sessionMcpGatewayHost.socketDir));
|
|
7215
|
-
protectedWrites = {
|
|
7216
|
-
denyWritePaths: protectedWrites?.denyWritePaths ?? [],
|
|
7217
|
-
denyWriteRegexes: [...(protectedWrites?.denyWriteRegexes ?? []), gatewaySocketRegex],
|
|
7218
|
-
denyWriteLiterals: protectedWrites?.denyWriteLiterals ?? [],
|
|
7058
|
+
const sandboxHome = canonical(homedir());
|
|
7059
|
+
const expandTilde = (raw) => raw.replace(/^~(?=\/|$)/, sandboxHome);
|
|
7060
|
+
const keepExisting = (paths) => {
|
|
7061
|
+
const out = [];
|
|
7062
|
+
for (const raw of paths) {
|
|
7063
|
+
if (!raw || typeof raw !== 'string')
|
|
7064
|
+
continue;
|
|
7065
|
+
const p = expandTilde(raw);
|
|
7066
|
+
try {
|
|
7067
|
+
if (existsSync(p))
|
|
7068
|
+
out.push(canonical(p));
|
|
7069
|
+
}
|
|
7070
|
+
catch { /* */ }
|
|
7071
|
+
}
|
|
7072
|
+
return out;
|
|
7219
7073
|
};
|
|
7220
|
-
//
|
|
7221
|
-
//
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7074
|
+
// User three-tier lists: the new sandboxPaths field, or a pre-migration
|
|
7075
|
+
// session/config's legacy fields mapped through the SAME lossless mapping
|
|
7076
|
+
// the startup migration uses.
|
|
7077
|
+
const legacyMapped = migrateLegacySandboxFields({
|
|
7078
|
+
sandbox: cfg.sandbox,
|
|
7079
|
+
readIsolation: cfg.readIsolation,
|
|
7080
|
+
sandboxReadonlyPaths: cfg.sandboxReadonlyPaths,
|
|
7081
|
+
sandboxHidePaths: cfg.sandboxHidePaths,
|
|
7082
|
+
readDenyExtraPaths: cfg.readDenyExtraPaths,
|
|
7083
|
+
sandboxPaths: cfg.sandboxPaths,
|
|
7084
|
+
});
|
|
7085
|
+
const userLists = cfg.sandboxPaths ?? legacyMapped?.sandboxPaths;
|
|
7086
|
+
const droppedUser = [];
|
|
7087
|
+
const resolveUser = (paths) => {
|
|
7088
|
+
const out = [];
|
|
7089
|
+
for (const raw of paths ?? []) {
|
|
7090
|
+
if (!raw || typeof raw !== 'string')
|
|
7091
|
+
continue;
|
|
7092
|
+
const p = expandTilde(raw);
|
|
7093
|
+
if (existsSync(p))
|
|
7094
|
+
out.push(canonical(p));
|
|
7095
|
+
else
|
|
7096
|
+
droppedUser.push(raw);
|
|
7228
7097
|
}
|
|
7098
|
+
return out;
|
|
7099
|
+
};
|
|
7100
|
+
// deny paths are NOT existence-filtered on EITHER platform: a deny must guard
|
|
7101
|
+
// a path that does not exist YET (else the agent creates it under an exposed
|
|
7102
|
+
// rw parent and the deny is bypassed — codex review finding). Seatbelt keeps
|
|
7103
|
+
// the rule literally; bwrap masks it with a tmpfs the mount creates (writes
|
|
7104
|
+
// land in the ephemeral tmpfs, the real path is never created).
|
|
7105
|
+
const resolveDeny = (paths) => (paths ?? []).filter((p) => typeof p === 'string' && !!p).map(p => canonical(expandTilde(p)));
|
|
7106
|
+
const userPaths = {
|
|
7107
|
+
readWrite: resolveUser(userLists?.readWrite),
|
|
7108
|
+
readOnly: resolveUser(userLists?.readOnly),
|
|
7109
|
+
deny: resolveDeny(userLists?.deny),
|
|
7110
|
+
};
|
|
7111
|
+
if (droppedUser.length)
|
|
7112
|
+
log(`[sandbox] sandboxPaths entries dropped (path not found): ${droppedUser.join(', ')}`);
|
|
7113
|
+
// Every executable spawned INSIDE the sandbox must be readable: the CLI
|
|
7114
|
+
// binary's dir, the daemon's own node (fnm farms under /run land here),
|
|
7115
|
+
// adapter second-stage bins, plus the standalone-codex package tree.
|
|
7116
|
+
const execDirs = [cliAdapter.resolvedBin, process.execPath, ...(cliAdapter.sandboxExtraExecPaths?.() ?? [])]
|
|
7117
|
+
.filter((p) => typeof p === 'string' && !!p)
|
|
7118
|
+
.map(p => dirname(canonical(p)));
|
|
7119
|
+
const execCarve = buildCliExecutableReadCarveOuts({
|
|
7120
|
+
homeDir: sandboxHome,
|
|
7121
|
+
cliId: cliAdapter.id,
|
|
7122
|
+
resolvedBin: canonical(cliAdapter.resolvedBin),
|
|
7123
|
+
}).map(canonical);
|
|
7124
|
+
// Linux relay outbox (created by prepareDirectSandbox; in the policy so the
|
|
7125
|
+
// compiled plan binds it read-write).
|
|
7126
|
+
const outbox = process.platform === 'linux'
|
|
7127
|
+
? join(canonical(dataDir), 'sandboxes', cfg.sessionId, 'outbox')
|
|
7229
7128
|
: undefined;
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7129
|
+
// Pre-create the outbox BEFORE buildFsPolicy so it survives the allow-rule
|
|
7130
|
+
// existence-filter below (a not-yet-existing readWrite path is dropped, and
|
|
7131
|
+
// bwrap can't bind a missing source → the sandboxed `botmux send` relay
|
|
7132
|
+
// would EPERM/ENOENT writing its <hash>.content into an unbound dir).
|
|
7133
|
+
// prepareDirectSandbox re-mkdirs it too; recursive make is idempotent.
|
|
7134
|
+
if (outbox) {
|
|
7135
|
+
try {
|
|
7136
|
+
mkdirSync(outbox, { recursive: true });
|
|
7137
|
+
}
|
|
7138
|
+
catch { /* best-effort; prepareDirectSandbox retries */ }
|
|
7139
|
+
}
|
|
7140
|
+
// The botmux install/checkout root (dir containing dist/ + node_modules).
|
|
7141
|
+
// This module compiles to <checkout>/dist/worker.js, so `../../` from here is
|
|
7142
|
+
// the checkout root. Exposed readOnly so `botmux` + claude hooks can exec
|
|
7143
|
+
// `node <checkout>/dist/cli.js`.
|
|
7144
|
+
const botmuxInstallRoot = canonical(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
7145
|
+
// Pre-create the OWN writable dirs/files the sandboxed CLI creates on
|
|
7146
|
+
// demand, so they EXIST at spawn and survive the existence-filter below
|
|
7147
|
+
// (bwrap can't bind a nonexistent source; a dropped rule → the CLI's
|
|
7148
|
+
// mkdir/write EPERMs).
|
|
7149
|
+
// - data/turn-sends/<sessionId>.jsonl: `botmux send` appends its dedup
|
|
7150
|
+
// marker. The policy grants ONLY this exact file (not the whole dir) so a
|
|
7151
|
+
// sandboxed CLI can't rewrite another session's marker — pre-create the
|
|
7152
|
+
// file itself (touch) so the single-file bind has a source.
|
|
7153
|
+
// - data/attachments/<self>: `botmux quoted`/downloadResources writes here
|
|
7154
|
+
// (BOT_HOME + outbox are created elsewhere.) Best-effort — a failure just
|
|
7155
|
+
// reverts to the pre-existing drop behaviour.
|
|
7247
7156
|
try {
|
|
7248
|
-
const
|
|
7249
|
-
mkdirSync(
|
|
7250
|
-
|
|
7157
|
+
const tsDir = join(dataDir, 'turn-sends');
|
|
7158
|
+
mkdirSync(tsDir, { recursive: true });
|
|
7159
|
+
const tsFile = join(tsDir, `${cfg.sessionId}.jsonl`);
|
|
7160
|
+
if (!existsSync(tsFile))
|
|
7161
|
+
writeFileSync(tsFile, '');
|
|
7251
7162
|
}
|
|
7252
|
-
catch { /*
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
// bwrap directly) and tmux (the tmux pane's command becomes `bwrap … -- cli`);
|
|
7256
|
-
// env is carried via bwrap --setenv (see prepareSandbox), not the backend.
|
|
7257
|
-
// Linux ONLY — on macOS the same `sandbox: true` is enforced by the Seatbelt
|
|
7258
|
-
// write-sandbox above (willWriteSandbox), so bwrap must not run here.
|
|
7259
|
-
//
|
|
7260
|
-
// riff bypass: there is NO local CLI process to wrap — execution happens in
|
|
7261
|
-
// riff's own remote sandbox, and the fail-safe "backend not sandboxable"
|
|
7262
|
-
// hard error below would otherwise brick every sandbox-enabled bot the
|
|
7263
|
-
// moment it switches to riff (the dashboard agent switch clears only
|
|
7264
|
-
// readIsolation, not `sandbox`). Bypassing is safe (nothing local runs);
|
|
7265
|
-
// log it so the state is visible.
|
|
7266
|
-
if (cfg.sandbox === true && effectiveBackendType === 'riff') {
|
|
7267
|
-
log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local file sandbox bypassed');
|
|
7268
|
-
}
|
|
7269
|
-
const sandboxOn = localSandboxApplies(process.platform, effectiveBackendType) && (cfg.sandbox === true || sandboxEnabled());
|
|
7270
|
-
// Linux read isolation: build the bwrap MASK set (the Seatbelt read-deny twin) and
|
|
7271
|
-
// fold it into the SAME overlay sandbox plan below. Enumerate sibling bots from the
|
|
7272
|
-
// FILESYSTEM (the worker runs unsandboxed on the host; bots.json may be denied) —
|
|
7273
|
-
// spawn-time-static, so a bot added after this spawn is covered on its next cold
|
|
7274
|
-
// start (the accepted Linux tradeoff vs the macOS regex). Only when read isolation
|
|
7275
|
-
// is enforceable here (willReadIsolate ⇒ readIsolationCtx set) AND on Linux.
|
|
7276
|
-
let readIsoLinuxMasks;
|
|
7277
|
-
if (readIsolationCtx && process.platform === 'linux') {
|
|
7278
|
-
const canon = (p) => { try {
|
|
7279
|
-
return realpathSync(p);
|
|
7163
|
+
catch { /* */ }
|
|
7164
|
+
try {
|
|
7165
|
+
mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true });
|
|
7280
7166
|
}
|
|
7281
|
-
catch {
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
//
|
|
7285
|
-
// (bwrap masks resolve symlinks — a symlinked home/data root would else fail-open).
|
|
7286
|
-
const ctxReal = {
|
|
7287
|
-
...readIsolationCtx,
|
|
7288
|
-
homeDir: canon(readIsolationCtx.homeDir),
|
|
7289
|
-
botmuxHome: canon(readIsolationCtx.botmuxHome),
|
|
7290
|
-
defaultBotmuxHome: canon(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
|
|
7291
|
-
sessionDataDir: canon(readIsolationCtx.sessionDataDir),
|
|
7292
|
-
};
|
|
7293
|
-
const ids = new Set();
|
|
7294
|
-
const scan = (dir, pick) => {
|
|
7295
|
-
try {
|
|
7296
|
-
for (const name of readdirSync(dir)) {
|
|
7297
|
-
const id = pick(name);
|
|
7298
|
-
if (id)
|
|
7299
|
-
ids.add(id);
|
|
7300
|
-
}
|
|
7301
|
-
}
|
|
7302
|
-
catch { /* dir absent → no siblings there */ }
|
|
7303
|
-
};
|
|
7304
|
-
scan(join(ctxReal.botmuxHome, 'bots'), n => n); // sibling BOT_HOME dirs
|
|
7305
|
-
scan(join(ctxReal.homeDir, '.lark-cli-bots'), n => n); // sibling lark configs
|
|
7306
|
-
scan(ctxReal.sessionDataDir, n => { const m = /^sessions-(.+)\.json$/.exec(n); return m ? m[1] : null; });
|
|
7307
|
-
ids.delete(cfg.larkAppId); // never mask own
|
|
7308
|
-
let sidecars = [];
|
|
7167
|
+
catch { /* */ }
|
|
7168
|
+
// schedules.json is a shared RMW store (`botmux schedule`); pre-create as an
|
|
7169
|
+
// empty map if absent (same content schedule-store itself writes) so a fresh
|
|
7170
|
+
// install's first in-sandbox `schedule add` can bind+write it on bwrap too.
|
|
7309
7171
|
try {
|
|
7310
|
-
|
|
7172
|
+
const sf = join(dataDir, 'schedules.json');
|
|
7173
|
+
if (!existsSync(sf))
|
|
7174
|
+
writeFileSync(sf, '{}');
|
|
7311
7175
|
}
|
|
7312
7176
|
catch { /* */ }
|
|
7313
|
-
const
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
//
|
|
7317
|
-
//
|
|
7318
|
-
|
|
7319
|
-
|
|
7320
|
-
|
|
7321
|
-
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
|
|
7329
|
-
|
|
7330
|
-
if (sandboxOn) {
|
|
7331
|
-
// FAIL-SAFE (not fail-open): when the sandbox is requested, a missing
|
|
7332
|
-
// precondition (no SESSION_DATA_DIR, or a backend we can't wrap) must be a
|
|
7333
|
-
// HARD ERROR, never a silent skip — otherwise the CLI would spawn UNSANDBOXED
|
|
7334
|
-
// with full host write access and no log, the exact opposite of the
|
|
7335
|
-
// oncall-untrusted-agent invariant. In normal operation worker-pool sets
|
|
7336
|
-
// SESSION_DATA_DIR and the backend is pty/tmux, so this never trips.
|
|
7337
|
-
const dataDir = process.env.SESSION_DATA_DIR;
|
|
7338
|
-
if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
|
|
7339
|
-
const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
|
|
7340
|
-
log(msg);
|
|
7341
|
-
throw new Error(msg);
|
|
7177
|
+
const mandatoryDenyPaths = [];
|
|
7178
|
+
const mandatoryDenyRegexes = [];
|
|
7179
|
+
const mandatoryReadOnlyPaths = [];
|
|
7180
|
+
// Linux: the per-session sandbox tree (`sandboxes/<sid>`) holds the deny-mask
|
|
7181
|
+
// cleanup manifest + the mode-000 empty ro-bind SOURCES. If SESSION_DATA_DIR
|
|
7182
|
+
// is configured INSIDE the working dir (a custom data dir under a RW-bound
|
|
7183
|
+
// project), the project's readWrite rule would otherwise cover the whole
|
|
7184
|
+
// tree and let the sandboxed CLI tamper with the manifest (→ trick teardown
|
|
7185
|
+
// into deleting arbitrary host paths) or the empty sources. Deny the tree
|
|
7186
|
+
// as a MANDATORY rule (user paths can't override); the outbox is re-granted
|
|
7187
|
+
// readWrite via ctx.outbox as a deeper carve-out (the compiler masks the
|
|
7188
|
+
// tree with a tmpfs that hosts the nested outbox bind, then remounts it RO —
|
|
7189
|
+
// manifest/empties stay unreadable AND unwritable in-sandbox). bwrap reads
|
|
7190
|
+
// ro-bind SOURCES from the host FS, so the empty masks still work even though
|
|
7191
|
+
// the child can't see them.
|
|
7192
|
+
if (process.platform === 'linux') {
|
|
7193
|
+
mandatoryDenyPaths.push(join(canonical(dataDir), 'sandboxes', cfg.sessionId));
|
|
7342
7194
|
}
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7195
|
+
readIsolationOriginCapabilityFile = process.platform === 'darwin'
|
|
7196
|
+
? managedOriginCapabilityPath(dataDir, cfg.sessionId)
|
|
7197
|
+
: null;
|
|
7198
|
+
// The macOS child reads the per-session rotating capability directly.
|
|
7199
|
+
// Materialize it before the policy's existence filter, and make the exact
|
|
7200
|
+
// file a mandatory read-only carve-out that user rules cannot shadow.
|
|
7201
|
+
if (readIsolationOriginCapabilityFile) {
|
|
7202
|
+
publishSandboxRelayCapability({ failClosed: true });
|
|
7203
|
+
mandatoryReadOnlyPaths.push(readIsolationOriginCapabilityFile);
|
|
7347
7204
|
}
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
catch { /* */ }
|
|
7364
|
-
}
|
|
7365
|
-
if (sandboxCleanup) {
|
|
7366
|
-
try {
|
|
7367
|
-
sandboxCleanup();
|
|
7205
|
+
if (credentialBoundaryActive) {
|
|
7206
|
+
const credentialRules = buildCredentialIsolationRules({
|
|
7207
|
+
homeDir: sandboxHome,
|
|
7208
|
+
botmuxHome: canonical(configuredBotmuxHome),
|
|
7209
|
+
defaultBotmuxHome: canonical(defaultBotmuxHome),
|
|
7210
|
+
});
|
|
7211
|
+
mandatoryDenyPaths.push(...credentialRules.denyPaths.map(canonical));
|
|
7212
|
+
mandatoryDenyRegexes.push(...credentialRules.denyRegexes);
|
|
7213
|
+
// Materialize every existing atomic sidecar as a concrete Linux mask.
|
|
7214
|
+
// Seatbelt additionally keeps the regexes above for files created later.
|
|
7215
|
+
for (const root of [...new Set([defaultBotmuxHome, configuredBotmuxHome])]) {
|
|
7216
|
+
try {
|
|
7217
|
+
for (const name of readdirSync(root)) {
|
|
7218
|
+
if (isCredentialIsolationReservedBasename(name)) {
|
|
7219
|
+
mandatoryDenyPaths.push(canonical(join(root, name)));
|
|
7368
7220
|
}
|
|
7369
|
-
catch { /* */ }
|
|
7370
7221
|
}
|
|
7371
|
-
sandboxCleanup = att.cleanup;
|
|
7372
|
-
sandboxRelayOutbox = att.outbox;
|
|
7373
|
-
sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7374
|
-
publishSandboxRelayCapability();
|
|
7375
|
-
log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox} (no remount)`);
|
|
7376
7222
|
}
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7223
|
+
catch { /* absent authority root */ }
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
7226
|
+
if (mcpRuntimeManifest) {
|
|
7227
|
+
mandatoryDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
|
|
7228
|
+
}
|
|
7229
|
+
if (process.platform === 'darwin') {
|
|
7230
|
+
const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
|
|
7231
|
+
mandatoryDenyRegexes.push(sessionMcpGatewayPathRegex(gatewaySocketRoot));
|
|
7232
|
+
if (sessionMcpGatewayHost) {
|
|
7233
|
+
mandatoryReadOnlyPaths.push(canonical(sessionMcpGatewayHost.socketDir));
|
|
7234
|
+
}
|
|
7235
|
+
}
|
|
7236
|
+
const policy = buildFsPolicy({
|
|
7237
|
+
platform: process.platform,
|
|
7238
|
+
homeDir: sandboxHome,
|
|
7239
|
+
botmuxHome: canonical(dirname(dataDir)),
|
|
7240
|
+
sessionDataDir: canonical(dataDir),
|
|
7241
|
+
workingDir: canonical(cfg.workingDir),
|
|
7242
|
+
currentAppId: cfg.larkAppId,
|
|
7243
|
+
sessionId: cfg.sessionId,
|
|
7244
|
+
botHome: canonical(ownBotHome),
|
|
7245
|
+
redirectedCliData: willRedirectCliData,
|
|
7246
|
+
cliDataPaths: willRedirectCliData ? undefined : keepExisting([
|
|
7247
|
+
cliAdapter.claudeDataDir,
|
|
7248
|
+
claudeDataDir ? `${sandboxHome}/.claude.json` : undefined,
|
|
7249
|
+
claudeDataDir ? `${sandboxHome}/.claude.json.lock` : undefined,
|
|
7250
|
+
claudeDataDir ? `${sandboxHome}/.claude.lock` : undefined,
|
|
7251
|
+
claudeDataDir ? `${sandboxHome}/.local/state/claude` : undefined,
|
|
7252
|
+
]),
|
|
7253
|
+
authPaths: keepExisting([...(cliAdapter.authPaths ?? [])]),
|
|
7254
|
+
execPaths: keepExisting([...execDirs, ...execCarve]),
|
|
7255
|
+
readonlyRoots: keepExisting([
|
|
7256
|
+
...(cfg.skillReadonlyRoots ?? []),
|
|
7257
|
+
...piInitialPromptReadonlyRoots,
|
|
7258
|
+
]),
|
|
7259
|
+
botmuxInstallRoot,
|
|
7260
|
+
outbox,
|
|
7261
|
+
extraWritePaths: keepExisting([process.env.TMPDIR]),
|
|
7262
|
+
userPaths,
|
|
7263
|
+
mandatoryDenyPaths,
|
|
7264
|
+
mandatoryDenyRegexes,
|
|
7265
|
+
mandatoryReadOnlyPaths,
|
|
7266
|
+
net: cfg.sandboxNetwork !== false,
|
|
7267
|
+
// Claude Code saves ~/.claude.json atomically via a PID/random-suffixed
|
|
7268
|
+
// sibling — only relevant when the data dir is NOT redirected to BOT_HOME.
|
|
7269
|
+
writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir
|
|
7270
|
+
? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`]
|
|
7271
|
+
: [],
|
|
7272
|
+
});
|
|
7273
|
+
// Existence-filter only the ALLOW rules (baseline entries are candidates,
|
|
7274
|
+
// not guarantees): a non-existent allow path has nothing to expose, and
|
|
7275
|
+
// bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless —
|
|
7276
|
+
// they must guard a path created later in the session (Seatbelt keeps the
|
|
7277
|
+
// literal rule; bwrap masks with a tmpfs whose mountpoint the mount creates).
|
|
7278
|
+
policy.rules = policy.rules.filter(r => {
|
|
7279
|
+
if (r.access === 'deny')
|
|
7280
|
+
return true;
|
|
7281
|
+
try {
|
|
7282
|
+
return existsSync(r.path);
|
|
7283
|
+
}
|
|
7284
|
+
catch {
|
|
7285
|
+
return false;
|
|
7286
|
+
}
|
|
7287
|
+
});
|
|
7288
|
+
if (process.platform === 'darwin') {
|
|
7289
|
+
if (!locateOnPath('sandbox-exec')) {
|
|
7290
|
+
throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
|
|
7291
|
+
}
|
|
7292
|
+
const profileDir = join(dataDir, 'read-isolation');
|
|
7293
|
+
mkdirSync(profileDir, { recursive: true });
|
|
7294
|
+
const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
|
|
7295
|
+
writeFileSync(profilePath, compileToSeatbelt(policy), { mode: 0o600 });
|
|
7296
|
+
seatbeltProfilePath = profilePath;
|
|
7297
|
+
spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
|
|
7298
|
+
spawnBin = 'sandbox-exec';
|
|
7299
|
+
log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (fs-policy, ${policy.rules.length} rules): sandbox-exec -f ${profilePath}`);
|
|
7300
|
+
}
|
|
7301
|
+
else if (willReattachPersistent) {
|
|
7302
|
+
// Daemon-restart reattach to a live bwrap'd pane: backend.spawn() ignores
|
|
7303
|
+
// bin/args and just re-attaches. Only re-wire the outbox watcher (so the
|
|
7304
|
+
// live CLI's `botmux send` keeps being serviced) + the cleanup ref. The
|
|
7305
|
+
// direct model has NO mounts — cleanup is a plain rm at close/exit.
|
|
7306
|
+
const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
|
|
7307
|
+
if (att) {
|
|
7308
|
+
if (sandboxStopWatcher) {
|
|
7309
|
+
try {
|
|
7310
|
+
sandboxStopWatcher();
|
|
7311
|
+
}
|
|
7312
|
+
catch { /* */ }
|
|
7382
7313
|
}
|
|
7314
|
+
sandboxCleanup = att.cleanup;
|
|
7315
|
+
sandboxRelayOutbox = att.outbox;
|
|
7316
|
+
sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7317
|
+
publishSandboxRelayCapability();
|
|
7318
|
+
log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox}`);
|
|
7383
7319
|
}
|
|
7384
7320
|
else {
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
|
|
7411
|
-
|
|
7412
|
-
mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
|
|
7413
|
-
trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
|
|
7414
|
-
userReadonlyPaths: cfg.sandboxReadonlyPaths ?? [],
|
|
7415
|
-
net: cfg.sandboxNetwork !== false,
|
|
7416
|
-
});
|
|
7417
|
-
if (sbx) {
|
|
7418
|
-
spawnBin = sbx.bin;
|
|
7419
|
-
spawnArgs = sbx.args;
|
|
7420
|
-
// In the overlay model the child still chdirs to projectMount (via bwrap
|
|
7421
|
-
// --chdir), so spawnCwd stays the real workingDir; the overlay merged dir
|
|
7422
|
-
// is bound there. sbx.workDir is the UPPER changeset (for landing), NOT a cwd.
|
|
7423
|
-
Object.assign(childEnv, sbx.env);
|
|
7424
|
-
if (sandboxStopWatcher) {
|
|
7425
|
-
try {
|
|
7426
|
-
sandboxStopWatcher();
|
|
7427
|
-
}
|
|
7428
|
-
catch { /* */ }
|
|
7429
|
-
}
|
|
7430
|
-
if (sandboxCleanup) {
|
|
7431
|
-
try {
|
|
7432
|
-
sandboxCleanup();
|
|
7433
|
-
}
|
|
7434
|
-
catch { /* */ }
|
|
7435
|
-
}
|
|
7436
|
-
sandboxCleanup = sbx.cleanup;
|
|
7437
|
-
sandboxRelayOutbox = sbx.outbox;
|
|
7438
|
-
// session-id is FORCED here so a relayed send can't target another session.
|
|
7439
|
-
sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7440
|
-
publishSandboxRelayCapability();
|
|
7441
|
-
log(`Sandbox ON (${cfg.cliId}): upper=${sbx.workDir} outbox=${sbx.outbox}`);
|
|
7321
|
+
log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane as-is`);
|
|
7322
|
+
}
|
|
7323
|
+
}
|
|
7324
|
+
else {
|
|
7325
|
+
const sbx = prepareDirectSandbox({
|
|
7326
|
+
sessionId: cfg.sessionId,
|
|
7327
|
+
dataDir,
|
|
7328
|
+
policy,
|
|
7329
|
+
chdir: canonical(cfg.workingDir),
|
|
7330
|
+
home: sandboxHome,
|
|
7331
|
+
cliBin: cliAdapter.resolvedBin,
|
|
7332
|
+
cliArgs: args,
|
|
7333
|
+
trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
|
|
7334
|
+
mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
|
|
7335
|
+
});
|
|
7336
|
+
if (!sbx) {
|
|
7337
|
+
// FAIL-SAFE: never silently run unsandboxed.
|
|
7338
|
+
const msg = 'sandbox requested but could not be established (bwrap missing or setup failed) — aborting spawn';
|
|
7339
|
+
log(msg);
|
|
7340
|
+
throw new Error(msg);
|
|
7341
|
+
}
|
|
7342
|
+
spawnBin = sbx.bin;
|
|
7343
|
+
spawnArgs = sbx.args;
|
|
7344
|
+
Object.assign(childEnv, sbx.env);
|
|
7345
|
+
if (sandboxStopWatcher) {
|
|
7346
|
+
try {
|
|
7347
|
+
sandboxStopWatcher();
|
|
7442
7348
|
}
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
7447
|
-
|
|
7448
|
-
throw new Error('sandbox requested but could not be established');
|
|
7349
|
+
catch { /* */ }
|
|
7350
|
+
}
|
|
7351
|
+
if (sandboxCleanup) {
|
|
7352
|
+
try {
|
|
7353
|
+
sandboxCleanup();
|
|
7449
7354
|
}
|
|
7355
|
+
catch { /* */ }
|
|
7450
7356
|
}
|
|
7357
|
+
sandboxCleanup = sbx.cleanup;
|
|
7358
|
+
sandboxRelayOutbox = sbx.outbox;
|
|
7359
|
+
// session-id is FORCED here so a relayed send can't target another session.
|
|
7360
|
+
sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
|
|
7361
|
+
publishSandboxRelayCapability();
|
|
7362
|
+
log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`);
|
|
7451
7363
|
}
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
|
|
7364
|
+
}
|
|
7365
|
+
// Fresh sandboxed spawn on a persistent backend: stamp the pane with this
|
|
7366
|
+
// daemon's boot id so a later reattach can be trusted (see the stale-pane
|
|
7367
|
+
// guard above). pty needs no marker (never reattached).
|
|
7368
|
+
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
|
|
7369
|
+
try {
|
|
7370
|
+
const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
|
|
7371
|
+
mkdirSync(markerDir, { recursive: true });
|
|
7372
|
+
writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities), { mode: 0o600 });
|
|
7455
7373
|
}
|
|
7374
|
+
catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
|
|
7456
7375
|
}
|
|
7457
7376
|
// 通用启动前缀(wrapperCli):把启动命令重写成 `<wrapperCli> <CLI 参数>`(首 token 当
|
|
7458
7377
|
// bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的
|
|
7459
7378
|
// --settings(见 buildWrappedLaunch)。与文件沙盒互斥:沙盒已把命令重写成 bwrap,叠加
|
|
7460
|
-
//
|
|
7379
|
+
// 前缀会破坏隔离,故沙盒开启时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
|
|
7461
7380
|
// CJADK_INTERACTIVE is a cjadk-only knob we set on the cjadk wrapper branch
|
|
7462
7381
|
// below. Strip any value inherited from the daemon's own env first so a
|
|
7463
7382
|
// daemon launched under `cjadk feishu` (which exports it) can't leak it via
|
|
@@ -7466,8 +7385,8 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7466
7385
|
// keeps the behaviour intentional rather than ambient. (Codex review note.)
|
|
7467
7386
|
delete childEnv.CJADK_INTERACTIVE;
|
|
7468
7387
|
if (cfg.wrapperCli && cfg.wrapperCli.trim()) {
|
|
7469
|
-
if (
|
|
7470
|
-
log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with
|
|
7388
|
+
if (sandboxRequested) {
|
|
7389
|
+
log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`);
|
|
7471
7390
|
}
|
|
7472
7391
|
else {
|
|
7473
7392
|
const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, {
|
|
@@ -7660,7 +7579,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7660
7579
|
baseBin: reproduceBaseBin,
|
|
7661
7580
|
baseArgs: reproduceBaseArgs,
|
|
7662
7581
|
wrapperCli: cfg.wrapperCli,
|
|
7663
|
-
sandboxOn,
|
|
7582
|
+
sandboxOn: sandboxRequested,
|
|
7664
7583
|
binResolver: (b) => locateOnPath(b) ?? b,
|
|
7665
7584
|
ttadkModel: cfg.model,
|
|
7666
7585
|
});
|
|
@@ -7726,7 +7645,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7726
7645
|
// MARKER inference is unaffected (the launcher-pid marker is still a valid
|
|
7727
7646
|
// ancestor of an in-CLI `botmux send`, and the env fallback covers it too).
|
|
7728
7647
|
const startWrapperRealPidResolve = (launcherPid) => {
|
|
7729
|
-
if (!cfg.wrapperCli || !cfg.wrapperCli.trim() ||
|
|
7648
|
+
if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxRequested || !claudeDataDir)
|
|
7730
7649
|
return;
|
|
7731
7650
|
const targetCliId = cfg.cliId;
|
|
7732
7651
|
scheduleWrapperRealCliPid(launcherPid, {
|
|
@@ -7948,7 +7867,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
7948
7867
|
const readyHookAvailable = effectiveReadyHookInstall
|
|
7949
7868
|
? hasInstalledSessionReadyHook(effectiveReadyHookInstall)
|
|
7950
7869
|
: true; // Hermes emits BOTMUX_READY_COMMAND directly instead of a config hook.
|
|
7951
|
-
const isolatedReadyTransportRequired =
|
|
7870
|
+
const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive;
|
|
7952
7871
|
const readyPortAvailable = !isolatedReadyTransportRequired
|
|
7953
7872
|
|| parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
|
|
7954
7873
|
const readyCapabilityAvailable = !isolatedReadyTransportRequired
|