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.
- package/dist/adapters/backend/sandbox.d.ts +47 -0
- package/dist/adapters/backend/sandbox.d.ts.map +1 -1
- package/dist/adapters/backend/sandbox.js +137 -0
- package/dist/adapters/backend/sandbox.js.map +1 -1
- package/dist/adapters/cli/read-isolation.d.ts +72 -3
- package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
- package/dist/adapters/cli/read-isolation.js +143 -6
- package/dist/adapters/cli/read-isolation.js.map +1 -1
- package/dist/adapters/cli/registry.d.ts.map +1 -1
- package/dist/adapters/cli/registry.js +8 -2
- package/dist/adapters/cli/registry.js.map +1 -1
- package/dist/cli/pm2-command.d.ts.map +1 -1
- package/dist/cli/pm2-command.js +6 -0
- package/dist/cli/pm2-command.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +13 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +21 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/device-isolation-activation.d.ts +56 -0
- package/dist/core/device-isolation-activation.d.ts.map +1 -0
- package/dist/core/device-isolation-activation.js +110 -0
- package/dist/core/device-isolation-activation.js.map +1 -0
- package/dist/core/device-isolation-daemon.d.ts +84 -0
- package/dist/core/device-isolation-daemon.d.ts.map +1 -0
- package/dist/core/device-isolation-daemon.js +591 -0
- package/dist/core/device-isolation-daemon.js.map +1 -0
- package/dist/core/types.d.ts +9 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +35 -0
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +5 -0
- package/dist/daemon.js.map +1 -1
- package/dist/desktop/main/auto-start.d.ts +2 -0
- package/dist/desktop/main/auto-start.d.ts.map +1 -1
- package/dist/desktop/main/auto-start.js +10 -6
- package/dist/desktop/main/auto-start.js.map +1 -1
- package/dist/desktop/main/bundled-runtime.d.ts +12 -0
- package/dist/desktop/main/bundled-runtime.d.ts.map +1 -0
- package/dist/desktop/main/bundled-runtime.js +30 -0
- package/dist/desktop/main/bundled-runtime.js.map +1 -0
- package/dist/desktop/main/device-status.d.ts +15 -0
- package/dist/desktop/main/device-status.d.ts.map +1 -0
- package/dist/desktop/main/device-status.js +94 -0
- package/dist/desktop/main/device-status.js.map +1 -0
- package/dist/desktop/main/external-runtime.d.ts +9 -0
- package/dist/desktop/main/external-runtime.d.ts.map +1 -1
- package/dist/desktop/main/external-runtime.js +88 -18
- package/dist/desktop/main/external-runtime.js.map +1 -1
- package/dist/desktop/main/ipc.d.ts.map +1 -1
- package/dist/desktop/main/ipc.js +1 -0
- package/dist/desktop/main/ipc.js.map +1 -1
- package/dist/desktop/main/node-command.d.ts +21 -0
- package/dist/desktop/main/node-command.d.ts.map +1 -1
- package/dist/desktop/main/node-command.js +41 -0
- package/dist/desktop/main/node-command.js.map +1 -1
- package/dist/desktop/main/pm2-apps.d.ts +4 -2
- package/dist/desktop/main/pm2-apps.d.ts.map +1 -1
- package/dist/desktop/main/pm2-apps.js +11 -3
- package/dist/desktop/main/pm2-apps.js.map +1 -1
- package/dist/desktop/main/runtime-service.d.ts +21 -5
- package/dist/desktop/main/runtime-service.d.ts.map +1 -1
- package/dist/desktop/main/runtime-service.js +94 -10
- package/dist/desktop/main/runtime-service.js.map +1 -1
- package/dist/desktop/main/runtime-source.d.ts +2 -0
- package/dist/desktop/main/runtime-source.d.ts.map +1 -1
- package/dist/desktop/main/runtime-source.js +12 -6
- package/dist/desktop/main/runtime-source.js.map +1 -1
- package/dist/desktop/main/tray.d.ts +1 -0
- package/dist/desktop/main/tray.d.ts.map +1 -1
- package/dist/desktop/main/tray.js +3 -3
- package/dist/desktop/main/tray.js.map +1 -1
- package/dist/desktop/main.js +28 -8
- package/dist/desktop/main.js.map +1 -1
- package/dist/desktop/preload.js +3 -0
- package/dist/desktop/preload.js.map +1 -1
- package/dist/desktop/renderer/app.d.ts +2 -1
- package/dist/desktop/renderer/app.d.ts.map +1 -1
- package/dist/desktop/renderer/app.js +6 -1
- package/dist/desktop/renderer/app.js.map +1 -1
- package/dist/desktop/renderer/runtime-state.d.ts.map +1 -1
- package/dist/desktop/renderer/runtime-state.js +2 -0
- package/dist/desktop/renderer/runtime-state.js.map +1 -1
- package/dist/desktop/shared/shell-path-probes.d.ts +24 -0
- package/dist/desktop/shared/shell-path-probes.d.ts.map +1 -0
- package/dist/desktop/shared/shell-path-probes.js +38 -0
- package/dist/desktop/shared/shell-path-probes.js.map +1 -0
- package/dist/desktop/shared/types.d.ts +21 -1
- package/dist/desktop/shared/types.d.ts.map +1 -1
- package/dist/desktop/smoke.d.ts +1 -0
- package/dist/desktop/smoke.d.ts.map +1 -1
- package/dist/desktop/smoke.js +20 -5
- package/dist/desktop/smoke.js.map +1 -1
- package/dist/platform/bind.d.ts +4 -1
- package/dist/platform/bind.d.ts.map +1 -1
- package/dist/platform/bind.js +10 -1
- package/dist/platform/bind.js.map +1 -1
- package/dist/platform/binding.d.ts.map +1 -1
- package/dist/platform/binding.js +15 -9
- package/dist/platform/binding.js.map +1 -1
- package/dist/platform/device-command.d.ts +35 -0
- package/dist/platform/device-command.d.ts.map +1 -0
- package/dist/platform/device-command.js +187 -0
- package/dist/platform/device-command.js.map +1 -0
- package/dist/platform/device-enroll.d.ts +156 -0
- package/dist/platform/device-enroll.d.ts.map +1 -0
- package/dist/platform/device-enroll.js +781 -0
- package/dist/platform/device-enroll.js.map +1 -0
- package/dist/platform/device-isolation-activation-client.d.ts +28 -0
- package/dist/platform/device-isolation-activation-client.d.ts.map +1 -0
- package/dist/platform/device-isolation-activation-client.js +279 -0
- package/dist/platform/device-isolation-activation-client.js.map +1 -0
- package/dist/platform/device-isolation.d.ts +35 -0
- package/dist/platform/device-isolation.d.ts.map +1 -0
- package/dist/platform/device-isolation.js +90 -0
- package/dist/platform/device-isolation.js.map +1 -0
- package/dist/platform/device-paths.d.ts +9 -0
- package/dist/platform/device-paths.d.ts.map +1 -0
- package/dist/platform/device-paths.js +14 -0
- package/dist/platform/device-paths.js.map +1 -0
- package/dist/platform/device.d.ts +77 -0
- package/dist/platform/device.d.ts.map +1 -0
- package/dist/platform/device.js +212 -0
- package/dist/platform/device.js.map +1 -0
- package/dist/platform/host-command-context.d.ts +15 -0
- package/dist/platform/host-command-context.d.ts.map +1 -0
- package/dist/platform/host-command-context.js +26 -0
- package/dist/platform/host-command-context.js.map +1 -0
- package/dist/platform/secure-host-file.d.ts +12 -0
- package/dist/platform/secure-host-file.d.ts.map +1 -0
- package/dist/platform/secure-host-file.js +182 -0
- package/dist/platform/secure-host-file.js.map +1 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/atomic-write.d.ts +13 -0
- package/dist/utils/atomic-write.d.ts.map +1 -1
- package/dist/utils/atomic-write.js +89 -9
- package/dist/utils/atomic-write.js.map +1 -1
- package/dist/worker.js +289 -19
- package/dist/worker.js.map +1 -1
- package/dist/workflows/events/payloads.d.ts +4 -4
- package/dist/workflows/events/schema.d.ts +16 -16
- 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 (
|
|
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(
|
|
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
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
6701
|
+
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
|
|
6583
6702
|
try {
|
|
6584
|
-
const markerDir = join(
|
|
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.
|