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.
Files changed (147) hide show
  1. package/dist/adapters/backend/sandbox.d.ts +99 -279
  2. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  3. package/dist/adapters/backend/sandbox.js +424 -826
  4. package/dist/adapters/backend/sandbox.js.map +1 -1
  5. package/dist/adapters/cli/codex.d.ts.map +1 -1
  6. package/dist/adapters/cli/codex.js +4 -0
  7. package/dist/adapters/cli/codex.js.map +1 -1
  8. package/dist/adapters/cli/fs-policy.d.ts +244 -0
  9. package/dist/adapters/cli/fs-policy.d.ts.map +1 -0
  10. package/dist/adapters/cli/fs-policy.js +539 -0
  11. package/dist/adapters/cli/fs-policy.js.map +1 -0
  12. package/dist/adapters/cli/read-isolation.d.ts +14 -200
  13. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  14. package/dist/adapters/cli/read-isolation.js +18 -477
  15. package/dist/adapters/cli/read-isolation.js.map +1 -1
  16. package/dist/bot-registry.d.ts +26 -23
  17. package/dist/bot-registry.d.ts.map +1 -1
  18. package/dist/bot-registry.js +16 -0
  19. package/dist/bot-registry.js.map +1 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +25 -18
  22. package/dist/cli.js.map +1 -1
  23. package/dist/core/command-handler.d.ts.map +1 -1
  24. package/dist/core/command-handler.js +16 -58
  25. package/dist/core/command-handler.js.map +1 -1
  26. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  27. package/dist/core/dashboard-ipc-server.js +26 -27
  28. package/dist/core/dashboard-ipc-server.js.map +1 -1
  29. package/dist/core/passthrough-commands.d.ts.map +1 -1
  30. package/dist/core/passthrough-commands.js +1 -1
  31. package/dist/core/passthrough-commands.js.map +1 -1
  32. package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
  33. package/dist/core/plugins/mcp/gateway-installer.js +14 -2
  34. package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
  35. package/dist/core/session-manager.d.ts.map +1 -1
  36. package/dist/core/session-manager.js +90 -60
  37. package/dist/core/session-manager.js.map +1 -1
  38. package/dist/core/worker-pool.d.ts +24 -0
  39. package/dist/core/worker-pool.d.ts.map +1 -1
  40. package/dist/core/worker-pool.js +50 -6
  41. package/dist/core/worker-pool.js.map +1 -1
  42. package/dist/daemon.d.ts.map +1 -1
  43. package/dist/daemon.js +6 -0
  44. package/dist/daemon.js.map +1 -1
  45. package/dist/dashboard/bot-payload.d.ts +5 -0
  46. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  47. package/dist/dashboard/bot-payload.js +7 -0
  48. package/dist/dashboard/bot-payload.js.map +1 -1
  49. package/dist/dashboard/web/bot-defaults-page.d.ts +16 -0
  50. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  51. package/dist/dashboard/web/bot-defaults-page.js +225 -6
  52. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  53. package/dist/dashboard/web/bot-defaults.d.ts +7 -0
  54. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  55. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  56. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  57. package/dist/dashboard/web/i18n.js +38 -22
  58. package/dist/dashboard/web/i18n.js.map +1 -1
  59. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  60. package/dist/dashboard/web/sessions-page.js +1 -44
  61. package/dist/dashboard/web/sessions-page.js.map +1 -1
  62. package/dist/dashboard-web/app.js +1 -1
  63. package/dist/dashboard-web/chunks/bot-defaults-page-AUKV7P4L.js +10 -0
  64. package/dist/dashboard-web/chunks/{chunk-QCPPKCX3.js → chunk-KPQAUX7V.js} +1 -1
  65. package/dist/dashboard-web/chunks/{chunk-WXPMNEYV.js → chunk-NRL6CVAS.js} +1 -1
  66. package/dist/dashboard-web/chunks/{chunk-EJDYUINZ.js → chunk-R7IAKAYX.js} +1 -1
  67. package/dist/dashboard-web/chunks/{chunk-H7P5UV4W.js → chunk-X2L65IOM.js} +6 -6
  68. package/dist/dashboard-web/chunks/{connectors-page-IR54AOAI.js → connectors-page-HDQFEXZI.js} +1 -1
  69. package/dist/dashboard-web/chunks/{groups-page-3P4TLUMF.js → groups-page-6DKXBYOM.js} +1 -1
  70. package/dist/dashboard-web/chunks/{insights-page-TDCZV56A.js → insights-page-NWARTJ6J.js} +1 -1
  71. package/dist/dashboard-web/chunks/{monitor-room-QWCWL3SX.js → monitor-room-AGX3XRJX.js} +1 -1
  72. package/dist/dashboard-web/chunks/{monitoring-page-IOWFSNVC.js → monitoring-page-GGGIWN4C.js} +1 -1
  73. package/dist/dashboard-web/chunks/{office-page-5AUPEHZP.js → office-page-6SIVKSK3.js} +1 -1
  74. package/dist/dashboard-web/chunks/{overview-page-QJMI6NLW.js → overview-page-FVR3NKAF.js} +1 -1
  75. package/dist/dashboard-web/chunks/{roles-page-WJVB52ET.js → roles-page-WE2YB7KN.js} +1 -1
  76. package/dist/dashboard-web/chunks/{schedules-page-RVRWHP4V.js → schedules-page-TB3I24XN.js} +1 -1
  77. package/dist/dashboard-web/chunks/sessions-page-KOOOASIL.js +2 -0
  78. package/dist/dashboard-web/chunks/{settings-page-TNZEPC7V.js → settings-page-IEBR7TGN.js} +1 -1
  79. package/dist/dashboard-web/chunks/{skills-page-7YT5TIX5.js → skills-page-3QAT2IWD.js} +1 -1
  80. package/dist/dashboard-web/chunks/{team-federation-page-NJZKTU7A.js → team-federation-page-K4VVAVAK.js} +1 -1
  81. package/dist/dashboard-web/chunks/{v3-page-NQ73UA2Q.js → v3-page-V6IOJR2B.js} +1 -1
  82. package/dist/dashboard-web/chunks/{whiteboards-page-DSM3MDMN.js → whiteboards-page-V6CGKRNX.js} +1 -1
  83. package/dist/dashboard-web/style.css +20 -3
  84. package/dist/dashboard.js +86 -1
  85. package/dist/dashboard.js.map +1 -1
  86. package/dist/global-config.d.ts.map +1 -1
  87. package/dist/global-config.js +4 -1
  88. package/dist/global-config.js.map +1 -1
  89. package/dist/i18n/en.d.ts.map +1 -1
  90. package/dist/i18n/en.js +1 -19
  91. package/dist/i18n/en.js.map +1 -1
  92. package/dist/i18n/zh.d.ts.map +1 -1
  93. package/dist/i18n/zh.js +1 -19
  94. package/dist/i18n/zh.js.map +1 -1
  95. package/dist/im/lark/card-builder.d.ts +0 -13
  96. package/dist/im/lark/card-builder.d.ts.map +1 -1
  97. package/dist/im/lark/card-builder.js +0 -34
  98. package/dist/im/lark/card-builder.js.map +1 -1
  99. package/dist/im/lark/card-handler.d.ts.map +1 -1
  100. package/dist/im/lark/card-handler.js +1 -30
  101. package/dist/im/lark/card-handler.js.map +1 -1
  102. package/dist/im/lark/client.d.ts +36 -0
  103. package/dist/im/lark/client.d.ts.map +1 -1
  104. package/dist/im/lark/client.js +80 -0
  105. package/dist/im/lark/client.js.map +1 -1
  106. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  107. package/dist/im/lark/event-dispatcher.js +28 -3
  108. package/dist/im/lark/event-dispatcher.js.map +1 -1
  109. package/dist/services/sandbox-migration.d.ts +14 -0
  110. package/dist/services/sandbox-migration.d.ts.map +1 -0
  111. package/dist/services/sandbox-migration.js +85 -0
  112. package/dist/services/sandbox-migration.js.map +1 -0
  113. package/dist/services/sandbox-store.d.ts +28 -0
  114. package/dist/services/sandbox-store.d.ts.map +1 -1
  115. package/dist/services/sandbox-store.js +82 -0
  116. package/dist/services/sandbox-store.js.map +1 -1
  117. package/dist/types.d.ts +13 -1
  118. package/dist/types.d.ts.map +1 -1
  119. package/dist/utils/child-env.d.ts +2 -2
  120. package/dist/utils/child-env.d.ts.map +1 -1
  121. package/dist/utils/child-env.js +14 -0
  122. package/dist/utils/child-env.js.map +1 -1
  123. package/dist/worker.js +417 -498
  124. package/dist/worker.js.map +1 -1
  125. package/dist/workflows/events/payloads.d.ts +56 -0
  126. package/dist/workflows/events/payloads.d.ts.map +1 -1
  127. package/dist/workflows/events/payloads.js +9 -0
  128. package/dist/workflows/events/payloads.js.map +1 -1
  129. package/dist/workflows/events/schema.d.ts +86 -0
  130. package/dist/workflows/events/schema.d.ts.map +1 -1
  131. package/dist/workflows/shared/sandbox-policy.d.ts +10 -1
  132. package/dist/workflows/shared/sandbox-policy.d.ts.map +1 -1
  133. package/dist/workflows/shared/sandbox-policy.js +12 -0
  134. package/dist/workflows/shared/sandbox-policy.js.map +1 -1
  135. package/dist/workflows/v3/bot-resolve.d.ts.map +1 -1
  136. package/dist/workflows/v3/bot-resolve.js +36 -0
  137. package/dist/workflows/v3/bot-resolve.js.map +1 -1
  138. package/dist/workflows/v3/contract.d.ts +8 -0
  139. package/dist/workflows/v3/contract.d.ts.map +1 -1
  140. package/dist/workflows/v3/contract.js.map +1 -1
  141. package/package.json +1 -1
  142. package/dist/dashboard-web/chunks/bot-defaults-page-O376WSER.js +0 -5
  143. package/dist/dashboard-web/chunks/sessions-page-KWLGBX6G.js +0 -3
  144. package/dist/services/sandbox-land.d.ts +0 -45
  145. package/dist/services/sandbox-land.d.ts.map +0 -1
  146. package/dist/services/sandbox-land.js +0 -394
  147. 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 { evaluateReadIsolationGate, evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildV2DenyPaths, buildV2DenyRegexes, buildV2CarveOuts, buildReadIsolationProtectedWriteRules, buildCliExecutableReadCarveOuts, buildWriteSandboxRules, buildLinuxReadIsolationMasks, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
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 { prepareSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, sandboxedClaudeDataDir, localSandboxApplies, } from './adapters/backend/sandbox.js';
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
- // When this session will be file-sandboxed, the CLI's session jsonl is written
6368
- // into the overlay's EPHEMERAL home upper (CLAUDE_CONFIG_DIR lives under $HOME),
6369
- // invisible at the real path the bridge normally watches → "Bridge mark expired"
6370
- // and the turn never relays (2026-06-10 incident). Redirect every jsonl/pid/
6371
- // bridge gate below to the upper copy where the sandboxed CLI actually writes.
6372
- const willFileSandbox = process.platform === 'linux' && // bwrap overlay is Linux-only; macOS uses the Seatbelt write-sandbox below
6373
- (cfg.sandbox === true || sandboxEnabled()) &&
6374
- (effectiveBackendType === 'pty' || effectiveBackendType === 'tmux') &&
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/read-isolation flags set but backend is riff (remote sandbox, no local process) — local isolation bypassed');
6407
- }
6408
- const wantsFileSandbox = !riffRemoteBackend && (cfg.sandbox === true || sandboxEnabled());
6409
- // Legacy EXPLICIT read-isolation opt-in (macOS only Linux read-iso rides the
6410
- // bwrap sandbox, so it's driven solely by the sandbox toggle there). Keeps
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 (willReadIsolate)
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: !willReadIsolate
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 (willReadIsolate) {
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 versioned policy marker when we spawn an isolated
6583
- // pane; if an existing pane was not spawned under the current policy, kill it
6584
- // so the probe below sees no pane and we cold-spawn fresh isolated. A current-
6585
- // policy pane survives daemon restarts and suspendresume safely because the
6586
- // confinement remains attached to the live process.
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 markerreattaches 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
- // Per-bot local read isolation: assemble the Seatbelt profile context (the gate
6839
- // already fail-closed above reaching here with willReadIsolate means it is
6840
- // enforceable). The worker is on the host (NOT sandboxed), so it holds the
6841
- // secret; only the spawned CLI child is confined. A reattach that reaches here
6842
- // is safe: the stale-pane guard above already killed any persistent pane not
6843
- // stamped as isolated, so `willReattachPersistent` can now only be true for a
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(sessionDataDir, cfg.larkAppId);
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(`[read-isolation] WARN could not write send-cred file: ${e.message}`);
6797
+ log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
6879
6798
  }
6880
6799
  }
6881
- // macOS write-sandbox rules (pure): the writable-zone allow-list + crown-jewel
6882
- // re-denies. Realpath'd + emitted into the Seatbelt profile at the spawn site.
6883
- let writeSandboxRules;
6884
- if (willWriteSandbox && process.env.SESSION_DATA_DIR) {
6885
- const sessionDataDir = process.env.SESSION_DATA_DIR;
6886
- // Regex rules cannot be realpath'd after construction, so build their home
6887
- // prefix from the canonical path up front (Seatbelt matches canonical paths).
6888
- const canonicalSandboxPath = (path) => {
6889
- try {
6890
- return realpathSync(path);
6891
- }
6892
- catch {
6893
- return path;
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: cfg.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: willReadIsolate,
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
- // Session owner under the standard BOTMUX_* name (the riff backend already
7027
- // exposes it; `__OWNER_OPEN_ID` stays for back-compat). Custom CLI wrappers
7028
- // use it for per-user permission isolation. Advisory identity only botmux's
7029
- // own authz paths (v3 command authority, host.ts) ignore env owner values and
7030
- // resolve the current-turn caller from turn provenance instead.
7031
- if (cfg.ownerOpenId)
7032
- childEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
7033
- else
7034
- delete childEnv.BOTMUX_OWNER_OPEN_ID;
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
- // Read isolation (macOS): wrap the whole CLI process in a Seatbelt sandbox that
7112
- // denies reads of the sensitive paths (blocklist). The CLI bypasses its OWN
7113
- // sandbox (see adapter) so the outer wrapper is the sole enforcer. DARWIN ONLY —
7114
- // on Linux read isolation is enforced by bwrap masks fed into the overlay sandbox
7115
- // below (see readIsoLinuxMasks), NOT sandbox-exec (which doesn't exist there).
7116
- if (process.platform === 'darwin' && (readIsolationCtx || writeSandboxRules)) {
7117
- // Seatbelt matches CANONICAL paths (it resolves symlinks), so realpath every
7118
- // deny/allow before emitting the profile otherwise a sensitive root reached
7119
- // through a symlinked prefix (e.g. a symlinked home / SESSION_DATA_DIR) would
7120
- // silently fail-open (the /tmp→/private/tmp class of miss). realpath-if-exists:
7121
- // a non-existent path has nothing to read, so its literal form is harmless.
7122
- // The ROOT dirs are canonicalized FIRST (profileCtx) so the regex patterns —
7123
- // which can't be realpath'd as a result — are built on canonical prefixes too.
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
- // READ rules — only when read isolation is on. v2 HYBRID model: whole-deny
7131
- // ~/.claude|~/.codex (F1 fix own CLI data is redirected into BOT_HOME,
7132
- // readable) + surgical-deny only the cross-bot-SENSITIVE parts of the otherwise-
7133
- // readable ~/.botmux + system creds; the own slice is re-opened via carve-outs.
7134
- // For a WRITE-sandbox-only session these stay empty → reads wide open (Linux parity).
7135
- let denyPaths = [], denyRegexes = [], allowPaths = [], finalDenyPaths = [], traverseDirs = [];
7136
- let protectedWrites;
7137
- if (readIsolationCtx) {
7138
- const profileCtx = {
7139
- ...readIsolationCtx,
7140
- homeDir: canonical(readIsolationCtx.homeDir),
7141
- botmuxHome: canonical(readIsolationCtx.botmuxHome),
7142
- defaultBotmuxHome: canonical(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
7143
- sessionDataDir: canonical(readIsolationCtx.sessionDataDir),
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
- // WRITE rules the macOS file-sandbox (Linux-bwrap twin). Realpath the writable
7221
- // zones + crown-jewel re-denies for the same symlink-safety reason as reads.
7222
- const writeRules = writeSandboxRules
7223
- ? {
7224
- allowWritePaths: writeSandboxRules.allowWritePaths.map(canonical),
7225
- allowWriteRegexes: writeSandboxRules.allowWriteRegexes,
7226
- denyWritePaths: writeSandboxRules.denyWritePaths.map(canonical),
7227
- denyWriteRegexes: writeSandboxRules.denyWriteRegexes,
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
- if (!locateOnPath('sandbox-exec')) {
7231
- throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
7232
- }
7233
- const profileDir = join(isolationRuntimeDataDir, 'read-isolation');
7234
- mkdirSync(profileDir, { recursive: true });
7235
- const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
7236
- replaceManagedOriginCapabilityFile(profilePath, buildSeatbeltProfile(denyPaths, allowPaths, finalDenyPaths, traverseDirs, denyRegexes, writeRules, protectedWrites));
7237
- seatbeltProfilePath = profilePath;
7238
- spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
7239
- spawnBin = credentialMechanismExecutable ?? '/usr/bin/sandbox-exec';
7240
- log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (read-isolation=${!!readIsolationCtx}, write-sandbox=${!!writeRules}): sandbox-exec -f ${profilePath}`);
7241
- }
7242
- // Fresh sandboxed spawn on a persistent backend: stamp the pane with this daemon's
7243
- // policy version + boot id so a later suspend→resume reattach can be trusted (see the stale-pane
7244
- // guard above). Applies to read-isolation AND write-sandbox panes (both carry the
7245
- // Seatbelt confinement on the live process). pty needs no marker (never reattached).
7246
- if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
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 markerDir = join(isolationRuntimeDataDir, 'read-isolation');
7249
- mkdirSync(markerDir, { recursive: true });
7250
- replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities));
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 { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
7253
- }
7254
- // Sandbox wraps the spawned binary in bwrap. Works for pty (PtyBackend runs
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
- return p;
7283
- } };
7284
- // Canonicalize the root prefixes FIRST so every derived mask path is symlink-safe
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
- sidecars = readdirSync(ctxReal.botmuxHome).filter(n => n.startsWith('bots.json.')).map(n => join(ctxReal.botmuxHome, n));
7172
+ const sf = join(dataDir, 'schedules.json');
7173
+ if (!existsSync(sf))
7174
+ writeFileSync(sf, '{}');
7311
7175
  }
7312
7176
  catch { /* */ }
7313
- const m = buildLinuxReadIsolationMasks({ ctx: ctxReal, siblingAppIds: [...ids], botsJsonSidecars: sidecars });
7314
- // Same execvp carve-out as macOS (Codex 342a3e1c): a standalone Codex whose
7315
- // binary lives UNDER the now-masked ~/.codex would fail execvp. Re-expose ONLY
7316
- // its executable package tree (read-only, after the masks) — auth/config/sessions
7317
- // stay masked. No-op for npm/system installs (binary not under ~/.codex) + non-codex.
7318
- const execCarveOuts = buildCliExecutableReadCarveOuts({
7319
- homeDir: ctxReal.homeDir,
7320
- cliId: cliAdapter.id,
7321
- resolvedBin: canon(cliAdapter.resolvedBin),
7322
- }).map(canon);
7323
- readIsoLinuxMasks = {
7324
- hidePaths: m.hidePaths.map(canon),
7325
- ownReadWritePaths: m.ownReadWritePaths.map(canon),
7326
- ownReadOnlyPaths: [...m.ownReadOnlyPaths.map(canon), ...execCarveOuts],
7327
- };
7328
- log(`[read-isolation] linux bwrap masks: ${m.hidePaths.length} hide, ${[...ids].length} siblings enumerated${execCarveOuts.length ? ', +codex-standalone exec carve' : ''}`);
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
- if (!dataDir) {
7344
- const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
7345
- log(msg);
7346
- throw new Error(msg);
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
- try {
7349
- if (willReattachPersistent) {
7350
- // Daemon-restart reattach to a persistent (tmux/herdr/zellij) pane whose
7351
- // bwrap'd CLI is STILL ALIVE. backend.spawn() ignores bin/args here and
7352
- // just re-attaches, and the live CLI is bound to its own namespace-pinned
7353
- // overlay — so we must NOT unmount/remount (prepareSandbox would leave a
7354
- // duplicate host-side overlay the CLI isn't using). We only re-wire the
7355
- // outbox watcher (so the live CLI's `botmux send` keeps being serviced)
7356
- // and the cleanup ref (so close/exit reclaims the residue). No re-prep.
7357
- const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
7358
- if (att) {
7359
- if (sandboxStopWatcher) {
7360
- try {
7361
- sandboxStopWatcher();
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
- else {
7378
- // No sandbox tree on disk for a session we're reattaching to: the
7379
- // pane's CLI may be unsandboxed (sandbox enabled after it spawned). Do
7380
- // NOT remount under a live CLI; just continue the reattach as-is.
7381
- log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane without re-prep`);
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
- const sbx = prepareSandbox({
7386
- enabled: sandboxOn,
7387
- cliId: cfg.cliId,
7388
- sessionId: cfg.sessionId,
7389
- sourceWorkingDir: cfg.workingDir,
7390
- dataDir,
7391
- cliBin: cliAdapter.resolvedBin,
7392
- cliArgs: args,
7393
- // Read-isolation masks (Linux) fold into the same plan: hide the cross-bot
7394
- // sensitive set, keep the own BOT_HOME real+writable (authPaths), and re-
7395
- // expose the own attachments bucket read-only after the masks (readonlyRoots).
7396
- hidePaths: [...(cfg.sandboxHidePaths ?? []), ...(readIsoLinuxMasks?.hidePaths ?? [])],
7397
- authPaths: [...(cliAdapter.authPaths ?? [])],
7398
- // BOTMUX_HOME is credential-masked wholesale. Re-open only this
7399
- // bot's daemon-derived private CLI home after that parent mask, then
7400
- // re-mask its send credential so an untrusted receiver cannot bypass
7401
- // the host-authorized relay with a direct Lark API call.
7402
- trustedWritablePaths: readIsoLinuxMasks?.ownReadWritePaths ?? [],
7403
- finalHidePaths: readIsoLinuxMasks
7404
- ? [sendCredFilePath(dataDir, cfg.larkAppId)]
7405
- : [],
7406
- extraExecPaths: cliAdapter.sandboxExtraExecPaths?.(),
7407
- readonlyRoots: [
7408
- ...(cfg.skillReadonlyRoots ?? []),
7409
- ...piInitialPromptReadonlyRoots,
7410
- ...(readIsoLinuxMasks?.ownReadOnlyPaths ?? []),
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
- else {
7444
- // Sandbox was requested but prepareSandbox returned null (a required
7445
- // overlay mount failed, or non-Linux). Fail safe: do NOT silently run
7446
- // unsandboxed — surface a hard error so the session doesn't leak.
7447
- log(`Sandbox ENABLED but prepare returned null (mount failed / unsupported) — aborting spawn to avoid an unsandboxed run`);
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
- catch (err) {
7453
- log(`Sandbox prepare failed (${err.message}) aborting (sandbox is a hard requirement when enabled)`);
7454
- throw err;
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
- // 前缀会破坏隔离,故 sandboxOn 时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
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 (sandboxOn) {
7470
- log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with bwrap)`);
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() || sandboxOn || !claudeDataDir)
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 = sandboxOn || willReadIsolate;
7870
+ const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive;
7952
7871
  const readyPortAvailable = !isolatedReadyTransportRequired
7953
7872
  || parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
7954
7873
  const readyCapabilityAvailable = !isolatedReadyTransportRequired