botmux 3.3.0 → 3.4.0

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 +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/pm2-command.d.ts.map +1 -1
  13. package/dist/cli/pm2-command.js +6 -0
  14. package/dist/cli/pm2-command.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +13 -0
  17. package/dist/cli.js.map +1 -1
  18. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  19. package/dist/core/dashboard-ipc-server.js +21 -0
  20. package/dist/core/dashboard-ipc-server.js.map +1 -1
  21. package/dist/core/device-isolation-activation.d.ts +56 -0
  22. package/dist/core/device-isolation-activation.d.ts.map +1 -0
  23. package/dist/core/device-isolation-activation.js +110 -0
  24. package/dist/core/device-isolation-activation.js.map +1 -0
  25. package/dist/core/device-isolation-daemon.d.ts +84 -0
  26. package/dist/core/device-isolation-daemon.d.ts.map +1 -0
  27. package/dist/core/device-isolation-daemon.js +591 -0
  28. package/dist/core/device-isolation-daemon.js.map +1 -0
  29. package/dist/core/types.d.ts +9 -0
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/core/types.js.map +1 -1
  32. package/dist/core/worker-pool.d.ts.map +1 -1
  33. package/dist/core/worker-pool.js +35 -0
  34. package/dist/core/worker-pool.js.map +1 -1
  35. package/dist/daemon.d.ts.map +1 -1
  36. package/dist/daemon.js +5 -0
  37. package/dist/daemon.js.map +1 -1
  38. package/dist/desktop/main/auto-start.d.ts +2 -0
  39. package/dist/desktop/main/auto-start.d.ts.map +1 -1
  40. package/dist/desktop/main/auto-start.js +10 -6
  41. package/dist/desktop/main/auto-start.js.map +1 -1
  42. package/dist/desktop/main/bundled-runtime.d.ts +12 -0
  43. package/dist/desktop/main/bundled-runtime.d.ts.map +1 -0
  44. package/dist/desktop/main/bundled-runtime.js +30 -0
  45. package/dist/desktop/main/bundled-runtime.js.map +1 -0
  46. package/dist/desktop/main/device-status.d.ts +15 -0
  47. package/dist/desktop/main/device-status.d.ts.map +1 -0
  48. package/dist/desktop/main/device-status.js +94 -0
  49. package/dist/desktop/main/device-status.js.map +1 -0
  50. package/dist/desktop/main/external-runtime.d.ts +9 -0
  51. package/dist/desktop/main/external-runtime.d.ts.map +1 -1
  52. package/dist/desktop/main/external-runtime.js +88 -18
  53. package/dist/desktop/main/external-runtime.js.map +1 -1
  54. package/dist/desktop/main/ipc.d.ts.map +1 -1
  55. package/dist/desktop/main/ipc.js +1 -0
  56. package/dist/desktop/main/ipc.js.map +1 -1
  57. package/dist/desktop/main/node-command.d.ts +21 -0
  58. package/dist/desktop/main/node-command.d.ts.map +1 -1
  59. package/dist/desktop/main/node-command.js +41 -0
  60. package/dist/desktop/main/node-command.js.map +1 -1
  61. package/dist/desktop/main/pm2-apps.d.ts +4 -2
  62. package/dist/desktop/main/pm2-apps.d.ts.map +1 -1
  63. package/dist/desktop/main/pm2-apps.js +11 -3
  64. package/dist/desktop/main/pm2-apps.js.map +1 -1
  65. package/dist/desktop/main/runtime-service.d.ts +21 -5
  66. package/dist/desktop/main/runtime-service.d.ts.map +1 -1
  67. package/dist/desktop/main/runtime-service.js +94 -10
  68. package/dist/desktop/main/runtime-service.js.map +1 -1
  69. package/dist/desktop/main/runtime-source.d.ts +2 -0
  70. package/dist/desktop/main/runtime-source.d.ts.map +1 -1
  71. package/dist/desktop/main/runtime-source.js +12 -6
  72. package/dist/desktop/main/runtime-source.js.map +1 -1
  73. package/dist/desktop/main/tray.d.ts +1 -0
  74. package/dist/desktop/main/tray.d.ts.map +1 -1
  75. package/dist/desktop/main/tray.js +3 -3
  76. package/dist/desktop/main/tray.js.map +1 -1
  77. package/dist/desktop/main.js +28 -8
  78. package/dist/desktop/main.js.map +1 -1
  79. package/dist/desktop/preload.js +3 -0
  80. package/dist/desktop/preload.js.map +1 -1
  81. package/dist/desktop/renderer/app.d.ts +2 -1
  82. package/dist/desktop/renderer/app.d.ts.map +1 -1
  83. package/dist/desktop/renderer/app.js +6 -1
  84. package/dist/desktop/renderer/app.js.map +1 -1
  85. package/dist/desktop/renderer/runtime-state.d.ts.map +1 -1
  86. package/dist/desktop/renderer/runtime-state.js +2 -0
  87. package/dist/desktop/renderer/runtime-state.js.map +1 -1
  88. package/dist/desktop/shared/shell-path-probes.d.ts +24 -0
  89. package/dist/desktop/shared/shell-path-probes.d.ts.map +1 -0
  90. package/dist/desktop/shared/shell-path-probes.js +38 -0
  91. package/dist/desktop/shared/shell-path-probes.js.map +1 -0
  92. package/dist/desktop/shared/types.d.ts +21 -1
  93. package/dist/desktop/shared/types.d.ts.map +1 -1
  94. package/dist/desktop/smoke.d.ts +1 -0
  95. package/dist/desktop/smoke.d.ts.map +1 -1
  96. package/dist/desktop/smoke.js +20 -5
  97. package/dist/desktop/smoke.js.map +1 -1
  98. package/dist/platform/bind.d.ts +4 -1
  99. package/dist/platform/bind.d.ts.map +1 -1
  100. package/dist/platform/bind.js +10 -1
  101. package/dist/platform/bind.js.map +1 -1
  102. package/dist/platform/binding.d.ts.map +1 -1
  103. package/dist/platform/binding.js +15 -9
  104. package/dist/platform/binding.js.map +1 -1
  105. package/dist/platform/device-command.d.ts +35 -0
  106. package/dist/platform/device-command.d.ts.map +1 -0
  107. package/dist/platform/device-command.js +187 -0
  108. package/dist/platform/device-command.js.map +1 -0
  109. package/dist/platform/device-enroll.d.ts +156 -0
  110. package/dist/platform/device-enroll.d.ts.map +1 -0
  111. package/dist/platform/device-enroll.js +781 -0
  112. package/dist/platform/device-enroll.js.map +1 -0
  113. package/dist/platform/device-isolation-activation-client.d.ts +28 -0
  114. package/dist/platform/device-isolation-activation-client.d.ts.map +1 -0
  115. package/dist/platform/device-isolation-activation-client.js +279 -0
  116. package/dist/platform/device-isolation-activation-client.js.map +1 -0
  117. package/dist/platform/device-isolation.d.ts +35 -0
  118. package/dist/platform/device-isolation.d.ts.map +1 -0
  119. package/dist/platform/device-isolation.js +90 -0
  120. package/dist/platform/device-isolation.js.map +1 -0
  121. package/dist/platform/device-paths.d.ts +9 -0
  122. package/dist/platform/device-paths.d.ts.map +1 -0
  123. package/dist/platform/device-paths.js +14 -0
  124. package/dist/platform/device-paths.js.map +1 -0
  125. package/dist/platform/device.d.ts +77 -0
  126. package/dist/platform/device.d.ts.map +1 -0
  127. package/dist/platform/device.js +212 -0
  128. package/dist/platform/device.js.map +1 -0
  129. package/dist/platform/host-command-context.d.ts +15 -0
  130. package/dist/platform/host-command-context.d.ts.map +1 -0
  131. package/dist/platform/host-command-context.js +26 -0
  132. package/dist/platform/host-command-context.js.map +1 -0
  133. package/dist/platform/secure-host-file.d.ts +12 -0
  134. package/dist/platform/secure-host-file.d.ts.map +1 -0
  135. package/dist/platform/secure-host-file.js +182 -0
  136. package/dist/platform/secure-host-file.js.map +1 -0
  137. package/dist/types.d.ts +9 -0
  138. package/dist/types.d.ts.map +1 -1
  139. package/dist/utils/atomic-write.d.ts +13 -0
  140. package/dist/utils/atomic-write.d.ts.map +1 -1
  141. package/dist/utils/atomic-write.js +89 -9
  142. package/dist/utils/atomic-write.js.map +1 -1
  143. package/dist/worker.js +289 -19
  144. package/dist/worker.js.map +1 -1
  145. package/dist/workflows/events/payloads.d.ts +4 -4
  146. package/dist/workflows/events/schema.d.ts +16 -16
  147. package/package.json +6 -3
package/dist/worker.js CHANGED
@@ -13,12 +13,12 @@
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';
@@ -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. */
@@ -5581,6 +5586,40 @@ function scheduleBusyPatternIdleProbe(source) {
5581
5586
  }
5582
5587
  async function spawnCli(cfg, opts = {}) {
5583
5588
  clearSessionRenameInFlight();
5589
+ currentCliCredentialIsolated = false;
5590
+ // Enrollment writes the fixed marker before any device credential appears.
5591
+ // From that instant onward every NEW local CLI must carry a credential
5592
+ // boundary, regardless of adapter capability or optional sandbox toggles.
5593
+ // lstat (not existsSync) deliberately treats a hostile/broken symlink as a
5594
+ // present authority signal and therefore fails closed.
5595
+ const hostHomeDir = homedir();
5596
+ const defaultBotmuxHome = join(hostHomeDir, '.botmux');
5597
+ const configuredBotmuxHome = process.env.SESSION_DATA_DIR
5598
+ ? dirname(process.env.SESSION_DATA_DIR)
5599
+ : defaultBotmuxHome;
5600
+ const hostEntryExistsNoFollow = (path) => {
5601
+ try {
5602
+ lstatSync(path);
5603
+ return true;
5604
+ }
5605
+ catch {
5606
+ return false;
5607
+ }
5608
+ };
5609
+ const deviceIsolationMarkerExists = hostEntryExistsNoFollow(deviceCredentialIsolationMarkerPath(hostHomeDir));
5610
+ const deviceCredentialExists = [...new Set([defaultBotmuxHome, configuredBotmuxHome])]
5611
+ .some(root => hostEntryExistsNoFollow(join(root, DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE))
5612
+ // Upgrade fail-safe: a pre-dedicated-directory credential still activates
5613
+ // mandatory confinement until the host explicitly removes/migrates it.
5614
+ || hostEntryExistsNoFollow(join(root, DEVICE_CREDENTIAL_FILE)));
5615
+ const mandatoryCredentialIsolation = credentialIsolationRequired({
5616
+ markerExists: deviceIsolationMarkerExists,
5617
+ deviceCredentialExists,
5618
+ });
5619
+ if (mandatoryCredentialIsolation && cfg.adoptMode) {
5620
+ throw new Error(`[device-credential-isolation] refusing adopt session ${cfg.sessionId}: `
5621
+ + 'an already-running external CLI cannot be retrofitted with the mandatory credential boundary');
5622
+ }
5584
5623
  // (startupCommands one-shot is re-armed below, AFTER the reattach-vs-fresh
5585
5624
  // prediction — only a genuinely fresh CLI process replays them; see
5586
5625
  // willReattachPersistent.)
@@ -5940,6 +5979,56 @@ async function spawnCli(cfg, opts = {}) {
5940
5979
  // established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the
5941
5980
  // spawn site below, never a silent unconfined run.
5942
5981
  const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox;
5982
+ if (willWriteSandbox && !process.env.SESSION_DATA_DIR) {
5983
+ throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: missing SESSION_DATA_DIR`);
5984
+ }
5985
+ const fullIsolationCoversCredentials = !riffRemoteBackend && ((process.platform === 'darwin' && (willReadIsolate || willWriteSandbox))
5986
+ || (process.platform === 'linux' && wantsFileSandbox));
5987
+ let credentialMechanismAvailable = true;
5988
+ let credentialMechanismExecutable;
5989
+ if (mandatoryCredentialIsolation && !riffRemoteBackend && !fullIsolationCoversCredentials) {
5990
+ if (process.platform === 'darwin') {
5991
+ const probe = probeHostCredentialIsolationMechanism();
5992
+ credentialMechanismAvailable = probe.supported;
5993
+ if (probe.supported)
5994
+ credentialMechanismExecutable = probe.executable;
5995
+ }
5996
+ else {
5997
+ credentialMechanismAvailable = process.platform === 'linux'
5998
+ ? credentialOnlySandboxAvailable()
5999
+ : false;
6000
+ }
6001
+ }
6002
+ const credentialIsolationGate = evaluateCredentialOnlyIsolationGate({
6003
+ markerExists: deviceIsolationMarkerExists,
6004
+ deviceCredentialExists,
6005
+ remoteBackend: riffRemoteBackend,
6006
+ platform: process.platform,
6007
+ mechanismAvailable: credentialMechanismAvailable,
6008
+ fullIsolationCoversCredentials,
6009
+ });
6010
+ if (credentialIsolationGate.mode === 'blocked') {
6011
+ throw new Error(`[device-credential-isolation] refusing to start session ${cfg.sessionId}: `
6012
+ + credentialIsolationGate.failClosedReason);
6013
+ }
6014
+ const credentialBoundaryActive = credentialIsolationGate.required
6015
+ && credentialIsolationGate.mode !== 'remote-bypass';
6016
+ const credentialOnlySeatbelt = credentialIsolationGate.mode === 'seatbelt';
6017
+ const credentialOnlyBwrap = credentialIsolationGate.mode === 'bwrap';
6018
+ const appliedIsolationCapabilities = [];
6019
+ if (credentialBoundaryActive
6020
+ || willReadIsolate
6021
+ || (process.platform === 'linux' && wantsFileSandbox)) {
6022
+ appliedIsolationCapabilities.push('credential');
6023
+ }
6024
+ if (willReadIsolate)
6025
+ appliedIsolationCapabilities.push('read');
6026
+ if (willWriteSandbox || (process.platform === 'linux' && wantsFileSandbox)) {
6027
+ appliedIsolationCapabilities.push('write');
6028
+ }
6029
+ currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
6030
+ const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
6031
+ ?? join(defaultBotmuxHome, 'data');
5943
6032
  // Every bot — isolated OR not — gets its own BOT_HOME dir as a ready-made private-
5944
6033
  // storage slot. An isolated sibling denies this path regardless of whether the owner
5945
6034
  // is isolated (deny uses the full bots.json), so a non-isolated bot can drop private
@@ -6021,7 +6110,7 @@ async function spawnCli(cfg, opts = {}) {
6021
6110
  // so the probe below sees no pane and we cold-spawn fresh isolated. A current-
6022
6111
  // policy pane survives daemon restarts and suspend→resume safely because the
6023
6112
  // confinement remains attached to the live process.
6024
- if ((willReadIsolate || willWriteSandbox) && persistentSessionName && effectiveBackendType !== 'pty') {
6113
+ if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
6025
6114
  const paneLive = effectiveBackendType === 'tmux'
6026
6115
  ? TmuxBackend.hasSession(persistentSessionName)
6027
6116
  : effectiveBackendType === 'zellij'
@@ -6029,8 +6118,8 @@ async function spawnCli(cfg, opts = {}) {
6029
6118
  : HerdrBackend.hasSession(persistentSessionName);
6030
6119
  if (paneLive) {
6031
6120
  let marker = null;
6032
- marker = readRegularHostFileNoFollow(join(process.env.SESSION_DATA_DIR ?? '', 'read-isolation', `${cfg.sessionId}.boot`));
6033
- if (isolatedPaneReattachSafe(marker)) {
6121
+ marker = readRegularHostFileNoFollow(join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`));
6122
+ if (isolatedPaneReattachSafe(marker, appliedIsolationCapabilities)) {
6034
6123
  // Pane was spawned under the current isolation policy → still confined
6035
6124
  // on the running process across daemon restarts; warm reattach preserves
6036
6125
  // resume/context + tmux idle-suspend.
@@ -6270,16 +6359,20 @@ async function spawnCli(cfg, opts = {}) {
6270
6359
  const sessionDataDir = process.env.SESSION_DATA_DIR;
6271
6360
  // Regex rules cannot be realpath'd after construction, so build their home
6272
6361
  // 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
- } })();
6362
+ const canonicalSandboxPath = (path) => {
6363
+ try {
6364
+ return realpathSync(path);
6365
+ }
6366
+ catch {
6367
+ return path;
6368
+ }
6369
+ };
6370
+ const sandboxHome = canonicalSandboxPath(homedir());
6279
6371
  writeSandboxRules = buildWriteSandboxRules({
6280
6372
  homeDir: sandboxHome,
6281
- botmuxHome: dirname(sessionDataDir),
6282
- sessionDataDir,
6373
+ botmuxHome: canonicalSandboxPath(dirname(sessionDataDir)),
6374
+ defaultBotmuxHome: canonicalSandboxPath(join(homedir(), '.botmux')),
6375
+ sessionDataDir: canonicalSandboxPath(sessionDataDir),
6283
6376
  workingDir: cfg.workingDir,
6284
6377
  currentAppId: cfg.larkAppId,
6285
6378
  // TMPDIR on macOS resolves under /private/var/folders (already allowed), but
@@ -6536,6 +6629,31 @@ async function spawnCli(cfg, opts = {}) {
6536
6629
  sessionDataDirs: [readIsolationCtx.sessionDataDir],
6537
6630
  });
6538
6631
  }
6632
+ if (credentialBoundaryActive) {
6633
+ const credentialRules = buildCredentialIsolationRules({
6634
+ homeDir: canonical(hostHomeDir),
6635
+ botmuxHome: canonical(configuredBotmuxHome),
6636
+ defaultBotmuxHome: canonical(defaultBotmuxHome),
6637
+ });
6638
+ denyPaths = [...new Set([...denyPaths, ...credentialRules.denyPaths.map(canonical)])];
6639
+ denyRegexes = [...new Set([...denyRegexes, ...credentialRules.denyRegexes])];
6640
+ protectedWrites = {
6641
+ denyWritePaths: [...new Set([
6642
+ ...(protectedWrites?.denyWritePaths ?? []),
6643
+ ...credentialRules.denyWritePaths.map(canonical),
6644
+ ])],
6645
+ denyWriteRegexes: [...new Set([
6646
+ ...(protectedWrites?.denyWriteRegexes ?? []),
6647
+ ...credentialRules.denyWriteRegexes,
6648
+ ])],
6649
+ denyWriteLiterals: [...new Set([
6650
+ ...(protectedWrites?.denyWriteLiterals ?? []),
6651
+ ...credentialRules.denyWriteLiterals.map(canonical),
6652
+ defaultBotmuxHome,
6653
+ configuredBotmuxHome,
6654
+ ])],
6655
+ };
6656
+ }
6539
6657
  if (mcpRuntimeManifest) {
6540
6658
  finalDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
6541
6659
  }
@@ -6561,29 +6679,30 @@ async function spawnCli(cfg, opts = {}) {
6561
6679
  allowWritePaths: writeSandboxRules.allowWritePaths.map(canonical),
6562
6680
  allowWriteRegexes: writeSandboxRules.allowWriteRegexes,
6563
6681
  denyWritePaths: writeSandboxRules.denyWritePaths.map(canonical),
6682
+ denyWriteRegexes: writeSandboxRules.denyWriteRegexes,
6564
6683
  }
6565
6684
  : undefined;
6566
6685
  if (!locateOnPath('sandbox-exec')) {
6567
6686
  throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
6568
6687
  }
6569
- const profileDir = join(process.env.SESSION_DATA_DIR, 'read-isolation');
6688
+ const profileDir = join(isolationRuntimeDataDir, 'read-isolation');
6570
6689
  mkdirSync(profileDir, { recursive: true });
6571
6690
  const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
6572
6691
  replaceManagedOriginCapabilityFile(profilePath, buildSeatbeltProfile(denyPaths, allowPaths, finalDenyPaths, traverseDirs, denyRegexes, writeRules, protectedWrites));
6573
6692
  seatbeltProfilePath = profilePath;
6574
6693
  spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
6575
- spawnBin = 'sandbox-exec';
6694
+ spawnBin = credentialMechanismExecutable ?? '/usr/bin/sandbox-exec';
6576
6695
  log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (read-isolation=${!!readIsolationCtx}, write-sandbox=${!!writeRules}): sandbox-exec -f ${profilePath}`);
6577
6696
  }
6578
6697
  // Fresh sandboxed spawn on a persistent backend: stamp the pane with this daemon's
6579
6698
  // policy version + boot id so a later suspend→resume reattach can be trusted (see the stale-pane
6580
6699
  // guard above). Applies to read-isolation AND write-sandbox panes (both carry the
6581
6700
  // Seatbelt confinement on the live process). pty needs no marker (never reattached).
6582
- if ((readIsolationCtx || writeSandboxRules) && persistentSessionName && !willReattachPersistent) {
6701
+ if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
6583
6702
  try {
6584
- const markerDir = join(process.env.SESSION_DATA_DIR, 'read-isolation');
6703
+ const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
6585
6704
  mkdirSync(markerDir, { recursive: true });
6586
- replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? ''));
6705
+ replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities));
6587
6706
  }
6588
6707
  catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
6589
6708
  }
@@ -6836,6 +6955,143 @@ async function spawnCli(cfg, opts = {}) {
6836
6955
  }
6837
6956
  }
6838
6957
  }
6958
+ // Mandatory credential-only confinement is the OUTERMOST launch wrapper so
6959
+ // wrapperCli and every descendant it starts inherit the boundary. Full
6960
+ // Seatbelt/bwrap sessions were already wrapped above and never enter these
6961
+ // branches (gate mode `covered`), avoiding nested/double sandboxes.
6962
+ if (!willReattachPersistent && credentialOnlySeatbelt) {
6963
+ const canonical = (path) => {
6964
+ try {
6965
+ return realpathSync(path);
6966
+ }
6967
+ catch {
6968
+ return path;
6969
+ }
6970
+ };
6971
+ const rules = buildCredentialIsolationRules({
6972
+ homeDir: canonical(hostHomeDir),
6973
+ botmuxHome: canonical(configuredBotmuxHome),
6974
+ defaultBotmuxHome: canonical(defaultBotmuxHome),
6975
+ });
6976
+ const profileDir = join(isolationRuntimeDataDir, 'read-isolation');
6977
+ mkdirSync(profileDir, { recursive: true });
6978
+ const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
6979
+ replaceManagedOriginCapabilityFile(profilePath, buildSeatbeltProfile(rules.denyPaths.map(canonical), [], [], [], rules.denyRegexes, undefined, {
6980
+ denyWritePaths: [...new Set([
6981
+ ...rules.denyWritePaths.map(canonical),
6982
+ canonical(profileDir),
6983
+ ])],
6984
+ denyWriteRegexes: rules.denyWriteRegexes,
6985
+ denyWriteLiterals: [...new Set([
6986
+ ...rules.denyWriteLiterals.map(canonical),
6987
+ canonical(profileDir),
6988
+ defaultBotmuxHome,
6989
+ configuredBotmuxHome,
6990
+ ])],
6991
+ }));
6992
+ seatbeltProfilePath = profilePath;
6993
+ spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
6994
+ // Absolute path only: a pre-activation unconfined CLI could plant a fake
6995
+ // sandbox-exec earlier on PATH. Prefer the probe result; fall back to the
6996
+ // well-known system location used by full Seatbelt wrapping above.
6997
+ spawnBin = credentialMechanismExecutable ?? '/usr/bin/sandbox-exec';
6998
+ log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only Seatbelt: ${spawnBin} -f ${profilePath}`);
6999
+ }
7000
+ if (!willReattachPersistent && credentialOnlyBwrap) {
7001
+ const panePolicyDir = join(isolationRuntimeDataDir, 'read-isolation');
7002
+ mkdirSync(panePolicyDir, { recursive: true });
7003
+ const hideDirectories = new Set();
7004
+ const hideFiles = new Set();
7005
+ const processedRoots = new Set();
7006
+ const isDashboardAuthorityBasename = (name) => name === '.dashboard-secret'
7007
+ || name.startsWith('.dashboard-secret.')
7008
+ || name === '.dashboard-token'
7009
+ || name.startsWith('.dashboard-token.');
7010
+ for (const rawRoot of [defaultBotmuxHome, configuredBotmuxHome]) {
7011
+ let root = rawRoot;
7012
+ try {
7013
+ root = realpathSync(rawRoot);
7014
+ }
7015
+ catch { /* gate authority root is checked below */ }
7016
+ if (processedRoots.has(root))
7017
+ continue;
7018
+ let rootStat;
7019
+ try {
7020
+ rootStat = lstatSync(root);
7021
+ }
7022
+ catch {
7023
+ // An unrelated/default root can be absent when the activation signal
7024
+ // came from a custom BOTMUX_HOME. At least one signalled root must
7025
+ // survive; the builder below rejects an empty root set.
7026
+ continue;
7027
+ }
7028
+ if (!rootStat.isDirectory()) {
7029
+ throw new Error(`[device-credential-isolation] authority root is not a directory: ${rawRoot}`);
7030
+ }
7031
+ processedRoots.add(root);
7032
+ const authorityDirectory = join(root, DEVICE_AUTHORITY_DIRECTORY);
7033
+ try {
7034
+ const authorityStat = lstatSync(authorityDirectory);
7035
+ if (!authorityStat.isDirectory()) {
7036
+ throw new Error(`[device-credential-isolation] device authority path is not a directory: ${authorityDirectory}`);
7037
+ }
7038
+ }
7039
+ catch (error) {
7040
+ if (error.code !== 'ENOENT')
7041
+ throw error;
7042
+ // A fixed empty mount target lets the child mask the entire authority
7043
+ // namespace while leaving BOTMUX_HOME itself live and writable.
7044
+ mkdirSync(authorityDirectory, { mode: 0o700 });
7045
+ }
7046
+ hideDirectories.add(realpathSync(authorityDirectory));
7047
+ for (const name of readdirSync(root)) {
7048
+ if (name === DEVICE_AUTHORITY_DIRECTORY)
7049
+ continue;
7050
+ if (!isCredentialIsolationReservedBasename(name)
7051
+ && !isDashboardAuthorityBasename(name))
7052
+ continue;
7053
+ const entry = join(root, name);
7054
+ try {
7055
+ const stat = lstatSync(entry);
7056
+ if (!stat.isFile()) {
7057
+ // Never follow a host-authority symlink or special file. Exact mask
7058
+ // destinations must be stable regular files or the launch fails
7059
+ // closed before an untrusted CLI starts.
7060
+ throw new Error(`[device-credential-isolation] authority entry is not a regular file: ${entry}`);
7061
+ }
7062
+ hideFiles.add(entry);
7063
+ }
7064
+ catch (error) {
7065
+ if (error.code !== 'ENOENT')
7066
+ throw error;
7067
+ // A racing removal needs no mask; a later exact credential rotation
7068
+ // is host-only and the long-lived device secret namespace itself is
7069
+ // protected wholesale above.
7070
+ }
7071
+ }
7072
+ }
7073
+ let credentialCliBin = spawnBin;
7074
+ try {
7075
+ credentialCliBin = realpathSync(spawnBin);
7076
+ }
7077
+ catch { /* spawn will fail closed if unresolved */ }
7078
+ const credentialSandbox = prepareCredentialOnlySandbox({
7079
+ hideDirectories: [...hideDirectories],
7080
+ hideFiles: [...hideFiles],
7081
+ readonlyPaths: [realpathSync(panePolicyDir)],
7082
+ workingDir: spawnCwd,
7083
+ cliBin: credentialCliBin,
7084
+ cliArgs: spawnArgs,
7085
+ });
7086
+ if (!credentialSandbox) {
7087
+ throw new Error(`[device-credential-isolation] refusing to start session ${cfg.sessionId}: `
7088
+ + 'credential-only bubblewrap could not be established');
7089
+ }
7090
+ spawnBin = credentialSandbox.bin;
7091
+ spawnArgs = credentialSandbox.args;
7092
+ log(`[device-credential-isolation] wrapping ${cliAdapter.id} in credential-only bwrap `
7093
+ + `(${hideDirectories.size} authority dir(s), ${hideFiles.size} exact file(s))`);
7094
+ }
6839
7095
  backend.spawn(spawnBin, spawnArgs, {
6840
7096
  cwd: spawnCwd,
6841
7097
  cols: PTY_COLS,
@@ -6848,6 +7104,7 @@ async function spawnCli(cfg, opts = {}) {
6848
7104
  // can verify they were spawned inside a botmux session by walking the
6849
7105
  // process tree and looking for a matching pid file in this directory.
6850
7106
  const cliPid = backend.getChildPid?.();
7107
+ publishLocalProcessAttestation(cliPid ?? undefined);
6851
7108
  if (cliPid && process.env.SESSION_DATA_DIR) {
6852
7109
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
6853
7110
  try {
@@ -6890,6 +7147,7 @@ async function spawnCli(cfg, opts = {}) {
6890
7147
  // Per-tick maybeFollowSessionRotationViaPid (bridge 1s poller) reads the
6891
7148
  // module-level bridgeCliPid and re-points to the real CLI's jsonl.
6892
7149
  bridgeCliPid = realPid;
7150
+ publishLocalProcessAttestation(realPid);
6893
7151
  },
6894
7152
  schedule: (fn, ms) => { setTimeout(fn, ms); },
6895
7153
  });
@@ -6924,6 +7182,7 @@ async function spawnCli(cfg, opts = {}) {
6924
7182
  return;
6925
7183
  const pid = backend.getChildPid?.();
6926
7184
  if (pid) {
7185
+ publishLocalProcessAttestation(pid);
6927
7186
  if (process.env.SESSION_DATA_DIR && !cliPidMarker) {
6928
7187
  try {
6929
7188
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
@@ -7329,6 +7588,7 @@ async function spawnCli(cfg, opts = {}) {
7329
7588
  }
7330
7589
  }
7331
7590
  function killCli(opts = {}) {
7591
+ currentCliCredentialIsolated = false;
7332
7592
  stopSessionMcpGatewayHost();
7333
7593
  stopCodexRpcEngine();
7334
7594
  destroyCrashDiagnosticTerminal('killCli');
@@ -8639,6 +8899,16 @@ function send(msg) {
8639
8899
  }
8640
8900
  process.send?.(payload);
8641
8901
  }
8902
+ function publishLocalProcessAttestation(cliPid) {
8903
+ const cliProcStart = cliPid ? readProcessStartIdentity(cliPid) : undefined;
8904
+ send({
8905
+ type: 'local_process_attestation',
8906
+ backendType: effectiveBackendType,
8907
+ credentialIsolated: currentCliCredentialIsolated,
8908
+ ...(cliPid ? { cliPid } : {}),
8909
+ ...(cliProcStart ? { cliProcStart } : {}),
8910
+ });
8911
+ }
8642
8912
  /** Deliver a terminal IPC message before exiting the worker. `process.send()`
8643
8913
  * only queues asynchronously; calling process.exit() on the next line can drop
8644
8914
  * the exact startup diagnostic the user needs and recreate the silent-bot bug.