botmux 3.3.0 → 3.4.1

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 (159) hide show
  1. package/dist/adapters/backend/sandbox.d.ts +47 -0
  2. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  3. package/dist/adapters/backend/sandbox.js +137 -0
  4. package/dist/adapters/backend/sandbox.js.map +1 -1
  5. package/dist/adapters/cli/read-isolation.d.ts +72 -3
  6. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  7. package/dist/adapters/cli/read-isolation.js +143 -6
  8. package/dist/adapters/cli/read-isolation.js.map +1 -1
  9. package/dist/adapters/cli/registry.d.ts.map +1 -1
  10. package/dist/adapters/cli/registry.js +8 -2
  11. package/dist/adapters/cli/registry.js.map +1 -1
  12. package/dist/cli/create-group-resolver.d.ts +19 -0
  13. package/dist/cli/create-group-resolver.d.ts.map +1 -1
  14. package/dist/cli/create-group-resolver.js +30 -0
  15. package/dist/cli/create-group-resolver.js.map +1 -1
  16. package/dist/cli/pm2-command.d.ts.map +1 -1
  17. package/dist/cli/pm2-command.js +6 -0
  18. package/dist/cli/pm2-command.js.map +1 -1
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +36 -1
  21. package/dist/cli.js.map +1 -1
  22. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  23. package/dist/core/dashboard-ipc-server.js +21 -0
  24. package/dist/core/dashboard-ipc-server.js.map +1 -1
  25. package/dist/core/device-isolation-activation.d.ts +56 -0
  26. package/dist/core/device-isolation-activation.d.ts.map +1 -0
  27. package/dist/core/device-isolation-activation.js +110 -0
  28. package/dist/core/device-isolation-activation.js.map +1 -0
  29. package/dist/core/device-isolation-daemon.d.ts +84 -0
  30. package/dist/core/device-isolation-daemon.d.ts.map +1 -0
  31. package/dist/core/device-isolation-daemon.js +591 -0
  32. package/dist/core/device-isolation-daemon.js.map +1 -0
  33. package/dist/core/types.d.ts +9 -0
  34. package/dist/core/types.d.ts.map +1 -1
  35. package/dist/core/types.js.map +1 -1
  36. package/dist/core/worker-pool.d.ts.map +1 -1
  37. package/dist/core/worker-pool.js +35 -0
  38. package/dist/core/worker-pool.js.map +1 -1
  39. package/dist/daemon.d.ts.map +1 -1
  40. package/dist/daemon.js +5 -0
  41. package/dist/daemon.js.map +1 -1
  42. package/dist/desktop/main/auto-start.d.ts +2 -0
  43. package/dist/desktop/main/auto-start.d.ts.map +1 -1
  44. package/dist/desktop/main/auto-start.js +10 -6
  45. package/dist/desktop/main/auto-start.js.map +1 -1
  46. package/dist/desktop/main/bundled-runtime.d.ts +12 -0
  47. package/dist/desktop/main/bundled-runtime.d.ts.map +1 -0
  48. package/dist/desktop/main/bundled-runtime.js +30 -0
  49. package/dist/desktop/main/bundled-runtime.js.map +1 -0
  50. package/dist/desktop/main/device-status.d.ts +15 -0
  51. package/dist/desktop/main/device-status.d.ts.map +1 -0
  52. package/dist/desktop/main/device-status.js +94 -0
  53. package/dist/desktop/main/device-status.js.map +1 -0
  54. package/dist/desktop/main/external-runtime.d.ts +9 -0
  55. package/dist/desktop/main/external-runtime.d.ts.map +1 -1
  56. package/dist/desktop/main/external-runtime.js +88 -18
  57. package/dist/desktop/main/external-runtime.js.map +1 -1
  58. package/dist/desktop/main/ipc.d.ts.map +1 -1
  59. package/dist/desktop/main/ipc.js +1 -0
  60. package/dist/desktop/main/ipc.js.map +1 -1
  61. package/dist/desktop/main/node-command.d.ts +21 -0
  62. package/dist/desktop/main/node-command.d.ts.map +1 -1
  63. package/dist/desktop/main/node-command.js +41 -0
  64. package/dist/desktop/main/node-command.js.map +1 -1
  65. package/dist/desktop/main/pm2-apps.d.ts +4 -2
  66. package/dist/desktop/main/pm2-apps.d.ts.map +1 -1
  67. package/dist/desktop/main/pm2-apps.js +11 -3
  68. package/dist/desktop/main/pm2-apps.js.map +1 -1
  69. package/dist/desktop/main/runtime-service.d.ts +21 -5
  70. package/dist/desktop/main/runtime-service.d.ts.map +1 -1
  71. package/dist/desktop/main/runtime-service.js +94 -10
  72. package/dist/desktop/main/runtime-service.js.map +1 -1
  73. package/dist/desktop/main/runtime-source.d.ts +2 -0
  74. package/dist/desktop/main/runtime-source.d.ts.map +1 -1
  75. package/dist/desktop/main/runtime-source.js +12 -6
  76. package/dist/desktop/main/runtime-source.js.map +1 -1
  77. package/dist/desktop/main/tray.d.ts +1 -0
  78. package/dist/desktop/main/tray.d.ts.map +1 -1
  79. package/dist/desktop/main/tray.js +3 -3
  80. package/dist/desktop/main/tray.js.map +1 -1
  81. package/dist/desktop/main.js +28 -8
  82. package/dist/desktop/main.js.map +1 -1
  83. package/dist/desktop/preload.js +3 -0
  84. package/dist/desktop/preload.js.map +1 -1
  85. package/dist/desktop/renderer/app.d.ts +2 -1
  86. package/dist/desktop/renderer/app.d.ts.map +1 -1
  87. package/dist/desktop/renderer/app.js +6 -1
  88. package/dist/desktop/renderer/app.js.map +1 -1
  89. package/dist/desktop/renderer/runtime-state.d.ts.map +1 -1
  90. package/dist/desktop/renderer/runtime-state.js +2 -0
  91. package/dist/desktop/renderer/runtime-state.js.map +1 -1
  92. package/dist/desktop/shared/shell-path-probes.d.ts +24 -0
  93. package/dist/desktop/shared/shell-path-probes.d.ts.map +1 -0
  94. package/dist/desktop/shared/shell-path-probes.js +38 -0
  95. package/dist/desktop/shared/shell-path-probes.js.map +1 -0
  96. package/dist/desktop/shared/types.d.ts +21 -1
  97. package/dist/desktop/shared/types.d.ts.map +1 -1
  98. package/dist/desktop/smoke.d.ts +1 -0
  99. package/dist/desktop/smoke.d.ts.map +1 -1
  100. package/dist/desktop/smoke.js +20 -5
  101. package/dist/desktop/smoke.js.map +1 -1
  102. package/dist/platform/bind.d.ts +4 -1
  103. package/dist/platform/bind.d.ts.map +1 -1
  104. package/dist/platform/bind.js +10 -1
  105. package/dist/platform/bind.js.map +1 -1
  106. package/dist/platform/binding.d.ts.map +1 -1
  107. package/dist/platform/binding.js +15 -9
  108. package/dist/platform/binding.js.map +1 -1
  109. package/dist/platform/device-command.d.ts +35 -0
  110. package/dist/platform/device-command.d.ts.map +1 -0
  111. package/dist/platform/device-command.js +187 -0
  112. package/dist/platform/device-command.js.map +1 -0
  113. package/dist/platform/device-enroll.d.ts +156 -0
  114. package/dist/platform/device-enroll.d.ts.map +1 -0
  115. package/dist/platform/device-enroll.js +781 -0
  116. package/dist/platform/device-enroll.js.map +1 -0
  117. package/dist/platform/device-isolation-activation-client.d.ts +28 -0
  118. package/dist/platform/device-isolation-activation-client.d.ts.map +1 -0
  119. package/dist/platform/device-isolation-activation-client.js +279 -0
  120. package/dist/platform/device-isolation-activation-client.js.map +1 -0
  121. package/dist/platform/device-isolation.d.ts +35 -0
  122. package/dist/platform/device-isolation.d.ts.map +1 -0
  123. package/dist/platform/device-isolation.js +90 -0
  124. package/dist/platform/device-isolation.js.map +1 -0
  125. package/dist/platform/device-paths.d.ts +9 -0
  126. package/dist/platform/device-paths.d.ts.map +1 -0
  127. package/dist/platform/device-paths.js +14 -0
  128. package/dist/platform/device-paths.js.map +1 -0
  129. package/dist/platform/device.d.ts +77 -0
  130. package/dist/platform/device.d.ts.map +1 -0
  131. package/dist/platform/device.js +212 -0
  132. package/dist/platform/device.js.map +1 -0
  133. package/dist/platform/host-command-context.d.ts +15 -0
  134. package/dist/platform/host-command-context.d.ts.map +1 -0
  135. package/dist/platform/host-command-context.js +26 -0
  136. package/dist/platform/host-command-context.js.map +1 -0
  137. package/dist/platform/secure-host-file.d.ts +12 -0
  138. package/dist/platform/secure-host-file.d.ts.map +1 -0
  139. package/dist/platform/secure-host-file.js +182 -0
  140. package/dist/platform/secure-host-file.js.map +1 -0
  141. package/dist/services/group-creator.d.ts +8 -0
  142. package/dist/services/group-creator.d.ts.map +1 -1
  143. package/dist/services/group-creator.js +42 -0
  144. package/dist/services/group-creator.js.map +1 -1
  145. package/dist/types.d.ts +9 -0
  146. package/dist/types.d.ts.map +1 -1
  147. package/dist/utils/atomic-write.d.ts +13 -0
  148. package/dist/utils/atomic-write.d.ts.map +1 -1
  149. package/dist/utils/atomic-write.js +89 -9
  150. package/dist/utils/atomic-write.js.map +1 -1
  151. package/dist/utils/input-gate.d.ts +41 -0
  152. package/dist/utils/input-gate.d.ts.map +1 -1
  153. package/dist/utils/input-gate.js +41 -0
  154. package/dist/utils/input-gate.js.map +1 -1
  155. package/dist/worker.js +328 -22
  156. package/dist/worker.js.map +1 -1
  157. package/dist/workflows/events/payloads.d.ts +4 -4
  158. package/dist/workflows/events/schema.d.ts +16 -16
  159. package/package.json +6 -3
package/dist/worker.js CHANGED
@@ -13,18 +13,18 @@
13
13
  * 7. On 'restart', kills CLI and re-spawns with --resume
14
14
  */
15
15
  import { randomBytes } from 'node:crypto';
16
- import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
16
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, openSync, closeSync, fstatSync, constants as fsConstants } from 'node:fs';
17
17
  import { atomicWriteFileSync } from './utils/atomic-write.js';
18
18
  import { join, basename, dirname } from 'node:path';
19
19
  import { homedir, tmpdir } from 'node:os';
20
20
  import { spawnSync } from 'node:child_process';
21
- import { evaluateReadIsolationGate, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildV2DenyPaths, buildV2DenyRegexes, buildV2CarveOuts, buildReadIsolationProtectedWriteRules, buildCliExecutableReadCarveOuts, buildWriteSandboxRules, buildLinuxReadIsolationMasks, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
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';
22
22
  import { killPersistentSession } from './core/persistent-backend.js';
23
23
  import { readProcessStartIdentity } from './core/session-marker.js';
24
24
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
25
25
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
26
26
  import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
27
- import { shouldReleaseFirstPromptTimeout, shouldWriteNow } from './utils/input-gate.js';
27
+ import { decideHardTimeoutAction, decideSettleMarkReady, shouldReleaseFirstPromptTimeout, shouldWriteNow } from './utils/input-gate.js';
28
28
  import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirst } from './core/inject-queue-policy.js';
29
29
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
30
30
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
@@ -88,7 +88,8 @@ import { zellijEnv } from './setup/ensure-zellij.js';
88
88
  import { isObserveBackend } from './adapters/backend/types.js';
89
89
  import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
90
90
  import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
91
- import { prepareSandbox, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, sandboxedClaudeDataDir, localSandboxApplies } from './adapters/backend/sandbox.js';
91
+ import { prepareSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, sandboxedClaudeDataDir, localSandboxApplies, } from './adapters/backend/sandbox.js';
92
+ import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
92
93
  import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
93
94
  import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
94
95
  import { IdleDetector } from './utils/idle-detector.js';
@@ -689,6 +690,10 @@ let lastCliExitSignal = null;
689
690
  * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
690
691
  let isPipeMode = false;
691
692
  let effectiveBackendType = 'pty';
693
+ /** Worker-owned statement about the confinement attached to the CURRENT CLI
694
+ * generation. The daemon receives this over private IPC; child-writable PID
695
+ * marker files remain diagnostics only. */
696
+ let currentCliCredentialIsolated = false;
692
697
  /** pty-under-zellij backend (BACKEND_TYPE=zellij). Behaves like the non-tmux
693
698
  * pty path for the worker (renderer screenshots, relay web terminal) but owns
694
699
  * a persistent zellij session that survives daemon restart. */
@@ -832,6 +837,15 @@ let isSettlingFirstFlush = false;
832
837
  * ready yet, or flushPending will be blocked by isSettlingFirstFlush and a
833
838
  * later markPromptReady call would return early with the first prompt stranded. */
834
839
  let promptReadyDetectedDuringSettle = false;
840
+ /** While the ready-gate is holding, the IdleDetector may still fire on a real
841
+ * readyPattern (e.g. Hermes's ❯) — proving the input box exists — but
842
+ * markPromptReady() returns early because the gate is armed. Record that the
843
+ * pattern was seen so the gate's timeout-fallback settle can mark the prompt
844
+ * ready immediately instead of delivering into a !isPromptReady state that
845
+ * flushPending() rejects for non-type-ahead adapters. Without this, a Hermes
846
+ * spawn that renders ❯ but never fires BOTMUX_READY_COMMAND waits the full
847
+ * hard timeout (and previously never delivered at all). */
848
+ let readyPatternSeenDuringHold = false;
835
849
  /** Wait until the PTY has been quiet for READY_FLUSH_SETTLE_MS (Ink render
836
850
  * drained), capped at READY_FLUSH_SETTLE_CAP_MS, then flush the held prompt.
837
851
  * A real SessionStart/BOTMUX_READY_COMMAND signal is itself authoritative
@@ -843,8 +857,13 @@ function settleThenFlush(startedAtMs, promptReadyAfterSettle) {
843
857
  const quietForMs = now - lastPtyOutputAtMs;
844
858
  if (quietForMs >= READY_FLUSH_SETTLE_MS || now - startedAtMs >= READY_FLUSH_SETTLE_CAP_MS) {
845
859
  isSettlingFirstFlush = false;
846
- const shouldMarkPromptReady = promptReadyAfterSettle || promptReadyDetectedDuringSettle;
860
+ const shouldMarkPromptReady = decideSettleMarkReady({
861
+ promptReadyAfterSettle,
862
+ promptReadyDetectedDuringSettle,
863
+ readyPatternSeenDuringHold,
864
+ });
847
865
  promptReadyDetectedDuringSettle = false;
866
+ readyPatternSeenDuringHold = false;
848
867
  log(`Ready-gate settle done (quiet ${quietForMs}ms); ${shouldMarkPromptReady ? 'marking prompt ready' : 'delivering held first prompt'}`);
849
868
  if (shouldMarkPromptReady) {
850
869
  markPromptReady();
@@ -4519,6 +4538,11 @@ function markPromptReady() {
4519
4538
  // releaseReadyGate() drives flushPending() once the real signal lands, and a
4520
4539
  // later genuine idle then runs this fully. No-op for non-armed gates.
4521
4540
  if (readyGate.shouldHold()) {
4541
+ // A real readyPattern fired while the gate was holding — the input box
4542
+ // exists. Remember it so the gate's timeout-fallback settle can mark the
4543
+ // prompt ready (see settleThenFlush) instead of letting flushPending()
4544
+ // reject the held message for non-type-ahead adapters.
4545
+ readyPatternSeenDuringHold = true;
4522
4546
  log('Idle detected but holding for SessionStart ready signal (startup selector guard)');
4523
4547
  return;
4524
4548
  }
@@ -5581,6 +5605,40 @@ function scheduleBusyPatternIdleProbe(source) {
5581
5605
  }
5582
5606
  async function spawnCli(cfg, opts = {}) {
5583
5607
  clearSessionRenameInFlight();
5608
+ currentCliCredentialIsolated = false;
5609
+ // Enrollment writes the fixed marker before any device credential appears.
5610
+ // From that instant onward every NEW local CLI must carry a credential
5611
+ // boundary, regardless of adapter capability or optional sandbox toggles.
5612
+ // lstat (not existsSync) deliberately treats a hostile/broken symlink as a
5613
+ // present authority signal and therefore fails closed.
5614
+ const hostHomeDir = homedir();
5615
+ const defaultBotmuxHome = join(hostHomeDir, '.botmux');
5616
+ const configuredBotmuxHome = process.env.SESSION_DATA_DIR
5617
+ ? dirname(process.env.SESSION_DATA_DIR)
5618
+ : defaultBotmuxHome;
5619
+ const hostEntryExistsNoFollow = (path) => {
5620
+ try {
5621
+ lstatSync(path);
5622
+ return true;
5623
+ }
5624
+ catch {
5625
+ return false;
5626
+ }
5627
+ };
5628
+ const deviceIsolationMarkerExists = hostEntryExistsNoFollow(deviceCredentialIsolationMarkerPath(hostHomeDir));
5629
+ const deviceCredentialExists = [...new Set([defaultBotmuxHome, configuredBotmuxHome])]
5630
+ .some(root => hostEntryExistsNoFollow(join(root, DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE))
5631
+ // Upgrade fail-safe: a pre-dedicated-directory credential still activates
5632
+ // mandatory confinement until the host explicitly removes/migrates it.
5633
+ || hostEntryExistsNoFollow(join(root, DEVICE_CREDENTIAL_FILE)));
5634
+ const mandatoryCredentialIsolation = credentialIsolationRequired({
5635
+ markerExists: deviceIsolationMarkerExists,
5636
+ deviceCredentialExists,
5637
+ });
5638
+ if (mandatoryCredentialIsolation && cfg.adoptMode) {
5639
+ throw new Error(`[device-credential-isolation] refusing adopt session ${cfg.sessionId}: `
5640
+ + 'an already-running external CLI cannot be retrofitted with the mandatory credential boundary');
5641
+ }
5584
5642
  // (startupCommands one-shot is re-armed below, AFTER the reattach-vs-fresh
5585
5643
  // prediction — only a genuinely fresh CLI process replays them; see
5586
5644
  // willReattachPersistent.)
@@ -5940,6 +5998,56 @@ async function spawnCli(cfg, opts = {}) {
5940
5998
  // established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the
5941
5999
  // spawn site below, never a silent unconfined run.
5942
6000
  const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox;
6001
+ if (willWriteSandbox && !process.env.SESSION_DATA_DIR) {
6002
+ throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: missing SESSION_DATA_DIR`);
6003
+ }
6004
+ const fullIsolationCoversCredentials = !riffRemoteBackend && ((process.platform === 'darwin' && (willReadIsolate || willWriteSandbox))
6005
+ || (process.platform === 'linux' && wantsFileSandbox));
6006
+ let credentialMechanismAvailable = true;
6007
+ let credentialMechanismExecutable;
6008
+ if (mandatoryCredentialIsolation && !riffRemoteBackend && !fullIsolationCoversCredentials) {
6009
+ if (process.platform === 'darwin') {
6010
+ const probe = probeHostCredentialIsolationMechanism();
6011
+ credentialMechanismAvailable = probe.supported;
6012
+ if (probe.supported)
6013
+ credentialMechanismExecutable = probe.executable;
6014
+ }
6015
+ else {
6016
+ credentialMechanismAvailable = process.platform === 'linux'
6017
+ ? credentialOnlySandboxAvailable()
6018
+ : false;
6019
+ }
6020
+ }
6021
+ const credentialIsolationGate = evaluateCredentialOnlyIsolationGate({
6022
+ markerExists: deviceIsolationMarkerExists,
6023
+ deviceCredentialExists,
6024
+ remoteBackend: riffRemoteBackend,
6025
+ platform: process.platform,
6026
+ mechanismAvailable: credentialMechanismAvailable,
6027
+ fullIsolationCoversCredentials,
6028
+ });
6029
+ if (credentialIsolationGate.mode === 'blocked') {
6030
+ throw new Error(`[device-credential-isolation] refusing to start session ${cfg.sessionId}: `
6031
+ + credentialIsolationGate.failClosedReason);
6032
+ }
6033
+ const credentialBoundaryActive = credentialIsolationGate.required
6034
+ && credentialIsolationGate.mode !== 'remote-bypass';
6035
+ const credentialOnlySeatbelt = credentialIsolationGate.mode === 'seatbelt';
6036
+ const credentialOnlyBwrap = credentialIsolationGate.mode === 'bwrap';
6037
+ const appliedIsolationCapabilities = [];
6038
+ if (credentialBoundaryActive
6039
+ || willReadIsolate
6040
+ || (process.platform === 'linux' && wantsFileSandbox)) {
6041
+ appliedIsolationCapabilities.push('credential');
6042
+ }
6043
+ if (willReadIsolate)
6044
+ appliedIsolationCapabilities.push('read');
6045
+ if (willWriteSandbox || (process.platform === 'linux' && wantsFileSandbox)) {
6046
+ appliedIsolationCapabilities.push('write');
6047
+ }
6048
+ currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
6049
+ const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
6050
+ ?? join(defaultBotmuxHome, 'data');
5943
6051
  // Every bot — isolated OR not — gets its own BOT_HOME dir as a ready-made private-
5944
6052
  // storage slot. An isolated sibling denies this path regardless of whether the owner
5945
6053
  // is isolated (deny uses the full bots.json), so a non-isolated bot can drop private
@@ -6021,7 +6129,7 @@ async function spawnCli(cfg, opts = {}) {
6021
6129
  // so the probe below sees no pane and we cold-spawn fresh isolated. A current-
6022
6130
  // policy pane survives daemon restarts and suspend→resume safely because the
6023
6131
  // confinement remains attached to the live process.
6024
- if ((willReadIsolate || willWriteSandbox) && persistentSessionName && effectiveBackendType !== 'pty') {
6132
+ if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
6025
6133
  const paneLive = effectiveBackendType === 'tmux'
6026
6134
  ? TmuxBackend.hasSession(persistentSessionName)
6027
6135
  : effectiveBackendType === 'zellij'
@@ -6029,8 +6137,8 @@ async function spawnCli(cfg, opts = {}) {
6029
6137
  : HerdrBackend.hasSession(persistentSessionName);
6030
6138
  if (paneLive) {
6031
6139
  let marker = null;
6032
- marker = readRegularHostFileNoFollow(join(process.env.SESSION_DATA_DIR ?? '', 'read-isolation', `${cfg.sessionId}.boot`));
6033
- if (isolatedPaneReattachSafe(marker)) {
6140
+ marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
6141
+ if (isolatedPaneReattachSafe(marker, appliedIsolationCapabilities)) {
6034
6142
  // Pane was spawned under the current isolation policy → still confined
6035
6143
  // on the running process across daemon restarts; warm reattach preserves
6036
6144
  // resume/context + tmux idle-suspend.
@@ -6270,16 +6378,20 @@ async function spawnCli(cfg, opts = {}) {
6270
6378
  const sessionDataDir = process.env.SESSION_DATA_DIR;
6271
6379
  // Regex rules cannot be realpath'd after construction, so build their home
6272
6380
  // prefix from the canonical path up front (Seatbelt matches canonical paths).
6273
- const sandboxHome = (() => { try {
6274
- return realpathSync(homedir());
6275
- }
6276
- catch {
6277
- return homedir();
6278
- } })();
6381
+ const canonicalSandboxPath = (path) => {
6382
+ try {
6383
+ return realpathSync(path);
6384
+ }
6385
+ catch {
6386
+ return path;
6387
+ }
6388
+ };
6389
+ const sandboxHome = canonicalSandboxPath(homedir());
6279
6390
  writeSandboxRules = buildWriteSandboxRules({
6280
6391
  homeDir: sandboxHome,
6281
- botmuxHome: dirname(sessionDataDir),
6282
- sessionDataDir,
6392
+ botmuxHome: canonicalSandboxPath(dirname(sessionDataDir)),
6393
+ defaultBotmuxHome: canonicalSandboxPath(join(homedir(), '.botmux')),
6394
+ sessionDataDir: canonicalSandboxPath(sessionDataDir),
6283
6395
  workingDir: cfg.workingDir,
6284
6396
  currentAppId: cfg.larkAppId,
6285
6397
  // TMPDIR on macOS resolves under /private/var/folders (already allowed), but
@@ -6536,6 +6648,31 @@ async function spawnCli(cfg, opts = {}) {
6536
6648
  sessionDataDirs: [readIsolationCtx.sessionDataDir],
6537
6649
  });
6538
6650
  }
6651
+ if (credentialBoundaryActive) {
6652
+ const credentialRules = buildCredentialIsolationRules({
6653
+ homeDir: canonical(hostHomeDir),
6654
+ botmuxHome: canonical(configuredBotmuxHome),
6655
+ defaultBotmuxHome: canonical(defaultBotmuxHome),
6656
+ });
6657
+ denyPaths = [...new Set([...denyPaths, ...credentialRules.denyPaths.map(canonical)])];
6658
+ denyRegexes = [...new Set([...denyRegexes, ...credentialRules.denyRegexes])];
6659
+ protectedWrites = {
6660
+ denyWritePaths: [...new Set([
6661
+ ...(protectedWrites?.denyWritePaths ?? []),
6662
+ ...credentialRules.denyWritePaths.map(canonical),
6663
+ ])],
6664
+ denyWriteRegexes: [...new Set([
6665
+ ...(protectedWrites?.denyWriteRegexes ?? []),
6666
+ ...credentialRules.denyWriteRegexes,
6667
+ ])],
6668
+ denyWriteLiterals: [...new Set([
6669
+ ...(protectedWrites?.denyWriteLiterals ?? []),
6670
+ ...credentialRules.denyWriteLiterals.map(canonical),
6671
+ defaultBotmuxHome,
6672
+ configuredBotmuxHome,
6673
+ ])],
6674
+ };
6675
+ }
6539
6676
  if (mcpRuntimeManifest) {
6540
6677
  finalDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
6541
6678
  }
@@ -6561,29 +6698,30 @@ async function spawnCli(cfg, opts = {}) {
6561
6698
  allowWritePaths: writeSandboxRules.allowWritePaths.map(canonical),
6562
6699
  allowWriteRegexes: writeSandboxRules.allowWriteRegexes,
6563
6700
  denyWritePaths: writeSandboxRules.denyWritePaths.map(canonical),
6701
+ denyWriteRegexes: writeSandboxRules.denyWriteRegexes,
6564
6702
  }
6565
6703
  : undefined;
6566
6704
  if (!locateOnPath('sandbox-exec')) {
6567
6705
  throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
6568
6706
  }
6569
- const profileDir = join(process.env.SESSION_DATA_DIR, 'read-isolation');
6707
+ const profileDir = join(isolationRuntimeDataDir, 'read-isolation');
6570
6708
  mkdirSync(profileDir, { recursive: true });
6571
6709
  const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
6572
6710
  replaceManagedOriginCapabilityFile(profilePath, buildSeatbeltProfile(denyPaths, allowPaths, finalDenyPaths, traverseDirs, denyRegexes, writeRules, protectedWrites));
6573
6711
  seatbeltProfilePath = profilePath;
6574
6712
  spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
6575
- spawnBin = 'sandbox-exec';
6713
+ spawnBin = credentialMechanismExecutable ?? '/usr/bin/sandbox-exec';
6576
6714
  log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (read-isolation=${!!readIsolationCtx}, write-sandbox=${!!writeRules}): sandbox-exec -f ${profilePath}`);
6577
6715
  }
6578
6716
  // Fresh sandboxed spawn on a persistent backend: stamp the pane with this daemon's
6579
6717
  // policy version + boot id so a later suspend→resume reattach can be trusted (see the stale-pane
6580
6718
  // guard above). Applies to read-isolation AND write-sandbox panes (both carry the
6581
6719
  // Seatbelt confinement on the live process). pty needs no marker (never reattached).
6582
- if ((readIsolationCtx || writeSandboxRules) && persistentSessionName && !willReattachPersistent) {
6720
+ if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
6583
6721
  try {
6584
- const markerDir = join(process.env.SESSION_DATA_DIR, 'read-isolation');
6722
+ const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
6585
6723
  mkdirSync(markerDir, { recursive: true });
6586
- replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? ''));
6724
+ replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities));
6587
6725
  }
6588
6726
  catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
6589
6727
  }
@@ -6836,6 +6974,143 @@ async function spawnCli(cfg, opts = {}) {
6836
6974
  }
6837
6975
  }
6838
6976
  }
6977
+ // Mandatory credential-only confinement is the OUTERMOST launch wrapper so
6978
+ // wrapperCli and every descendant it starts inherit the boundary. Full
6979
+ // Seatbelt/bwrap sessions were already wrapped above and never enter these
6980
+ // branches (gate mode `covered`), avoiding nested/double sandboxes.
6981
+ if (!willReattachPersistent && credentialOnlySeatbelt) {
6982
+ const canonical = (path) => {
6983
+ try {
6984
+ return realpathSync(path);
6985
+ }
6986
+ catch {
6987
+ return path;
6988
+ }
6989
+ };
6990
+ const rules = buildCredentialIsolationRules({
6991
+ homeDir: canonical(hostHomeDir),
6992
+ botmuxHome: canonical(configuredBotmuxHome),
6993
+ defaultBotmuxHome: canonical(defaultBotmuxHome),
6994
+ });
6995
+ const profileDir = join(isolationRuntimeDataDir, 'read-isolation');
6996
+ mkdirSync(profileDir, { recursive: true });
6997
+ const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
6998
+ replaceManagedOriginCapabilityFile(profilePath, buildSeatbeltProfile(rules.denyPaths.map(canonical), [], [], [], rules.denyRegexes, undefined, {
6999
+ denyWritePaths: [...new Set([
7000
+ ...rules.denyWritePaths.map(canonical),
7001
+ canonical(profileDir),
7002
+ ])],
7003
+ denyWriteRegexes: rules.denyWriteRegexes,
7004
+ denyWriteLiterals: [...new Set([
7005
+ ...rules.denyWriteLiterals.map(canonical),
7006
+ canonical(profileDir),
7007
+ defaultBotmuxHome,
7008
+ configuredBotmuxHome,
7009
+ ])],
7010
+ }));
7011
+ seatbeltProfilePath = profilePath;
7012
+ spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
7013
+ // Absolute path only: a pre-activation unconfined CLI could plant a fake
7014
+ // sandbox-exec earlier on PATH. Prefer the probe result; fall back to the
7015
+ // well-known system location used by full Seatbelt wrapping above.
7016
+ spawnBin = credentialMechanismExecutable ?? '/usr/bin/sandbox-exec';
7017
+ log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only Seatbelt: ${spawnBin} -f ${profilePath}`);
7018
+ }
7019
+ if (!willReattachPersistent && credentialOnlyBwrap) {
7020
+ const panePolicyDir = join(isolationRuntimeDataDir, 'read-isolation');
7021
+ mkdirSync(panePolicyDir, { recursive: true });
7022
+ const hideDirectories = new Set();
7023
+ const hideFiles = new Set();
7024
+ const processedRoots = new Set();
7025
+ const isDashboardAuthorityBasename = (name) => name === '.dashboard-secret'
7026
+ || name.startsWith('.dashboard-secret.')
7027
+ || name === '.dashboard-token'
7028
+ || name.startsWith('.dashboard-token.');
7029
+ for (const rawRoot of [defaultBotmuxHome, configuredBotmuxHome]) {
7030
+ let root = rawRoot;
7031
+ try {
7032
+ root = realpathSync(rawRoot);
7033
+ }
7034
+ catch { /* gate authority root is checked below */ }
7035
+ if (processedRoots.has(root))
7036
+ continue;
7037
+ let rootStat;
7038
+ try {
7039
+ rootStat = lstatSync(root);
7040
+ }
7041
+ catch {
7042
+ // An unrelated/default root can be absent when the activation signal
7043
+ // came from a custom BOTMUX_HOME. At least one signalled root must
7044
+ // survive; the builder below rejects an empty root set.
7045
+ continue;
7046
+ }
7047
+ if (!rootStat.isDirectory()) {
7048
+ throw new Error(`[device-credential-isolation] authority root is not a directory: ${rawRoot}`);
7049
+ }
7050
+ processedRoots.add(root);
7051
+ const authorityDirectory = join(root, DEVICE_AUTHORITY_DIRECTORY);
7052
+ try {
7053
+ const authorityStat = lstatSync(authorityDirectory);
7054
+ if (!authorityStat.isDirectory()) {
7055
+ throw new Error(`[device-credential-isolation] device authority path is not a directory: ${authorityDirectory}`);
7056
+ }
7057
+ }
7058
+ catch (error) {
7059
+ if (error.code !== 'ENOENT')
7060
+ throw error;
7061
+ // A fixed empty mount target lets the child mask the entire authority
7062
+ // namespace while leaving BOTMUX_HOME itself live and writable.
7063
+ mkdirSync(authorityDirectory, { mode: 0o700 });
7064
+ }
7065
+ hideDirectories.add(realpathSync(authorityDirectory));
7066
+ for (const name of readdirSync(root)) {
7067
+ if (name === DEVICE_AUTHORITY_DIRECTORY)
7068
+ continue;
7069
+ if (!isCredentialIsolationReservedBasename(name)
7070
+ && !isDashboardAuthorityBasename(name))
7071
+ continue;
7072
+ const entry = join(root, name);
7073
+ try {
7074
+ const stat = lstatSync(entry);
7075
+ if (!stat.isFile()) {
7076
+ // Never follow a host-authority symlink or special file. Exact mask
7077
+ // destinations must be stable regular files or the launch fails
7078
+ // closed before an untrusted CLI starts.
7079
+ throw new Error(`[device-credential-isolation] authority entry is not a regular file: ${entry}`);
7080
+ }
7081
+ hideFiles.add(entry);
7082
+ }
7083
+ catch (error) {
7084
+ if (error.code !== 'ENOENT')
7085
+ throw error;
7086
+ // A racing removal needs no mask; a later exact credential rotation
7087
+ // is host-only and the long-lived device secret namespace itself is
7088
+ // protected wholesale above.
7089
+ }
7090
+ }
7091
+ }
7092
+ let credentialCliBin = spawnBin;
7093
+ try {
7094
+ credentialCliBin = realpathSync(spawnBin);
7095
+ }
7096
+ catch { /* spawn will fail closed if unresolved */ }
7097
+ const credentialSandbox = prepareCredentialOnlySandbox({
7098
+ hideDirectories: [...hideDirectories],
7099
+ hideFiles: [...hideFiles],
7100
+ readonlyPaths: [realpathSync(panePolicyDir)],
7101
+ workingDir: spawnCwd,
7102
+ cliBin: credentialCliBin,
7103
+ cliArgs: spawnArgs,
7104
+ });
7105
+ if (!credentialSandbox) {
7106
+ throw new Error(`[device-credential-isolation] refusing to start session ${cfg.sessionId}: `
7107
+ + 'credential-only bubblewrap could not be established');
7108
+ }
7109
+ spawnBin = credentialSandbox.bin;
7110
+ spawnArgs = credentialSandbox.args;
7111
+ log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only bwrap `
7112
+ + `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s))`);
7113
+ }
6839
7114
  backend.spawn(spawnBin, spawnArgs, {
6840
7115
  cwd: spawnCwd,
6841
7116
  cols: PTY_COLS,
@@ -6848,6 +7123,7 @@ async function spawnCli(cfg, opts = {}) {
6848
7123
  // can verify they were spawned inside a botmux session by walking the
6849
7124
  // process tree and looking for a matching pid file in this directory.
6850
7125
  const cliPid = backend.getChildPid?.();
7126
+ publishLocalProcessAttestation(cliPid ?? undefined);
6851
7127
  if (cliPid && process.env.SESSION_DATA_DIR) {
6852
7128
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
6853
7129
  try {
@@ -6890,6 +7166,7 @@ async function spawnCli(cfg, opts = {}) {
6890
7166
  // Per-tick maybeFollowSessionRotationViaPid (bridge 1s poller) reads the
6891
7167
  // module-level bridgeCliPid and re-points to the real CLI's jsonl.
6892
7168
  bridgeCliPid = realPid;
7169
+ publishLocalProcessAttestation(realPid);
6893
7170
  },
6894
7171
  schedule: (fn, ms) => { setTimeout(fn, ms); },
6895
7172
  });
@@ -6924,6 +7201,7 @@ async function spawnCli(cfg, opts = {}) {
6924
7201
  return;
6925
7202
  const pid = backend.getChildPid?.();
6926
7203
  if (pid) {
7204
+ publishLocalProcessAttestation(pid);
6927
7205
  if (process.env.SESSION_DATA_DIR && !cliPidMarker) {
6928
7206
  try {
6929
7207
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
@@ -7090,6 +7368,7 @@ async function spawnCli(cfg, opts = {}) {
7090
7368
  }
7091
7369
  isSettlingFirstFlush = false;
7092
7370
  promptReadyDetectedDuringSettle = false;
7371
+ readyPatternSeenDuringHold = false;
7093
7372
  // Reset quiescence baseline so the settle measures silence from THIS spawn.
7094
7373
  lastPtyOutputAtMs = Date.now();
7095
7374
  const readyHookAvailable = effectiveReadyHookInstall
@@ -7315,8 +7594,23 @@ async function spawnCli(cfg, opts = {}) {
7315
7594
  // invoking markPromptReady() would claim the CLI is idle while it's still
7316
7595
  // mid-boot, so flushPending() alone is safer — it respects typeAheadAllowed
7317
7596
  // and drains pendingMessages now.
7318
- if (cliAdapter?.supportsTypeAhead)
7597
+ //
7598
+ // Non-type-ahead adapters (Hermes etc.) flushPending() rejects the held
7599
+ // message while isPromptReady is false — it bails on
7600
+ // `!isPromptReady && !typeAheadAllowed`. The hard cap means we've waited
7601
+ // long enough. By now the ready gate's 45s fallback has already released
7602
+ // the gate (READY_SIGNAL_TIMEOUT_MS < this 90s hard cap) and the post-
7603
+ // release settle has drained, so markPromptReady() proceeds: it sets
7604
+ // isPromptReady and drains the held first prompt. Without this, a spawn
7605
+ // that never fires the ready signal (and whose readyPattern the idle
7606
+ // detector never matched) would hold the first queued message forever —
7607
+ // the previous code only logged "forcing flush" without actually flushing
7608
+ // for non-type-ahead adapters.
7609
+ if (decideHardTimeoutAction(cliAdapter?.supportsTypeAhead === true) === 'flush') {
7319
7610
  flushPending();
7611
+ return;
7612
+ }
7613
+ markPromptReady();
7320
7614
  };
7321
7615
  setTimeout(() => releaseFirstPromptTimeout(FIRST_PROMPT_TIMEOUT_MS, false), FIRST_PROMPT_TIMEOUT_MS);
7322
7616
  // Riff (and other remote HTTP backends) have no local boot process — the
@@ -7329,6 +7623,7 @@ async function spawnCli(cfg, opts = {}) {
7329
7623
  }
7330
7624
  }
7331
7625
  function killCli(opts = {}) {
7626
+ currentCliCredentialIsolated = false;
7332
7627
  stopSessionMcpGatewayHost();
7333
7628
  stopCodexRpcEngine();
7334
7629
  destroyCrashDiagnosticTerminal('killCli');
@@ -7347,6 +7642,7 @@ function killCli(opts = {}) {
7347
7642
  }
7348
7643
  isSettlingFirstFlush = false;
7349
7644
  promptReadyDetectedDuringSettle = false;
7645
+ readyPatternSeenDuringHold = false;
7350
7646
  stopScreenAnalyzer();
7351
7647
  stopScreenUpdates();
7352
7648
  backend?.kill();
@@ -8639,6 +8935,16 @@ function send(msg) {
8639
8935
  }
8640
8936
  process.send?.(payload);
8641
8937
  }
8938
+ function publishLocalProcessAttestation(cliPid) {
8939
+ const cliProcStart = cliPid ? readProcessStartIdentity(cliPid) : undefined;
8940
+ send({
8941
+ type: 'local_process_attestation',
8942
+ backendType: effectiveBackendType,
8943
+ credentialIsolated: currentCliCredentialIsolated,
8944
+ ...(cliPid ? { cliPid } : {}),
8945
+ ...(cliProcStart ? { cliProcStart } : {}),
8946
+ });
8947
+ }
8642
8948
  /** Deliver a terminal IPC message before exiting the worker. `process.send()`
8643
8949
  * only queues asynchronously; calling process.exit() on the next line can drop
8644
8950
  * the exact startup diagnostic the user needs and recreate the silent-bot bug.