@sysid/sandbox-runtime-improved 0.0.56-sysid.1 → 0.0.64-sysid.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 (129) hide show
  1. package/README.md +106 -4
  2. package/dist/cli.js +12 -18
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +5 -5
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/sandbox/credential-mask-files.d.ts +157 -0
  9. package/dist/sandbox/credential-mask-files.d.ts.map +1 -0
  10. package/dist/sandbox/credential-mask-files.js +298 -0
  11. package/dist/sandbox/credential-mask-files.js.map +1 -0
  12. package/dist/sandbox/credential-sentinel.d.ts +72 -0
  13. package/dist/sandbox/credential-sentinel.d.ts.map +1 -0
  14. package/dist/sandbox/credential-sentinel.js +130 -0
  15. package/dist/sandbox/credential-sentinel.js.map +1 -0
  16. package/dist/sandbox/domain-pattern.d.ts +36 -0
  17. package/dist/sandbox/domain-pattern.d.ts.map +1 -0
  18. package/dist/sandbox/domain-pattern.js +60 -0
  19. package/dist/sandbox/domain-pattern.js.map +1 -0
  20. package/dist/sandbox/http-proxy.d.ts +33 -2
  21. package/dist/sandbox/http-proxy.d.ts.map +1 -1
  22. package/dist/sandbox/http-proxy.js +30 -2
  23. package/dist/sandbox/http-proxy.js.map +1 -1
  24. package/dist/sandbox/linux-sandbox-utils.d.ts +20 -0
  25. package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
  26. package/dist/sandbox/linux-sandbox-utils.js +134 -65
  27. package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
  28. package/dist/sandbox/linux-violation-monitor.d.ts +48 -0
  29. package/dist/sandbox/linux-violation-monitor.d.ts.map +1 -0
  30. package/dist/sandbox/linux-violation-monitor.js +156 -0
  31. package/dist/sandbox/linux-violation-monitor.js.map +1 -0
  32. package/dist/sandbox/listen-in-range.d.ts +12 -0
  33. package/dist/sandbox/listen-in-range.d.ts.map +1 -0
  34. package/dist/sandbox/listen-in-range.js +43 -0
  35. package/dist/sandbox/listen-in-range.js.map +1 -0
  36. package/dist/sandbox/macos-sandbox-utils.d.ts +11 -0
  37. package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
  38. package/dist/sandbox/macos-sandbox-utils.js +72 -17
  39. package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
  40. package/dist/sandbox/mitm-ca.d.ts +69 -2
  41. package/dist/sandbox/mitm-ca.d.ts.map +1 -1
  42. package/dist/sandbox/mitm-ca.js +188 -16
  43. package/dist/sandbox/mitm-ca.js.map +1 -1
  44. package/dist/sandbox/mitm-leaf.d.ts +1 -1
  45. package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
  46. package/dist/sandbox/mitm-leaf.js +26 -19
  47. package/dist/sandbox/mitm-leaf.js.map +1 -1
  48. package/dist/sandbox/mux-proxy.d.ts +59 -0
  49. package/dist/sandbox/mux-proxy.d.ts.map +1 -0
  50. package/dist/sandbox/mux-proxy.js +159 -0
  51. package/dist/sandbox/mux-proxy.js.map +1 -0
  52. package/dist/sandbox/request-filter.d.ts +11 -1
  53. package/dist/sandbox/request-filter.d.ts.map +1 -1
  54. package/dist/sandbox/request-filter.js.map +1 -1
  55. package/dist/sandbox/sandbox-config.d.ts +461 -41
  56. package/dist/sandbox/sandbox-config.d.ts.map +1 -1
  57. package/dist/sandbox/sandbox-config.js +342 -26
  58. package/dist/sandbox/sandbox-config.js.map +1 -1
  59. package/dist/sandbox/sandbox-manager.d.ts +5 -1
  60. package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
  61. package/dist/sandbox/sandbox-manager.js +697 -211
  62. package/dist/sandbox/sandbox-manager.js.map +1 -1
  63. package/dist/sandbox/sandbox-schemas.d.ts +15 -0
  64. package/dist/sandbox/sandbox-schemas.d.ts.map +1 -1
  65. package/dist/sandbox/sandbox-utils.d.ts +55 -6
  66. package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
  67. package/dist/sandbox/sandbox-utils.js +143 -30
  68. package/dist/sandbox/sandbox-utils.js.map +1 -1
  69. package/dist/sandbox/socks-proxy.d.ts +11 -5
  70. package/dist/sandbox/socks-proxy.d.ts.map +1 -1
  71. package/dist/sandbox/socks-proxy.js +13 -81
  72. package/dist/sandbox/socks-proxy.js.map +1 -1
  73. package/dist/sandbox/tls-terminate-proxy.d.ts +2 -2
  74. package/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -1
  75. package/dist/sandbox/tls-terminate-proxy.js +31 -5
  76. package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
  77. package/dist/sandbox/windows-sandbox-utils.d.ts +508 -116
  78. package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
  79. package/dist/sandbox/windows-sandbox-utils.js +721 -209
  80. package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
  81. package/dist/utils/shell-quote.d.ts +27 -0
  82. package/dist/utils/shell-quote.d.ts.map +1 -0
  83. package/dist/utils/shell-quote.js +47 -0
  84. package/dist/utils/shell-quote.js.map +1 -0
  85. package/package.json +7 -6
  86. package/vendor/seccomp/arm64/apply-seccomp +0 -0
  87. package/vendor/seccomp/build.ts +8 -30
  88. package/vendor/seccomp/x64/apply-seccomp +0 -0
  89. package/vendor/srt-win/build.ts +21 -0
  90. package/dist/vendor/seccomp/Dockerfile.build +0 -6
  91. package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
  92. package/dist/vendor/seccomp/build.ts +0 -91
  93. package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
  94. package/dist/vendor/seccomp-src/apply-seccomp.c +0 -280
  95. package/dist/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  96. package/dist/vendor/srt-win/Cargo.lock +0 -361
  97. package/dist/vendor/srt-win/Cargo.toml +0 -46
  98. package/dist/vendor/srt-win/ci/cleanup.ps1 +0 -49
  99. package/dist/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  100. package/dist/vendor/srt-win/ci/smoke.ps1 +0 -307
  101. package/dist/vendor/srt-win/src/job.rs +0 -102
  102. package/dist/vendor/srt-win/src/launch.rs +0 -732
  103. package/dist/vendor/srt-win/src/lib.rs +0 -20
  104. package/dist/vendor/srt-win/src/main.rs +0 -669
  105. package/dist/vendor/srt-win/src/self_protect.rs +0 -169
  106. package/dist/vendor/srt-win/src/sid.rs +0 -296
  107. package/dist/vendor/srt-win/src/token.rs +0 -341
  108. package/dist/vendor/srt-win/src/util.rs +0 -42
  109. package/dist/vendor/srt-win/src/wfp.rs +0 -992
  110. package/dist/vendor/srt-win/src/winsta.rs +0 -209
  111. package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
  112. package/vendor/seccomp-src/apply-seccomp.c +0 -280
  113. package/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  114. package/vendor/srt-win/Cargo.lock +0 -361
  115. package/vendor/srt-win/Cargo.toml +0 -46
  116. package/vendor/srt-win/ci/cleanup.ps1 +0 -49
  117. package/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  118. package/vendor/srt-win/ci/smoke.ps1 +0 -307
  119. package/vendor/srt-win/src/job.rs +0 -102
  120. package/vendor/srt-win/src/launch.rs +0 -732
  121. package/vendor/srt-win/src/lib.rs +0 -20
  122. package/vendor/srt-win/src/main.rs +0 -669
  123. package/vendor/srt-win/src/self_protect.rs +0 -169
  124. package/vendor/srt-win/src/sid.rs +0 -296
  125. package/vendor/srt-win/src/token.rs +0 -341
  126. package/vendor/srt-win/src/util.rs +0 -42
  127. package/vendor/srt-win/src/wfp.rs +0 -992
  128. package/vendor/srt-win/src/winsta.rs +0 -209
  129. package/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
@@ -1,36 +1,73 @@
1
1
  import { createHttpProxyServer } from './http-proxy.js';
2
2
  import { createSocksProxyServer } from './socks-proxy.js';
3
- import { createMitmCA, disposeMitmCA } from './mitm-ca.js';
3
+ import { createMuxProxyServer } from './mux-proxy.js';
4
+ import { listenInRange } from './listen-in-range.js';
5
+ import { SentinelRegistry } from './credential-sentinel.js';
6
+ import { MaskedFileStore, buildMaskedFileBinds, } from './credential-mask-files.js';
7
+ import { createMitmCA, CRL_PATH, disposeMitmCA, } from './mitm-ca.js';
4
8
  import { logForDebugging } from '../utils/debug.js';
5
9
  import { whichSync } from '../utils/which.js';
6
10
  import { getPlatform, getWslVersion } from '../utils/platform.js';
7
11
  import * as fs from 'fs';
8
- import { randomBytes } from 'node:crypto';
12
+ import { randomBytes, X509Certificate } from 'node:crypto';
9
13
  import { wrapCommandWithSandboxLinux, initializeLinuxNetworkBridge, checkLinuxDependencies, cleanupBwrapMountPoints, } from './linux-sandbox-utils.js';
10
14
  import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, } from './macos-sandbox-utils.js';
11
- import { checkWindowsDependencies, wrapCommandWithSandboxWindows, DEFAULT_WINDOWS_GROUP_NAME, DEFAULT_WINDOWS_PROXY_PORT_RANGE, } from './windows-sandbox-utils.js';
15
+ import { startLinuxSandboxViolationMonitor, } from './linux-violation-monitor.js';
16
+ import { checkWindowsDependencies, wrapCommandWithSandboxWindows, parseWindowsBinShell, expandWindowsFsPaths, stampWindowsAcl, restoreWindowsAcl, grantWindowsAcl, revokeWindowsAcl, getWindowsSandboxUserStatus, getWindowsSandboxCaCert, verifyWindowsWfpEgress, resolveSrtWin, DEFAULT_WINDOWS_PROXY_PORT_RANGE, } from './windows-sandbox-utils.js';
12
17
  import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, ensureSandboxTmpdir, } from './sandbox-utils.js';
13
18
  import { SandboxViolationStore } from './sandbox-violation-store.js';
14
- import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy, stripBrackets, } from './parent-proxy.js';
15
- import { isIP } from 'node:net';
19
+ import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy, } from './parent-proxy.js';
20
+ import { matchesDomainPattern } from './domain-pattern.js';
16
21
  import { EOL } from 'node:os';
22
+ import { dirname } from 'node:path';
17
23
  // ============================================================================
18
24
  // Private Module State
19
25
  // ============================================================================
20
26
  let config;
21
27
  let httpProxyServer;
22
28
  let socksProxyServer;
29
+ let muxProxyServer;
23
30
  let managerContext;
24
31
  let initializationPromise;
25
32
  let cleanupRegistered = false;
26
33
  let logMonitorShutdown;
34
+ let linuxMonitor;
27
35
  let parentProxy;
28
36
  let mitmCA;
29
37
  // Per-session proxy auth token. Generated at proxy start, exported only into
30
38
  // the sandbox child env, checked on every CONNECT/request — so a host process
31
39
  // dialing 127.0.0.1:<proxyPort> can't reach the filter callback.
32
40
  let proxyAuthToken;
41
+ // Windows: the resolved access set that was actually applied at
42
+ // initialize(). `undefined` means no stamp/grant was applied
43
+ // (gates running `acl restore`/`acl revoke` at reset()).
44
+ let windowsFsStampedSet;
45
+ // The sandbox user SID captured at initialize(). reset() uses this
46
+ // so a config change between init and reset can't strand ACEs
47
+ // under a different SID.
48
+ let windowsFsSbUserSid;
49
+ // The RAW config inputs that produced `windowsFsStampedSet`.
50
+ // updateConfig() compares these (not the resolved set) so it never
51
+ // re-expands globs — see `sameWindowsStampSet`.
52
+ let windowsFsRawInputs;
53
+ // `verifyWindowsWfpEgress()` is once per PROCESS (it spawns a
54
+ // CreateProcessWithLogonW runner; first call may create the sandbox
55
+ // user's profile). The WFP fence is install-scoped, not config- or
56
+ // session-scoped — reset() does NOT clear this, so updateConfig()'s
57
+ // reset+reinit and the test suite's per-test reset() don't re-verify.
58
+ let windowsWfpVerified = false;
59
+ // Resolved once at initialize() (`resolveSrtWin` stats the disk).
60
+ // Captured so wrapWithSandboxArgv/reset() don't re-resolve per call
61
+ // and so reset()'s revoke/restore addresses the SAME binary the
62
+ // grants/stamps were applied with even if `config` mutated between.
63
+ let srtWinSpawn;
33
64
  const sandboxViolationStore = new SandboxViolationStore();
65
+ // Per-session sentinel↔real-value map for masked credentials. Lives only in
66
+ // process memory; never written to disk or logged. Cleared on reset().
67
+ const sentinelRegistry = new SentinelRegistry();
68
+ // Temp dir holding the sentinel-content fake files for masked credential
69
+ // files. Created lazily on first masked file; removed on reset().
70
+ const maskedFileStore = new MaskedFileStore();
34
71
  // ============================================================================
35
72
  // Private Helper Functions (not exported)
36
73
  // ============================================================================
@@ -48,26 +85,6 @@ function registerCleanup() {
48
85
  process.once('SIGTERM', cleanupHandler);
49
86
  cleanupRegistered = true;
50
87
  }
51
- function matchesDomainPattern(hostname, pattern) {
52
- const h = hostname.toLowerCase();
53
- // Bare '*' is deny-all when it appears in deniedDomains. The schema only
54
- // accepts it there (allowedDomains still rejects it as too broad).
55
- if (pattern === '*')
56
- return true;
57
- // Support wildcard patterns like *.example.com. Never apply wildcard
58
- // suffix matching to IP literals — an IPv6 zone-ID payload like
59
- // `::ffff:1.2.3.4%x.allowed.com` would otherwise pass .endsWith() while
60
- // the OS connects to the bare IP. isValidHost already rejects `%`, but
61
- // we refuse here too for defence in depth.
62
- if (pattern.startsWith('*.')) {
63
- if (isIP(stripBrackets(h)))
64
- return false;
65
- const baseDomain = pattern.substring(2).toLowerCase();
66
- return h.endsWith('.' + baseDomain);
67
- }
68
- // Exact match for non-wildcard patterns
69
- return h === pattern.toLowerCase();
70
- }
71
88
  async function filterNetworkRequest(port, host, sandboxAskCallback) {
72
89
  if (!config) {
73
90
  logForDebugging('No config available, denying network request');
@@ -133,6 +150,24 @@ async function filterNetworkRequest(port, host, sandboxAskCallback) {
133
150
  * Returns the socket path if the host matches any MITM domain pattern,
134
151
  * otherwise returns undefined.
135
152
  */
153
+ /**
154
+ * Build the header-mutation callback that substitutes sentinel→real for
155
+ * masked credentials. Returns undefined when no `credentials` block is
156
+ * configured — wiring the seam at all is unnecessary then.
157
+ *
158
+ * Per-host gating happens inside the registry: each sentinel carries its
159
+ * own injectHosts list and substitutes independently, so credential A's
160
+ * sentinel cannot be laundered through credential B's allowed host. The
161
+ * returned closure does not log header values; the registry holds the only
162
+ * copy of the real value.
163
+ */
164
+ function buildCredentialInjector() {
165
+ if (!config?.credentials)
166
+ return undefined;
167
+ return (headers, destHost) => {
168
+ sentinelRegistry.substituteInHeaders(headers, destHost, matchesDomainPattern);
169
+ };
170
+ }
136
171
  function getMitmSocketPath(host) {
137
172
  if (!config?.network.mitmProxy) {
138
173
  return undefined;
@@ -147,101 +182,82 @@ function getMitmSocketPath(host) {
147
182
  return undefined;
148
183
  }
149
184
  /**
150
- * Bind `server.listen()` to the first free port in `[lo, hi]`,
151
- * skipping `EADDRINUSE`. With `range` undefined, binds to ephemeral
152
- * port 0 (the previous behaviour).
185
+ * Per-host TLS-termination opt-out from network.tlsTerminate.excludeDomains.
186
+ * Only consulted by the HTTP proxy when tlsTerminate is enabled; exempted
187
+ * hosts fall back to the opaque CONNECT tunnel (still allowlist-filtered),
188
+ * so mTLS / cert-pinning clients can complete their own handshake.
153
189
  *
154
- * Used on Windows: the WFP loopback permit only covers a fixed port
155
- * range (default 60080–60089), so the JS proxies must bind inside it
156
- * for the sandboxed child to reach them. On other platforms the
157
- * sandbox layer (seatbelt rule, namespace+socat) targets whatever
158
- * port we landed on, so ephemeral is fine.
190
+ * Matches the canonicalized hostname, like the allow/deny filter
191
+ * (filterNetworkRequest) otherwise a spelling the allowlist accepts after
192
+ * canonicalization (`127.1`, a trailing-dot FQDN) would dodge the exclusion
193
+ * and get terminated anyway.
159
194
  */
160
- function listenInRange(server, doListen, range, exclude) {
161
- return new Promise((resolve, reject) => {
162
- const [lo, hi] = range ?? [0, 0];
163
- let port = lo;
164
- const tryNext = () => {
165
- while (exclude.has(port) && port <= hi)
166
- port++;
167
- if (port > hi) {
168
- reject(new Error(`No free port in range ${lo}-${hi} (excluding ${[...exclude].join(',')})`));
169
- return;
170
- }
171
- const onListening = () => {
172
- server.removeListener('error', onError);
173
- resolve();
174
- };
175
- const onError = (err) => {
176
- // The paired 'listening' once-listener never fired; drop it
177
- // so retries don't accumulate stale listeners.
178
- server.removeListener('listening', onListening);
179
- if (range &&
180
- err?.code === 'EADDRINUSE' &&
181
- port < hi) {
182
- port++;
183
- tryNext();
184
- return;
185
- }
186
- reject(err ?? new Error('listen error'));
187
- };
188
- server.once('error', onError);
189
- server.once('listening', onListening);
190
- doListen(range ? port : 0);
191
- };
192
- tryNext();
193
- });
195
+ function shouldTerminateTLSForHost(host) {
196
+ const excludeDomains = config?.network.tlsTerminate?.excludeDomains;
197
+ if (!excludeDomains?.length)
198
+ return true;
199
+ const canonicalHost = canonicalizeHost(host) ?? host;
200
+ for (const pattern of excludeDomains) {
201
+ if (!matchesDomainPattern(canonicalHost, pattern))
202
+ continue;
203
+ logForDebugging(`Host ${host} matches tlsTerminate.excludeDomains pattern ${pattern}; skipping TLS termination`);
204
+ // Masked-credential substitution only happens on the terminated path,
205
+ // so a credential whose injectHosts cover this host can never be
206
+ // injected here the upstream gets the placeholder. Config validation
207
+ // rejects the fully-contradictory spellings; this flags the partial
208
+ // ones (e.g. default injectHosts = allowedDomains) at the moment they
209
+ // actually bite.
210
+ const masked = sentinelRegistry.namesInjectableAt(canonicalHost, matchesDomainPattern);
211
+ if (masked.length > 0) {
212
+ logForDebugging(`tlsTerminate.excludeDomains: masked credential(s) ${masked.join(', ')} ` +
213
+ `are configured for injection at ${host}, but its connections are ` +
214
+ `not terminated, so the upstream will receive the placeholder`, { level: 'error' });
215
+ }
216
+ return false;
217
+ }
218
+ return true;
194
219
  }
195
- async function startHttpProxyServer(sandboxAskCallback, portRange, excludePorts) {
220
+ async function startMuxProxyServer(sandboxAskCallback, portRange) {
221
+ const injectCredentials = buildCredentialInjector();
196
222
  httpProxyServer = createHttpProxyServer({
197
223
  filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
198
224
  getMitmSocketPath,
199
225
  mitmCA,
226
+ shouldTerminateTLS: shouldTerminateTLSForHost,
200
227
  filterRequest: config?.network.filterRequest,
228
+ // TLS-terminated path always gets the injector; the plain-HTTP path
229
+ // only when explicitly opted in. Without the opt-in, a sentinel sent
230
+ // over plain HTTP reaches the upstream unchanged (fails closed).
231
+ mutateHeaders: injectCredentials,
232
+ mutateHeadersPlaintext: config?.credentials?.allowPlaintextInject
233
+ ? injectCredentials
234
+ : undefined,
201
235
  parentProxy,
202
236
  proxyAuthToken,
203
237
  });
204
- const server = httpProxyServer;
205
- await listenInRange(server, p => server.listen(p, '127.0.0.1'), portRange, excludePorts);
206
- const address = server.address();
207
- if (!address || typeof address !== 'object') {
208
- throw new Error('Failed to get HTTP proxy server address');
209
- }
210
- server.unref();
211
- logForDebugging(`HTTP proxy listening on localhost:${address.port}`);
212
- return address.port;
213
- }
214
- async function startSocksProxyServer(sandboxAskCallback, portRange, excludePorts) {
215
238
  socksProxyServer = createSocksProxyServer({
216
239
  filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
217
240
  parentProxy,
218
241
  proxyAuthToken,
219
242
  });
220
- const wrapper = socksProxyServer;
221
- // SocksProxyWrapper.listen() resolves with the bound port; we
222
- // adapt it to the listenInRange shape by retrying on EADDRINUSE
223
- // here directly rather than via the once('error') path.
224
- if (!portRange) {
225
- const port = await wrapper.listen(0, '127.0.0.1');
226
- wrapper.unref();
227
- return port;
228
- }
229
- let lastErr;
230
- for (let p = portRange[0]; p <= portRange[1]; p++) {
231
- if (excludePorts.has(p))
232
- continue;
233
- try {
234
- const port = await wrapper.listen(p, '127.0.0.1');
235
- wrapper.unref();
236
- return port;
237
- }
238
- catch (err) {
239
- lastErr = err;
240
- if (err?.code !== 'EADDRINUSE')
241
- throw err;
242
- }
243
+ muxProxyServer = createMuxProxyServer({
244
+ httpServer: httpProxyServer,
245
+ handleSocksConnection: s => socksProxyServer.handleConnection(s),
246
+ httpBackendPortRange: portRange,
247
+ });
248
+ const mux = muxProxyServer;
249
+ // Backend first so the front-end never accepts a connection that would
250
+ // dispatch to an unbound backend. On Windows the backend's port is
251
+ // excluded when binding the front-end in the same WFP range.
252
+ const backendPort = await mux.listenHttpBackend();
253
+ await listenInRange(mux.server, p => mux.server.listen(p, '127.0.0.1'), portRange, backendPort !== undefined ? new Set([backendPort]) : new Set());
254
+ const muxPort = mux.getPort();
255
+ if (muxPort === undefined) {
256
+ throw new Error('Failed to get mux proxy server port');
243
257
  }
244
- throw new Error(`No free SOCKS port in range ${portRange[0]}-${portRange[1]}: ${lastErr?.message ?? 'all in use'}`);
258
+ mux.unref();
259
+ logForDebugging(`Mux proxy (HTTP+SOCKS) listening on localhost:${muxPort}`);
260
+ return muxPort;
245
261
  }
246
262
  // ============================================================================
247
263
  // Public Module Functions (will be exported via namespace)
@@ -283,8 +299,169 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
283
299
  logMonitorShutdown = startMacOSSandboxLogMonitor(sandboxViolationStore.addViolation.bind(sandboxViolationStore), config.ignoreViolations);
284
300
  logForDebugging('Started macOS sandbox log monitor');
285
301
  }
302
+ if (enableLogMonitor && getPlatform() === 'linux') {
303
+ linuxMonitor = startLinuxSandboxViolationMonitor(sandboxViolationStore.addViolation.bind(sandboxViolationStore), {
304
+ // apply-seccomp's observer reports every write-intent syscall
305
+ // (allowed or not). Only paths bwrap would actually refuse — outside
306
+ // allowWrite or inside a denyWrite carve-out — go to the store.
307
+ allowWritePaths: [
308
+ ...getDefaultWritePaths(),
309
+ ...config.filesystem.allowWrite,
310
+ ],
311
+ denyWritePaths: config.filesystem.denyWrite,
312
+ ignoreViolations: config.ignoreViolations,
313
+ });
314
+ // Don't block initialization on listen() — wrap-time checks
315
+ // fs.existsSync(observeSocketPath) and degrades gracefully.
316
+ void linuxMonitor.ready;
317
+ logForDebugging('Started Linux seccomp violation monitor');
318
+ }
286
319
  // Register cleanup handlers first time
287
320
  registerCleanup();
321
+ // Windows: validate provisioning + filesystem config BEFORE any
322
+ // sandboxed child can be spawned. Doing this at initialize() (not
323
+ // wrap-time) means the host gets a single actionable error before
324
+ // any per-exec work happens, instead of exit-15 on every command.
325
+ if (getPlatform() === 'windows') {
326
+ // Resolve once (stats disk); captured module-level for wrap/reset.
327
+ srtWinSpawn = resolveSrtWin(runtimeConfig.windows?.srtWin);
328
+ const srtWin = srtWinSpawn;
329
+ const u = getWindowsSandboxUserStatus({ srtWin });
330
+ if (!u.provisioned || !u.credPresent) {
331
+ config = undefined;
332
+ throw new Error(`Windows sandbox user is not provisioned (user=` +
333
+ `${u.provisioned}, cred=${u.credPresent}). Run \`npx ` +
334
+ `sandbox-runtime windows-install\` (one UAC prompt) to ` +
335
+ `provision it.`);
336
+ }
337
+ // Behavioral proof the WFP egress fence is active for the
338
+ // sandbox user — BFE enumeration (`wfp status`) is admin-gated,
339
+ // so this is the non-elevated readiness check. Fails closed: a
340
+ // stale install (user provisioned but filters since removed)
341
+ // throws here instead of running every exec with full egress.
342
+ // After the user-status check so the not-provisioned message is
343
+ // the actionable one. Once per process — the fence is install-
344
+ // scoped, not session-scoped.
345
+ if (!windowsWfpVerified) {
346
+ try {
347
+ await verifyWindowsWfpEgress({
348
+ proxyPortRange: runtimeConfig.windows?.proxyPortRange,
349
+ srtWin,
350
+ });
351
+ }
352
+ catch (e) {
353
+ config = undefined;
354
+ throw e;
355
+ }
356
+ windowsWfpVerified = true;
357
+ }
358
+ // schannel-level trust under the sandbox user is install-time
359
+ // (cert lifecycle = sandbox-user lifecycle), not per-session.
360
+ // System32 curl / IWR / .NET / default-backend git only trust
361
+ // what's in the sandbox user's `CurrentUser\Root` — which
362
+ // `srt-win exec` does not (and must not) write. Compare
363
+ // thumbprints so a stale install-time CA doesn't pass the gate
364
+ // while schannel rejects the session's proxy-minted leaves.
365
+ if (runtimeConfig.network.tlsTerminate && mitmCA) {
366
+ const installed = getWindowsSandboxCaCert(u);
367
+ const sessionThumb = new X509Certificate(mitmCA.certPem).fingerprint
368
+ .replace(/:/g, '')
369
+ .toUpperCase();
370
+ if (!installed) {
371
+ config = undefined;
372
+ throw new Error(`tlsTerminate on Windows requires the sandbox to be ` +
373
+ `installed with this CA (thumb=${sessionThumb}): run ` +
374
+ `\`srt-win user trust-ca ${mitmCA.certPath}\`. Per-exec ` +
375
+ `installs into the sandbox user's Root store are not ` +
376
+ `supported.`);
377
+ }
378
+ if (installed.thumb !== sessionThumb) {
379
+ config = undefined;
380
+ throw new Error(`tlsTerminate on Windows: the sandbox's installed CA ` +
381
+ `(thumb=${installed.thumb}) doesn't match this ` +
382
+ `session's CA (thumb=${sessionThumb}). Run \`srt-win ` +
383
+ `user trust-ca ${mitmCA.certPath}\` to update it.`);
384
+ }
385
+ }
386
+ // Filesystem grants/denies — additive sandbox-user ACEs.
387
+ try {
388
+ const acc = computeWindowsFsAccessSet(runtimeConfig);
389
+ // The trust bundle the CA-trust env vars point at
390
+ // (NODE_EXTRA_CA_CERTS etc.) must be readable by the
391
+ // srt-sandbox child. It's written into the broker's %TEMP%,
392
+ // which the sandbox user has no inherent rights on, so it
393
+ // rides the same session-level `acl grant` read-set as the
394
+ // working tree. Granted on the mkdtemp DIR (not the file)
395
+ // so the (OI)(CI) ACE covers both the file open AND the
396
+ // parent-directory list that cmd's `type`/`FindFirstFile`
397
+ // does before opening. Mirrors the mac/linux
398
+ // `expandedAllowRead` push in wrapWithSandbox.
399
+ if (mitmCA) {
400
+ acc.grantRead.push(dirname(mitmCA.trustBundlePath));
401
+ }
402
+ // `u` was fetched once above for the provisioning gate; the
403
+ // same status carries the SID — don't re-spawn `srt-win user
404
+ // status` here.
405
+ if (!u.sid) {
406
+ throw new Error('sandbox user SID missing from `srt-win user status` ' +
407
+ '(provisioned but in an inconsistent state)');
408
+ }
409
+ const sb = u.sid;
410
+ // Record module-level state BEFORE the first acl call so the
411
+ // catch's best-effort revoke/restore can address whatever
412
+ // partially landed.
413
+ windowsFsSbUserSid = sb;
414
+ // Grant FIRST so the sandbox user has working-tree access by
415
+ // the time the deny stamp runs. The two are independent
416
+ // refcounted state-DB sets keyed on the same holder PID.
417
+ if (acc.grantRead.length > 0 || acc.grantWrite.length > 0) {
418
+ grantWindowsAcl({
419
+ sandboxUserSid: sb,
420
+ read: acc.grantRead,
421
+ write: acc.grantWrite,
422
+ srtWin,
423
+ });
424
+ }
425
+ if (acc.denyRead.length > 0 || acc.denyWrite.length > 0) {
426
+ stampWindowsAcl({
427
+ sandboxUserSid: sb,
428
+ denyRead: acc.denyRead,
429
+ denyWrite: acc.denyWrite,
430
+ srtWin,
431
+ });
432
+ }
433
+ // Only record when something was actually applied — gates
434
+ // running revoke/restore at reset(). Recorded AFTER success —
435
+ // the catch below clears `config`, and a non-undefined
436
+ // stampedSet would leave reset()/updateConfig() seeing state
437
+ // that never landed.
438
+ const anyApplied = acc.grantRead.length > 0 ||
439
+ acc.grantWrite.length > 0 ||
440
+ acc.denyRead.length > 0 ||
441
+ acc.denyWrite.length > 0;
442
+ if (anyApplied) {
443
+ windowsFsStampedSet = acc;
444
+ logForDebugging(`[Sandbox Windows] fs applied: ` +
445
+ `${acc.grantWrite.length} grantWrite, ` +
446
+ `${acc.grantRead.length} grantRead, ` +
447
+ `${acc.denyRead.length} denyRead, ` +
448
+ `${acc.denyWrite.length} denyWrite`);
449
+ }
450
+ windowsFsRawInputs = rawWindowsFsInputs(runtimeConfig);
451
+ }
452
+ catch (e) {
453
+ // Best-effort release of whatever WAS applied before the
454
+ // failure (exit-2 partial stamps/grants the resolvable
455
+ // inputs; harmless if nothing was — no holds for this PID).
456
+ if (windowsFsSbUserSid) {
457
+ revokeWindowsAcl({ sandboxUserSid: windowsFsSbUserSid, srtWin });
458
+ restoreWindowsAcl({ sandboxUserSid: windowsFsSbUserSid, srtWin });
459
+ }
460
+ windowsFsSbUserSid = undefined;
461
+ config = undefined;
462
+ throw e;
463
+ }
464
+ }
288
465
  // Initialize network infrastructure
289
466
  initializationPromise = (async () => {
290
467
  try {
@@ -302,27 +479,35 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
302
479
  config.network.httpProxyPort !== undefined
303
480
  ? undefined
304
481
  : randomBytes(16).toString('hex');
305
- let httpProxyPort;
482
+ // The mux front-end serves both protocols on one port. Each side's
483
+ // reported port is the external override if configured, else the mux
484
+ // port — so the public config.network.{http,socks}ProxyPort contract
485
+ // is unchanged. The mux is skipped only when BOTH are external.
486
+ const needLocalProxy = config.network.httpProxyPort === undefined ||
487
+ config.network.socksProxyPort === undefined;
488
+ const muxPort = needLocalProxy
489
+ ? await startMuxProxyServer(sandboxAskCallback, portRange)
490
+ : undefined;
491
+ const httpProxyPort = config.network.httpProxyPort ?? muxPort;
492
+ const socksProxyPort = config.network.socksProxyPort ?? muxPort;
493
+ // Leaves are minted lazily per-CONNECT (after this point), so setting
494
+ // the CDP URL now means every leaf carries it. See MitmCA.crlUrl.
495
+ // Windows-only: on Linux the child runs under bwrap --unshare-net and
496
+ // reaches the proxy via a socat bridge on a fixed netns port, so a
497
+ // host-namespace mux port would be unreachable — worse than no CDP,
498
+ // since a Schannel-analog client (Java, OpenSSL with CRL_CHECK) then
499
+ // hard-fails "CRL fetch error" instead of soft-passing "no CDP". macOS
500
+ // has no in-tree Schannel-analog client. Also gated on `muxPort`: an
501
+ // external `network.httpProxyPort` doesn't answer /srt.crl.
502
+ if (mitmCA && muxPort !== undefined && getPlatform() === 'windows') {
503
+ mitmCA.crlUrl = `http://127.0.0.1:${muxPort}${CRL_PATH}`;
504
+ }
306
505
  if (config.network.httpProxyPort !== undefined) {
307
- // Use external HTTP proxy (don't start a server)
308
- httpProxyPort = config.network.httpProxyPort;
309
506
  logForDebugging(`Using external HTTP proxy on port ${httpProxyPort}`);
310
507
  }
311
- else {
312
- // Start local HTTP proxy
313
- httpProxyPort = await startHttpProxyServer(sandboxAskCallback, portRange, new Set());
314
- }
315
- let socksProxyPort;
316
508
  if (config.network.socksProxyPort !== undefined) {
317
- // Use external SOCKS proxy (don't start a server)
318
- socksProxyPort = config.network.socksProxyPort;
319
509
  logForDebugging(`Using external SOCKS proxy on port ${socksProxyPort}`);
320
510
  }
321
- else {
322
- // Start local SOCKS proxy. Skip the port the HTTP proxy
323
- // already took.
324
- socksProxyPort = await startSocksProxyServer(sandboxAskCallback, portRange, new Set([httpProxyPort]));
325
- }
326
511
  // Initialize platform-specific infrastructure
327
512
  let linuxBridge;
328
513
  if (getPlatform() === 'linux') {
@@ -359,16 +544,6 @@ function isSupportedPlatform() {
359
544
  }
360
545
  return platform === 'macos' || platform === 'windows';
361
546
  }
362
- /**
363
- * Resolve the Windows group reference from config. Used by both the
364
- * dependency check and `wrapWithSandbox` so they agree.
365
- */
366
- function getWindowsGroupRef() {
367
- return {
368
- groupName: config?.windows?.groupName ?? DEFAULT_WINDOWS_GROUP_NAME,
369
- groupSid: config?.windows?.groupSid,
370
- };
371
- }
372
547
  function isSandboxingEnabled() {
373
548
  // Sandboxing is enabled if config has been set (via initialize())
374
549
  return config !== undefined;
@@ -402,46 +577,110 @@ function checkDependencies(ripgrepConfig) {
402
577
  warnings.push(...linuxDeps.warnings);
403
578
  }
404
579
  else if (platform === 'windows') {
405
- const winDeps = checkWindowsDependencies(getWindowsGroupRef(), config?.windows?.wfpSublayerGuid);
580
+ let srtWin;
581
+ try {
582
+ srtWin = resolveSrtWin(config?.windows?.srtWin);
583
+ }
584
+ catch (e) {
585
+ errors.push(e.message);
586
+ return { errors, warnings };
587
+ }
588
+ const winDeps = checkWindowsDependencies({
589
+ sublayerGuid: config?.windows?.sublayerGuid ?? config?.windows?.wfpSublayerGuid,
590
+ srtWin,
591
+ });
406
592
  errors.push(...winDeps.errors);
407
593
  warnings.push(...winDeps.warnings);
408
594
  }
409
595
  return { errors, warnings };
410
596
  }
411
597
  /**
412
- * Build the read-deny / env-unset sets implied by the `credentials` config.
598
+ * Build the read-deny / env-unset / env-set maps implied by the
599
+ * `credentials` config.
413
600
  *
414
601
  * Only explicitly declared sources are restricted: `mode: 'deny'` file
415
- * entries join the read-deny set and `mode: 'deny'` env vars are unset.
416
- * The mode filter keeps the structure ready for future non-deny modes
417
- * (e.g. masking).
602
+ * entries join the read-deny set, `mode: 'deny'` env vars are unset, and
603
+ * `mode: 'mask'` env vars are set to a per-session sentinel registered in
604
+ * {@link sentinelRegistry}. A masked var with no value in the host
605
+ * environment is skipped — there is nothing to protect, and emitting an
606
+ * unset var would change tool behaviour (presence checks would pass where
607
+ * they didn't before).
418
608
  */
419
- function getCredentialRestrictions(credentials) {
609
+ function getCredentialRestrictions(credentials, allowedDomains) {
420
610
  if (!credentials) {
421
- return { denyReadPaths: [], unsetEnvVars: [] };
611
+ return {
612
+ denyReadPaths: [],
613
+ unsetEnvVars: [],
614
+ setEnvVars: {},
615
+ maskedFileBinds: [],
616
+ maskedFileStoreDir: undefined,
617
+ };
422
618
  }
619
+ const denyReadPaths = getCredentialDenyReadPaths(credentials);
620
+ const unsetEnvVars = [];
621
+ const setEnvVars = {};
622
+ for (const v of credentials.envVars ?? []) {
623
+ if (v.mode === 'deny') {
624
+ unsetEnvVars.push(v.name);
625
+ }
626
+ else if (v.mode === 'mask') {
627
+ const real = process.env[v.name];
628
+ if (real === undefined)
629
+ continue;
630
+ // Effective injectHosts: per-entry narrows; if unset, default to
631
+ // every reachable host (network.allowedDomains). injectHosts is an
632
+ // *optional narrowing*, not a required allowlist. Trade-off: a
633
+ // masked credential with no injectHosts is injectable at every host
634
+ // the sandbox can reach — narrow it explicitly when the credential
635
+ // should only go to a subset.
636
+ const injectHosts = v.injectHosts ?? allowedDomains ?? [];
637
+ setEnvVars[v.name] = sentinelRegistry.register(v.name, real, injectHosts);
638
+ }
639
+ }
640
+ // Masked files: read the real bytes on the host, register a sentinel,
641
+ // write it to a fake file in the manager-owned temp dir. Missing/unreadable
642
+ // entries are skipped (same posture as an unset masked env var).
643
+ // degradeToDenyPaths carries paths whose extract pattern matched
644
+ // nothing with onExtractNoMatch: "deny" — merged into denyReadPaths
645
+ // below so both the read-deny config and the platform builders see them.
423
646
  const files = credentials.files ?? [];
424
- const denyReadPaths = files.filter(f => f.mode === 'deny').map(f => f.path);
425
- const unsetEnvVars = (credentials.envVars ?? [])
426
- .filter(v => v.mode === 'deny')
427
- .map(v => v.name);
647
+ const { binds: maskedFileBinds, degradeToDenyPaths } = buildMaskedFileBinds(files, allowedDomains ?? [], sentinelRegistry, maskedFileStore);
428
648
  return {
429
- denyReadPaths: [...new Set(denyReadPaths)],
649
+ denyReadPaths: [...new Set([...denyReadPaths, ...degradeToDenyPaths])],
430
650
  unsetEnvVars: [...new Set(unsetEnvVars)],
651
+ setEnvVars,
652
+ maskedFileBinds,
653
+ maskedFileStoreDir: maskedFileStore.dirPath,
431
654
  };
432
655
  }
656
+ /**
657
+ * Pure (side-effect-free) chokepoint for credential file-deny
658
+ * paths — `credentials.files` entries with `mode: 'deny'`. Any
659
+ * code that needs the credential→denyRead contribution routes
660
+ * through here so a comparison predicate can read it without
661
+ * touching {@link sentinelRegistry}.
662
+ */
663
+ function getCredentialDenyReadPaths(credentials) {
664
+ const files = credentials?.files ?? [];
665
+ return [...new Set(files.filter(f => f.mode === 'deny').map(f => f.path))];
666
+ }
667
+ /**
668
+ * Union the explicit `filesystem.denyRead` with credential-derived
669
+ * deny paths. The single source of "what files does this config
670
+ * want read-denied" — all platforms route through here so a new
671
+ * credential kind that contributes deny paths reaches every
672
+ * backend.
673
+ */
674
+ function unionDenyReadPaths(denyRead, credentialRestrictions) {
675
+ return [...new Set([...denyRead, ...credentialRestrictions.denyReadPaths])];
676
+ }
433
677
  function getFsReadConfig() {
434
- if (!config) {
678
+ if (!config || config.filesystem.disabled) {
435
679
  return { denyOnly: [], allowWithinDeny: [] };
436
680
  }
437
681
  // Credential deny paths are unioned with the caller's denyRead — never
438
682
  // replacing it — so explicit filesystem restrictions always survive.
439
- const rawDenyRead = [
440
- ...new Set([
441
- ...config.filesystem.denyRead,
442
- ...getCredentialRestrictions(config.credentials).denyReadPaths,
443
- ]),
444
- ];
683
+ const rawDenyRead = unionDenyReadPaths(config.filesystem.denyRead, getCredentialRestrictions(config.credentials, config.network.allowedDomains));
445
684
  const denyPaths = [];
446
685
  for (const p of rawDenyRead) {
447
686
  const stripped = removeTrailingGlobSuffix(p);
@@ -477,6 +716,9 @@ function getFsWriteConfig() {
477
716
  if (!config) {
478
717
  return { allowOnly: getDefaultWritePaths(), denyWithinAllow: [] };
479
718
  }
719
+ if (config.filesystem.disabled) {
720
+ return { allowOnly: ['/'], denyWithinAllow: [] };
721
+ }
480
722
  // Filter out glob patterns on Linux/WSL for allowWrite (bubblewrap doesn't support globs)
481
723
  const allowPaths = config.filesystem.allowWrite
482
724
  .map(path => removeTrailingGlobSuffix(path))
@@ -504,6 +746,94 @@ function getFsWriteConfig() {
504
746
  denyWithinAllow: denyPaths,
505
747
  };
506
748
  }
749
+ /**
750
+ * Build the Windows file-access set (deny stamps + sandbox-user
751
+ * grants) from `runtimeConfig`. Globs are expanded to concrete
752
+ * paths (point-in-time — a path appearing after this returns is NOT
753
+ * covered). Directory targets are accepted (the `(OI)(CI)` ACEs
754
+ * cover the subtree).
755
+ *
756
+ * The sandbox user has no inherent rights on real-user-owned files,
757
+ * so `allowWrite` (the working-tree roots) becomes a per-session
758
+ * `MODIFY_NO_FDC` ALLOW ACE for `<sb-SID>`, `allowRead` a
759
+ * `READ|EXECUTE` ALLOW ACE, and `denyRead`/`denyWrite` become an
760
+ * explicit DENY ACE for `<sb-SID>` on the target plus a
761
+ * `(OI)(CI) FILE_DELETE_CHILD` DENY on its parent.
762
+ */
763
+ function computeWindowsFsAccessSet(c) {
764
+ const fs = c.filesystem;
765
+ // filesystem.disabled bypasses ALL filesystem rule generation —
766
+ // same as the macOS/Linux wrapWithSandbox path (readConfig /
767
+ // writeConfig left undefined). On Windows this means no ACL
768
+ // stamp/grant; credential FILE denies are dropped along with the
769
+ // rest (credential ENV: mode:'deny' is structural under the
770
+ // fresh srt-sandbox env; mode:'mask' sentinels are passed via
771
+ // the --env overlay).
772
+ if (fs?.disabled) {
773
+ return { grantRead: [], grantWrite: [], denyRead: [], denyWrite: [] };
774
+ }
775
+ const expand = expandWindowsFsPaths;
776
+ const denyRead = expand([
777
+ ...new Set([
778
+ ...(fs?.denyRead ?? []),
779
+ ...getCredentialDenyReadPaths(c.credentials),
780
+ ]),
781
+ ]);
782
+ const denyWrite = expand(fs?.denyWrite ?? []);
783
+ return {
784
+ // `allowRead` also serves as `allowWithinDeny`: a file under a
785
+ // denied dir gets an explicit ALLOW ACE for the sandbox user,
786
+ // and explicit DENY on the parent doesn't override it because
787
+ // the recompose chokepoint orders deny-before-allow per-path.
788
+ grantRead: expand(fs?.allowRead ?? []),
789
+ grantWrite: expand(fs?.allowWrite ?? []),
790
+ denyRead,
791
+ denyWrite,
792
+ };
793
+ }
794
+ /**
795
+ * Snapshot the raw config fields that feed
796
+ * {@link computeWindowsFsAccessSet}. Used by updateConfig() to
797
+ * short-circuit the resolved-set diff (which re-runs glob
798
+ * expansion) when nothing relevant changed.
799
+ */
800
+ function rawWindowsFsInputs(c) {
801
+ // Keyed exactly on what {@link computeWindowsFsAccessSet} reads.
802
+ // `network.allowedDomains` does NOT feed file-deny (only mask
803
+ // injectHosts), so a network-only updateConfig hits the cache.
804
+ return {
805
+ disabled: c.filesystem.disabled ?? false,
806
+ denyRead: [...c.filesystem.denyRead],
807
+ denyWrite: [...c.filesystem.denyWrite],
808
+ allowRead: [...(c.filesystem.allowRead ?? [])],
809
+ allowWrite: [...c.filesystem.allowWrite],
810
+ credFiles: getCredentialDenyReadPaths(c.credentials),
811
+ };
812
+ }
813
+ function setEq(a, b) {
814
+ if (a.length !== b.length)
815
+ return false;
816
+ const s = new Set(a);
817
+ return b.every(x => s.has(x));
818
+ }
819
+ function sameRawWindowsFsInputs(a, b) {
820
+ return (a.disabled === b.disabled &&
821
+ setEq(a.denyRead, b.denyRead) &&
822
+ setEq(a.denyWrite, b.denyWrite) &&
823
+ setEq(a.allowRead, b.allowRead) &&
824
+ setEq(a.allowWrite, b.allowWrite) &&
825
+ setEq(a.credFiles, b.credFiles));
826
+ }
827
+ /**
828
+ * True when `newConfig`'s file-deny inputs match what was
829
+ * stamped at initialize(). Compares raw inputs only (cheap,
830
+ * order-insensitive); never re-expands globs — updateConfig is
831
+ * warn-only on Windows and the resolved set wouldn't be used.
832
+ */
833
+ function sameWindowsStampSet(newConfig) {
834
+ return (windowsFsRawInputs !== undefined &&
835
+ sameRawWindowsFsInputs(windowsFsRawInputs, rawWindowsFsInputs(newConfig)));
836
+ }
507
837
  function getNetworkRestrictionConfig() {
508
838
  if (!config) {
509
839
  return {};
@@ -593,6 +923,22 @@ async function waitForNetworkInitialization() {
593
923
  }
594
924
  async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
595
925
  const platform = getPlatform();
926
+ // filesystem.disabled bypasses ALL filesystem rule generation. Both
927
+ // platform wrappers treat readConfig/writeConfig === undefined as "no
928
+ // filesystem restrictions" (seatbelt emits `(allow file-write*)`; bwrap
929
+ // skips the `--ro-bind / /` root and all path binds).
930
+ //
931
+ // Precedence: when a caller passes a per-call filesystem override at all,
932
+ // its `disabled` (defaulting to false) wins outright. A global
933
+ // disabled=true must not silently discard a per-call tightening that
934
+ // omits the new key.
935
+ const fsDisabled = customConfig?.filesystem !== undefined
936
+ ? (customConfig.filesystem.disabled ?? false)
937
+ : (config?.filesystem.disabled ?? false);
938
+ // Credential env handling is independent of filesystem policy: unsetEnvVars /
939
+ // setEnvVars must be applied even when fsDisabled (the credential file
940
+ // deny-reads are dropped, but env scrubbing still happens).
941
+ const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
596
942
  // Get configs - use custom if provided, otherwise fall back to main config
597
943
  // If neither exists, defaults to empty arrays (most restrictive)
598
944
  // Always include default system write paths (like /dev/null, /tmp/claude)
@@ -600,63 +946,62 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
600
946
  // Strip trailing /** and filter remaining globs on Linux (bwrap needs
601
947
  // real paths, not globs; macOS subpath matching is also recursive so
602
948
  // stripping is harmless there).
603
- const stripWriteGlobs = (paths) => paths
604
- .map(p => removeTrailingGlobSuffix(p))
605
- .filter(p => {
606
- if (getPlatform() === 'linux' && containsGlobChars(p)) {
607
- logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`);
608
- return false;
609
- }
610
- return true;
611
- });
612
- const userAllowWrite = stripWriteGlobs(customConfig?.filesystem?.allowWrite ?? config?.filesystem.allowWrite ?? []);
613
- const writeConfig = {
614
- allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
615
- denyWithinAllow: stripWriteGlobs(customConfig?.filesystem?.denyWrite ?? config?.filesystem.denyWrite ?? []),
616
- };
617
- // Credential file denies and env unsets derived from the credentials
618
- // section. The deny paths are unioned with the caller's denyRead — never
619
- // replacing it — so explicit filesystem restrictions always survive.
620
- const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials);
621
- const rawDenyRead = [
622
- ...new Set([
623
- ...(customConfig?.filesystem?.denyRead ??
624
- config?.filesystem.denyRead ??
949
+ let writeConfig;
950
+ let readConfig;
951
+ if (!fsDisabled) {
952
+ const stripWriteGlobs = (paths) => paths
953
+ .map(p => removeTrailingGlobSuffix(p))
954
+ .filter(p => {
955
+ if (getPlatform() === 'linux' && containsGlobChars(p)) {
956
+ logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`);
957
+ return false;
958
+ }
959
+ return true;
960
+ });
961
+ const userAllowWrite = stripWriteGlobs(customConfig?.filesystem?.allowWrite ??
962
+ config?.filesystem.allowWrite ??
963
+ []);
964
+ writeConfig = {
965
+ allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
966
+ denyWithinAllow: stripWriteGlobs(customConfig?.filesystem?.denyWrite ??
967
+ config?.filesystem.denyWrite ??
625
968
  []),
626
- ...credentialRestrictions.denyReadPaths,
627
- ]),
628
- ];
629
- const expandedDenyRead = [];
630
- for (const p of rawDenyRead) {
631
- const stripped = removeTrailingGlobSuffix(p);
632
- if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
633
- expandedDenyRead.push(...expandGlobPattern(p));
634
- }
635
- else {
636
- expandedDenyRead.push(stripped);
969
+ };
970
+ // Credential deny paths are unioned with the caller's denyRead — never
971
+ // replacing it — so explicit filesystem restrictions always survive.
972
+ const rawDenyRead = unionDenyReadPaths(customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions);
973
+ const expandedDenyRead = [];
974
+ for (const p of rawDenyRead) {
975
+ const stripped = removeTrailingGlobSuffix(p);
976
+ if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
977
+ expandedDenyRead.push(...expandGlobPattern(p));
978
+ }
979
+ else {
980
+ expandedDenyRead.push(stripped);
981
+ }
637
982
  }
638
- }
639
- const rawAllowRead = customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [];
640
- const expandedAllowRead = [];
641
- for (const p of rawAllowRead) {
642
- const stripped = removeTrailingGlobSuffix(p);
643
- if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
644
- expandedAllowRead.push(...expandGlobPattern(p));
983
+ const rawAllowRead = customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [];
984
+ const expandedAllowRead = [];
985
+ for (const p of rawAllowRead) {
986
+ const stripped = removeTrailingGlobSuffix(p);
987
+ if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
988
+ expandedAllowRead.push(...expandGlobPattern(p));
989
+ }
990
+ else {
991
+ expandedAllowRead.push(stripped);
992
+ }
645
993
  }
646
- else {
647
- expandedAllowRead.push(stripped);
994
+ // The TLS-termination CA cert and the trust bundle the env vars point at
995
+ // (NODE_EXTRA_CA_CERTS etc.) must be readable by the child, even if their
996
+ // paths fall under a user-configured denyRead.
997
+ if (mitmCA) {
998
+ expandedAllowRead.push(mitmCA.certPath, mitmCA.trustBundlePath);
648
999
  }
1000
+ readConfig = {
1001
+ denyOnly: expandedDenyRead,
1002
+ allowWithinDeny: expandedAllowRead,
1003
+ };
649
1004
  }
650
- // The TLS-termination CA cert must be readable by the child so the trust
651
- // env vars (NODE_EXTRA_CA_CERTS etc.) resolve, even if its path falls
652
- // under a user-configured denyRead.
653
- if (mitmCA) {
654
- expandedAllowRead.push(mitmCA.certPath);
655
- }
656
- const readConfig = {
657
- denyOnly: expandedDenyRead,
658
- allowWithinDeny: expandedAllowRead,
659
- };
660
1005
  // Check if network config is specified - this determines if we need network restrictions
661
1006
  // Network restriction is needed when:
662
1007
  // 1. customConfig has network.allowedDomains defined (even if empty array = block all)
@@ -690,10 +1035,12 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
690
1035
  httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined,
691
1036
  socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined,
692
1037
  proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
693
- caCertPath: mitmCA?.certPath,
1038
+ caCertPath: mitmCA?.trustBundlePath,
694
1039
  readConfig,
695
1040
  writeConfig,
696
1041
  unsetEnvVars: credentialRestrictions.unsetEnvVars,
1042
+ setEnvVars: credentialRestrictions.setEnvVars,
1043
+ maskedFileBinds: credentialRestrictions.maskedFileBinds,
697
1044
  allowUnixSockets: getAllowUnixSockets(),
698
1045
  allowAllUnixSockets: getAllowAllUnixSockets(),
699
1046
  allowLocalBinding: getAllowLocalBinding(),
@@ -724,10 +1071,13 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
724
1071
  ? managerContext?.socksProxyPort
725
1072
  : undefined,
726
1073
  proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
727
- caCertPath: mitmCA?.certPath,
1074
+ caCertPath: mitmCA?.trustBundlePath,
728
1075
  readConfig,
729
1076
  writeConfig,
730
1077
  unsetEnvVars: credentialRestrictions.unsetEnvVars,
1078
+ setEnvVars: credentialRestrictions.setEnvVars,
1079
+ maskedFileBinds: credentialRestrictions.maskedFileBinds,
1080
+ maskedFileStoreDir: credentialRestrictions.maskedFileStoreDir,
731
1081
  enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
732
1082
  allowAllUnixSockets: getAllowAllUnixSockets(),
733
1083
  binShell,
@@ -737,6 +1087,7 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
737
1087
  seccompConfig: getSeccompConfig(),
738
1088
  bwrapPath: config?.bwrapPath,
739
1089
  socatPath: config?.socatPath,
1090
+ observeSocketPath: linuxMonitor?.observeSocketPath,
740
1091
  abortSignal,
741
1092
  });
742
1093
  case 'windows':
@@ -758,14 +1109,22 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
758
1109
  * `spawn(argv[0], argv.slice(1), {shell: false, env})`.
759
1110
  *
760
1111
  * On Windows this is the ONLY supported wrap method (see
761
- * {@link wrapWithSandbox}); `env` carries the full proxy set that the
762
- * sandboxed child inherits (`srt-win exec` forwards its environment
763
- * verbatim see {@link wrapCommandWithSandboxWindows}). On
1112
+ * {@link wrapWithSandbox}); `env` is the broker process's spawn env
1113
+ * — the sandboxed child gets a fresh `srt-sandbox` profile env with
1114
+ * only the `--env` overlay baked into `argv` (see
1115
+ * {@link wrapCommandWithSandboxWindows}). On
764
1116
  * macOS/Linux `argv` is `[binShell, '-c', <wrapWithSandbox result>]`
765
1117
  * (proxy env is baked into that command) and `env` is the unchanged
766
1118
  * `process.env`, so callers can spawn uniformly across platforms.
1119
+ *
1120
+ * @param cwd the working directory the caller will spawn the result
1121
+ * with. On Windows the child's cwd is whatever the caller passes
1122
+ * as the spawn `{cwd:}` option (there is no `--cwd` flag), and
1123
+ * the `safe.directory` git-config injection derives from this — so
1124
+ * pass the same value here as to `spawn({cwd})`. Defaults to
1125
+ * `process.cwd()`. Currently unused on macOS/Linux.
767
1126
  */
768
- async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal) {
1127
+ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal, cwd) {
769
1128
  const platform = getPlatform();
770
1129
  if (platform === 'windows') {
771
1130
  const hasNetworkConfig = customConfig?.network?.allowedDomains !== undefined ||
@@ -773,13 +1132,83 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
773
1132
  if (hasNetworkConfig) {
774
1133
  await waitForNetworkInitialization();
775
1134
  }
1135
+ const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
1136
+ // Per-exec FILE denies (customConfig only — the session-level
1137
+ // config's denies were already stamped at initialize()).
1138
+ // Paths go through `expandWindowsFsPaths` — the SAME
1139
+ // chokepoint the session-level set uses (point-in-time glob
1140
+ // expand, normalize, missing→drop) — so a per-exec entry
1141
+ // resolves identically to its session-level equivalent.
1142
+ // macOS/Linux per-exec already reuses session-level expansion;
1143
+ // Windows now matches.
1144
+ //
1145
+ // The dedup against `windowsFsStampedSet` is an OPTIMIZATION,
1146
+ // not a correctness gate: re-stamping a session-held path
1147
+ // under the exec's distinct holder is refcount-safe but wastes
1148
+ // a SetSecurityInfo round-trip.
1149
+ //
1150
+ // filesystem.disabled bypasses ALL filesystem rule generation
1151
+ // — including credential-derived file denies — same ordering
1152
+ // as session-level `computeWindowsFsAccessSet` (credential
1153
+ // ENV: mode:'deny' is structural under the fresh srt-sandbox
1154
+ // env; mode:'mask' sentinels are passed via the --env
1155
+ // overlay).
1156
+ // Per-exec allowRead/allowWrite throw — `srt-win exec` only
1157
+ // exposes `--deny-*`; per-exec grants are not implemented.
1158
+ const fsCfg = customConfig?.filesystem;
1159
+ let perExecDenyRead = [];
1160
+ let perExecDenyWrite = [];
1161
+ if (!fsCfg?.disabled) {
1162
+ if (fsCfg?.allowRead?.length || fsCfg?.allowWrite?.length) {
1163
+ throw new Error(`Per-exec filesystem.allowRead/allowWrite is not supported ` +
1164
+ `on Windows — \`srt-win exec\` only exposes per-exec ` +
1165
+ `denies. Set them at the session level (initialize()).`);
1166
+ }
1167
+ const rawRead = [
1168
+ ...(fsCfg?.denyRead ?? []),
1169
+ ...getCredentialDenyReadPaths(customConfig?.credentials),
1170
+ ];
1171
+ const rawWrite = fsCfg?.denyWrite ?? [];
1172
+ // Skip on the dominant path (no per-exec fs or
1173
+ // credential-file deny).
1174
+ if (rawRead.length > 0 || rawWrite.length > 0) {
1175
+ const sessRead = new Set(windowsFsStampedSet?.denyRead ?? []);
1176
+ const sessWrite = new Set(windowsFsStampedSet?.denyWrite ?? []);
1177
+ const expand = expandWindowsFsPaths;
1178
+ perExecDenyRead = expand(rawRead).filter(p => !sessRead.has(p));
1179
+ perExecDenyWrite = expand(rawWrite).filter(p => !sessRead.has(p) && !sessWrite.has(p));
1180
+ }
1181
+ }
1182
+ // Per-exec deny rides on argv (`acl stamp` reads stdin, but
1183
+ // exec's stdin belongs to the child). The CreateProcessW
1184
+ // length check lives in `wrapCommandWithSandboxWindows`
1185
+ // where the full argv (incl. shell + user command) is known.
1186
+ //
1187
+ // The `denyReadPaths` half of the SESSION-level credentials
1188
+ // is already unioned into the stamp set at initialize() time
1189
+ // via `computeWindowsFsAccessSet`.
776
1190
  return wrapCommandWithSandboxWindows({
777
1191
  command,
778
- group: getWindowsGroupRef(),
779
1192
  httpProxyPort: hasNetworkConfig ? getProxyPort() : undefined,
780
1193
  socksProxyPort: hasNetworkConfig ? getSocksProxyPort() : undefined,
781
1194
  proxyAuthToken: hasNetworkConfig ? proxyAuthToken : undefined,
782
- binShell,
1195
+ // mode:'deny' env vars are structurally absent (fresh
1196
+ // srt-sandbox profile env). mode:'mask' sentinels are
1197
+ // passed via the --env overlay so the sandboxed child sees
1198
+ // the sentinel value, same as macOS/Linux.
1199
+ setEnvVars: credentialRestrictions.setEnvVars,
1200
+ denyRead: perExecDenyRead,
1201
+ denyWrite: perExecDenyWrite,
1202
+ // safe.directory: cwd + the resolved session-level write
1203
+ // grants — exactly the working-tree roots the sandbox user
1204
+ // has MODIFY on and where git will see real-user-owned files.
1205
+ cwd,
1206
+ allowWrite: windowsFsStampedSet?.grantWrite,
1207
+ caCertPath: mitmCA?.trustBundlePath,
1208
+ binShell: parseWindowsBinShell(binShell),
1209
+ srtWin: customConfig?.windows?.srtWin
1210
+ ? resolveSrtWin(customConfig.windows.srtWin)
1211
+ : (srtWinSpawn ?? resolveSrtWin(config?.windows?.srtWin)),
783
1212
  });
784
1213
  }
785
1214
  // macOS/Linux: delegate to the existing string wrapper, then put
@@ -799,7 +1228,7 @@ function getConfig() {
799
1228
  * Update the sandbox configuration in place.
800
1229
  *
801
1230
  * **Network/allowlist changes are a live swap**: the running
802
- * http/socks proxies read `config.network.allowedDomains` /
1231
+ * mux proxy reads `config.network.allowedDomains` /
803
1232
  * `deniedDomains` per-request (via `filterNetworkRequest`), so
804
1233
  * reassigning `config` here takes effect on the next connection
805
1234
  * with no proxy rebind and no port change — on every platform,
@@ -808,12 +1237,21 @@ function getConfig() {
808
1237
  *
809
1238
  * Filesystem changes (denyRead/denyWrite) are NOT applied live:
810
1239
  * macOS bakes them into the seatbelt profile at wrap time, and
811
- * Windows will need an explicit re-stamp. To change FS
812
- * restrictions, reset() then initialize() with the new config.
1240
+ * Linux/Windows bake them into the bwrap argv / DENY-ACE set at
1241
+ * wrap time. Call reset() + initialize() to apply a new
1242
+ * filesystem config.
813
1243
  *
814
1244
  * @param newConfig - The new configuration to use
815
1245
  */
816
1246
  function updateConfig(newConfig) {
1247
+ if (getPlatform() === 'windows' &&
1248
+ config &&
1249
+ !sameWindowsStampSet(newConfig)) {
1250
+ logForDebugging(`[Sandbox Windows] updateConfig: the resolved file-access set ` +
1251
+ `(filesystem.* ∪ credentials.files) changed but the ACL ` +
1252
+ `stamp/grant is session-wide — call reset() then initialize() ` +
1253
+ `to apply. The previously-applied set stays in effect.`, { level: 'warn' });
1254
+ }
817
1255
  // Deep clone the config to avoid mutations. structuredClone cannot clone
818
1256
  // functions, so pull filterRequest out, clone the rest, and put it back —
819
1257
  // a function reference is immutable in the sense that matters here.
@@ -950,6 +1388,38 @@ function forceCloseHttpServer(server) {
950
1388
  });
951
1389
  }
952
1390
  async function reset() {
1391
+ // Windows: release this session's sandbox-user ACEs. Best-effort
1392
+ // — log anomalies rather than throw, so teardown always
1393
+ // completes. Leftover ACEs are recoverable later via
1394
+ // `srt-win acl recover` (which sweeps by trustee SID).
1395
+ if (windowsFsStampedSet && windowsFsSbUserSid) {
1396
+ const sb = windowsFsSbUserSid;
1397
+ // Captured at initialize() — the SAME binary the grants/stamps
1398
+ // were applied with, immune to `config` mutation between.
1399
+ const srtWin = srtWinSpawn;
1400
+ // 'restored'/'alreadyOriginal' are the pre- same-user-removal
1401
+ // srt-win's success vocabulary; 'revoked'/'stillHeld' are the
1402
+ // post-. Either is non-anomalous.
1403
+ const ok = new Set(['revoked', 'stillHeld', 'restored', 'alreadyOriginal']);
1404
+ const log = (kind, e) => {
1405
+ if (!ok.has(e.status)) {
1406
+ logForDebugging(`[Sandbox Windows] ${kind}: '${e.path}' ${e.status} — ` +
1407
+ `ACE may be left in place; resolve and run ` +
1408
+ `\`srt-win acl recover\` to clear`, { level: 'warn' });
1409
+ }
1410
+ };
1411
+ for (const e of revokeWindowsAcl({ sandboxUserSid: sb, srtWin }) ?? []) {
1412
+ log('grant revoke', e);
1413
+ }
1414
+ for (const e of restoreWindowsAcl({ sandboxUserSid: sb, srtWin }) ?? []) {
1415
+ log('deny restore', e);
1416
+ }
1417
+ }
1418
+ windowsFsStampedSet = undefined;
1419
+ windowsFsSbUserSid = undefined;
1420
+ windowsFsRawInputs = undefined;
1421
+ srtWinSpawn = undefined;
1422
+ // windowsWfpVerified is NOT cleared — per-process, not per-session.
953
1423
  // Clean up any leftover bwrap mount points. Force past the
954
1424
  // active-sandbox counter — reset() means the session is over.
955
1425
  cleanupBwrapMountPoints({ force: true });
@@ -958,6 +1428,10 @@ async function reset() {
958
1428
  logMonitorShutdown();
959
1429
  logMonitorShutdown = undefined;
960
1430
  }
1431
+ if (linuxMonitor) {
1432
+ linuxMonitor.stop();
1433
+ linuxMonitor = undefined;
1434
+ }
961
1435
  if (managerContext?.linuxBridge) {
962
1436
  const { httpSocketPath, socksSocketPath, httpBridgeProcess, socksBridgeProcess, } = managerContext.linuxBridge;
963
1437
  // Kill both bridges and wait for them to exit
@@ -994,6 +1468,13 @@ async function reset() {
994
1468
  if (mitmCA) {
995
1469
  closePromises.push(disposeMitmCA(mitmCA));
996
1470
  }
1471
+ if (muxProxyServer) {
1472
+ closePromises.push(muxProxyServer.close().catch((error) => {
1473
+ logForDebugging(`Error closing mux proxy server: ${error.message}`, {
1474
+ level: 'error',
1475
+ });
1476
+ }));
1477
+ }
997
1478
  if (httpProxyServer) {
998
1479
  closePromises.push(forceCloseHttpServer(httpProxyServer));
999
1480
  }
@@ -1008,6 +1489,7 @@ async function reset() {
1008
1489
  // Wait for all servers to close
1009
1490
  await Promise.all(closePromises);
1010
1491
  // Clear references
1492
+ muxProxyServer = undefined;
1011
1493
  httpProxyServer = undefined;
1012
1494
  proxyAuthToken = undefined;
1013
1495
  socksProxyServer = undefined;
@@ -1015,6 +1497,8 @@ async function reset() {
1015
1497
  initializationPromise = undefined;
1016
1498
  parentProxy = undefined;
1017
1499
  mitmCA = undefined;
1500
+ sentinelRegistry.clear();
1501
+ maskedFileStore.dispose();
1018
1502
  }
1019
1503
  function getSandboxViolationStore() {
1020
1504
  return sandboxViolationStore;
@@ -1045,7 +1529,7 @@ function annotateStderrWithSandboxFailures(command, stderr) {
1045
1529
  function getLinuxGlobPatternWarnings() {
1046
1530
  // Only warn on Linux/WSL (bubblewrap doesn't support globs)
1047
1531
  // macOS supports glob patterns via regex conversion
1048
- if (getPlatform() !== 'linux' || !config) {
1532
+ if (getPlatform() !== 'linux' || !config || config.filesystem.disabled) {
1049
1533
  return [];
1050
1534
  }
1051
1535
  const globPatterns = [];
@@ -1096,6 +1580,8 @@ export const SandboxManager = {
1096
1580
  cleanupAfterCommand,
1097
1581
  reset,
1098
1582
  getMitmCA: () => mitmCA,
1583
+ getSentinelRegistry: () => sentinelRegistry,
1584
+ getMaskedFileStore: () => maskedFileStore,
1099
1585
  getSandboxViolationStore,
1100
1586
  annotateStderrWithSandboxFailures,
1101
1587
  getLinuxGlobPatternWarnings,