@sysid/sandbox-runtime-improved 0.0.55-sysid.1 → 0.0.61-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 (118) hide show
  1. package/README.md +43 -2
  2. package/dist/cli.js +8 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +6 -6
  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 +72 -0
  9. package/dist/sandbox/credential-mask-files.d.ts.map +1 -0
  10. package/dist/sandbox/credential-mask-files.js +146 -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 +32 -1
  21. package/dist/sandbox/http-proxy.d.ts.map +1 -1
  22. package/dist/sandbox/http-proxy.js +10 -2
  23. package/dist/sandbox/http-proxy.js.map +1 -1
  24. package/dist/sandbox/linux-sandbox-utils.d.ts +17 -0
  25. package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
  26. package/dist/sandbox/linux-sandbox-utils.js +155 -47
  27. package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
  28. package/dist/sandbox/listen-in-range.d.ts +12 -0
  29. package/dist/sandbox/listen-in-range.d.ts.map +1 -0
  30. package/dist/sandbox/listen-in-range.js +43 -0
  31. package/dist/sandbox/listen-in-range.js.map +1 -0
  32. package/dist/sandbox/macos-sandbox-utils.d.ts +13 -0
  33. package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
  34. package/dist/sandbox/macos-sandbox-utils.js +76 -14
  35. package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
  36. package/dist/sandbox/mitm-ca.d.ts +18 -2
  37. package/dist/sandbox/mitm-ca.d.ts.map +1 -1
  38. package/dist/sandbox/mitm-ca.js +49 -9
  39. package/dist/sandbox/mitm-ca.js.map +1 -1
  40. package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
  41. package/dist/sandbox/mitm-leaf.js +24 -6
  42. package/dist/sandbox/mitm-leaf.js.map +1 -1
  43. package/dist/sandbox/mux-proxy.d.ts +59 -0
  44. package/dist/sandbox/mux-proxy.d.ts.map +1 -0
  45. package/dist/sandbox/mux-proxy.js +159 -0
  46. package/dist/sandbox/mux-proxy.js.map +1 -0
  47. package/dist/sandbox/request-filter.d.ts +11 -1
  48. package/dist/sandbox/request-filter.d.ts.map +1 -1
  49. package/dist/sandbox/request-filter.js.map +1 -1
  50. package/dist/sandbox/sandbox-config.d.ts +383 -1
  51. package/dist/sandbox/sandbox-config.d.ts.map +1 -1
  52. package/dist/sandbox/sandbox-config.js +252 -1
  53. package/dist/sandbox/sandbox-config.js.map +1 -1
  54. package/dist/sandbox/sandbox-manager.d.ts +4 -0
  55. package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
  56. package/dist/sandbox/sandbox-manager.js +618 -168
  57. package/dist/sandbox/sandbox-manager.js.map +1 -1
  58. package/dist/sandbox/sandbox-schemas.d.ts +27 -0
  59. package/dist/sandbox/sandbox-schemas.d.ts.map +1 -1
  60. package/dist/sandbox/sandbox-utils.d.ts +42 -6
  61. package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
  62. package/dist/sandbox/sandbox-utils.js +115 -27
  63. package/dist/sandbox/sandbox-utils.js.map +1 -1
  64. package/dist/sandbox/socks-proxy.d.ts +11 -5
  65. package/dist/sandbox/socks-proxy.d.ts.map +1 -1
  66. package/dist/sandbox/socks-proxy.js +13 -81
  67. package/dist/sandbox/socks-proxy.js.map +1 -1
  68. package/dist/sandbox/tls-terminate-proxy.d.ts +2 -2
  69. package/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -1
  70. package/dist/sandbox/tls-terminate-proxy.js +31 -5
  71. package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
  72. package/dist/sandbox/windows-sandbox-utils.d.ts +347 -11
  73. package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
  74. package/dist/sandbox/windows-sandbox-utils.js +437 -45
  75. package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
  76. package/package.json +7 -4
  77. package/vendor/seccomp/build.ts +8 -30
  78. package/vendor/srt-win/build.ts +21 -0
  79. package/dist/vendor/seccomp/Dockerfile.build +0 -6
  80. package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
  81. package/dist/vendor/seccomp/build.ts +0 -91
  82. package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
  83. package/dist/vendor/seccomp-src/apply-seccomp.c +0 -280
  84. package/dist/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  85. package/dist/vendor/srt-win/Cargo.lock +0 -361
  86. package/dist/vendor/srt-win/Cargo.toml +0 -46
  87. package/dist/vendor/srt-win/ci/cleanup.ps1 +0 -49
  88. package/dist/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  89. package/dist/vendor/srt-win/ci/smoke.ps1 +0 -307
  90. package/dist/vendor/srt-win/src/job.rs +0 -102
  91. package/dist/vendor/srt-win/src/launch.rs +0 -732
  92. package/dist/vendor/srt-win/src/lib.rs +0 -20
  93. package/dist/vendor/srt-win/src/main.rs +0 -669
  94. package/dist/vendor/srt-win/src/self_protect.rs +0 -169
  95. package/dist/vendor/srt-win/src/sid.rs +0 -296
  96. package/dist/vendor/srt-win/src/token.rs +0 -341
  97. package/dist/vendor/srt-win/src/util.rs +0 -42
  98. package/dist/vendor/srt-win/src/wfp.rs +0 -992
  99. package/dist/vendor/srt-win/src/winsta.rs +0 -209
  100. package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
  101. package/vendor/seccomp-src/apply-seccomp.c +0 -280
  102. package/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  103. package/vendor/srt-win/Cargo.lock +0 -361
  104. package/vendor/srt-win/Cargo.toml +0 -46
  105. package/vendor/srt-win/ci/cleanup.ps1 +0 -49
  106. package/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  107. package/vendor/srt-win/ci/smoke.ps1 +0 -307
  108. package/vendor/srt-win/src/job.rs +0 -102
  109. package/vendor/srt-win/src/launch.rs +0 -732
  110. package/vendor/srt-win/src/lib.rs +0 -20
  111. package/vendor/srt-win/src/main.rs +0 -669
  112. package/vendor/srt-win/src/self_protect.rs +0 -169
  113. package/vendor/srt-win/src/sid.rs +0 -296
  114. package/vendor/srt-win/src/token.rs +0 -341
  115. package/vendor/srt-win/src/util.rs +0 -42
  116. package/vendor/srt-win/src/wfp.rs +0 -992
  117. package/vendor/srt-win/src/winsta.rs +0 -209
  118. package/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
@@ -1,18 +1,22 @@
1
1
  import { createHttpProxyServer } from './http-proxy.js';
2
2
  import { createSocksProxyServer } from './socks-proxy.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';
3
7
  import { createMitmCA, 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';
12
- import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, ensureSandboxTmpdir, } from './sandbox-utils.js';
15
+ import { checkWindowsDependencies, wrapCommandWithSandboxWindows, parseWindowsBinShell, expandWindowsFsDenyPaths, stampWindowsAcl, restoreWindowsAcl, getWindowsSandboxUserStatus, getWindowsSandboxCaCert, WINDOWS_ACL_PATH_OK, WINDOWS_ACL_PARENT_OK, DEFAULT_WINDOWS_GROUP_NAME, DEFAULT_WINDOWS_PROXY_PORT_RANGE, } from './windows-sandbox-utils.js';
16
+ import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, normalizePathForSandbox, ensureSandboxTmpdir, } from './sandbox-utils.js';
13
17
  import { SandboxViolationStore } from './sandbox-violation-store.js';
14
- import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy, stripBrackets, } from './parent-proxy.js';
15
- import { isIP } from 'node:net';
18
+ import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy, } from './parent-proxy.js';
19
+ import { matchesDomainPattern } from './domain-pattern.js';
16
20
  import { EOL } from 'node:os';
17
21
  // ============================================================================
18
22
  // Private Module State
@@ -20,6 +24,7 @@ import { EOL } from 'node:os';
20
24
  let config;
21
25
  let httpProxyServer;
22
26
  let socksProxyServer;
27
+ let muxProxyServer;
23
28
  let managerContext;
24
29
  let initializationPromise;
25
30
  let cleanupRegistered = false;
@@ -30,7 +35,28 @@ let mitmCA;
30
35
  // the sandbox child env, checked on every CONNECT/request — so a host process
31
36
  // dialing 127.0.0.1:<proxyPort> can't reach the filter callback.
32
37
  let proxyAuthToken;
38
+ // Windows: the resolved {denyRead, denyWrite} that was actually
39
+ // passed to `srt-win acl stamp` at initialize(). `undefined` means
40
+ // no stamp was applied (gates passing `--holder-pid` to exec —
41
+ // which engages the per-exec dir/file fence — and running `acl
42
+ // restore` at reset()).
43
+ let windowsFsStampedSet;
44
+ // The group reference that was passed to `srt-win acl stamp`.
45
+ // reset() restores against THIS, not the current config — a
46
+ // group change between stamp and restore would otherwise
47
+ // target the wrong group's broker DACL.
48
+ let windowsFsStampedGroup;
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;
33
53
  const sandboxViolationStore = new SandboxViolationStore();
54
+ // Per-session sentinel↔real-value map for masked credentials. Lives only in
55
+ // process memory; never written to disk or logged. Cleared on reset().
56
+ const sentinelRegistry = new SentinelRegistry();
57
+ // Temp dir holding the sentinel-content fake files for masked credential
58
+ // files. Created lazily on first masked file; removed on reset().
59
+ const maskedFileStore = new MaskedFileStore();
34
60
  // ============================================================================
35
61
  // Private Helper Functions (not exported)
36
62
  // ============================================================================
@@ -48,26 +74,6 @@ function registerCleanup() {
48
74
  process.once('SIGTERM', cleanupHandler);
49
75
  cleanupRegistered = true;
50
76
  }
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
77
  async function filterNetworkRequest(port, host, sandboxAskCallback) {
72
78
  if (!config) {
73
79
  logForDebugging('No config available, denying network request');
@@ -133,6 +139,24 @@ async function filterNetworkRequest(port, host, sandboxAskCallback) {
133
139
  * Returns the socket path if the host matches any MITM domain pattern,
134
140
  * otherwise returns undefined.
135
141
  */
142
+ /**
143
+ * Build the header-mutation callback that substitutes sentinel→real for
144
+ * masked credentials. Returns undefined when no `credentials` block is
145
+ * configured — wiring the seam at all is unnecessary then.
146
+ *
147
+ * Per-host gating happens inside the registry: each sentinel carries its
148
+ * own injectHosts list and substitutes independently, so credential A's
149
+ * sentinel cannot be laundered through credential B's allowed host. The
150
+ * returned closure does not log header values; the registry holds the only
151
+ * copy of the real value.
152
+ */
153
+ function buildCredentialInjector() {
154
+ if (!config?.credentials)
155
+ return undefined;
156
+ return (headers, destHost) => {
157
+ sentinelRegistry.substituteInHeaders(headers, destHost, matchesDomainPattern);
158
+ };
159
+ }
136
160
  function getMitmSocketPath(host) {
137
161
  if (!config?.network.mitmProxy) {
138
162
  return undefined;
@@ -147,101 +171,82 @@ function getMitmSocketPath(host) {
147
171
  return undefined;
148
172
  }
149
173
  /**
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).
174
+ * Per-host TLS-termination opt-out from network.tlsTerminate.excludeDomains.
175
+ * Only consulted by the HTTP proxy when tlsTerminate is enabled; exempted
176
+ * hosts fall back to the opaque CONNECT tunnel (still allowlist-filtered),
177
+ * so mTLS / cert-pinning clients can complete their own handshake.
153
178
  *
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.
179
+ * Matches the canonicalized hostname, like the allow/deny filter
180
+ * (filterNetworkRequest) otherwise a spelling the allowlist accepts after
181
+ * canonicalization (`127.1`, a trailing-dot FQDN) would dodge the exclusion
182
+ * and get terminated anyway.
159
183
  */
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
- });
184
+ function shouldTerminateTLSForHost(host) {
185
+ const excludeDomains = config?.network.tlsTerminate?.excludeDomains;
186
+ if (!excludeDomains?.length)
187
+ return true;
188
+ const canonicalHost = canonicalizeHost(host) ?? host;
189
+ for (const pattern of excludeDomains) {
190
+ if (!matchesDomainPattern(canonicalHost, pattern))
191
+ continue;
192
+ logForDebugging(`Host ${host} matches tlsTerminate.excludeDomains pattern ${pattern}; skipping TLS termination`);
193
+ // Masked-credential substitution only happens on the terminated path,
194
+ // so a credential whose injectHosts cover this host can never be
195
+ // injected here the upstream gets the placeholder. Config validation
196
+ // rejects the fully-contradictory spellings; this flags the partial
197
+ // ones (e.g. default injectHosts = allowedDomains) at the moment they
198
+ // actually bite.
199
+ const masked = sentinelRegistry.namesInjectableAt(canonicalHost, matchesDomainPattern);
200
+ if (masked.length > 0) {
201
+ logForDebugging(`tlsTerminate.excludeDomains: masked credential(s) ${masked.join(', ')} ` +
202
+ `are configured for injection at ${host}, but its connections are ` +
203
+ `not terminated, so the upstream will receive the placeholder`, { level: 'error' });
204
+ }
205
+ return false;
206
+ }
207
+ return true;
194
208
  }
195
- async function startHttpProxyServer(sandboxAskCallback, portRange, excludePorts) {
209
+ async function startMuxProxyServer(sandboxAskCallback, portRange) {
210
+ const injectCredentials = buildCredentialInjector();
196
211
  httpProxyServer = createHttpProxyServer({
197
212
  filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
198
213
  getMitmSocketPath,
199
214
  mitmCA,
215
+ shouldTerminateTLS: shouldTerminateTLSForHost,
200
216
  filterRequest: config?.network.filterRequest,
217
+ // TLS-terminated path always gets the injector; the plain-HTTP path
218
+ // only when explicitly opted in. Without the opt-in, a sentinel sent
219
+ // over plain HTTP reaches the upstream unchanged (fails closed).
220
+ mutateHeaders: injectCredentials,
221
+ mutateHeadersPlaintext: config?.credentials?.allowPlaintextInject
222
+ ? injectCredentials
223
+ : undefined,
201
224
  parentProxy,
202
225
  proxyAuthToken,
203
226
  });
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
227
  socksProxyServer = createSocksProxyServer({
216
228
  filter: (port, host) => filterNetworkRequest(port, host, sandboxAskCallback),
217
229
  parentProxy,
218
230
  proxyAuthToken,
219
231
  });
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
- }
232
+ muxProxyServer = createMuxProxyServer({
233
+ httpServer: httpProxyServer,
234
+ handleSocksConnection: s => socksProxyServer.handleConnection(s),
235
+ httpBackendPortRange: portRange,
236
+ });
237
+ const mux = muxProxyServer;
238
+ // Backend first so the front-end never accepts a connection that would
239
+ // dispatch to an unbound backend. On Windows the backend's port is
240
+ // excluded when binding the front-end in the same WFP range.
241
+ const backendPort = await mux.listenHttpBackend();
242
+ await listenInRange(mux.server, p => mux.server.listen(p, '127.0.0.1'), portRange, backendPort !== undefined ? new Set([backendPort]) : new Set());
243
+ const muxPort = mux.getPort();
244
+ if (muxPort === undefined) {
245
+ throw new Error('Failed to get mux proxy server port');
243
246
  }
244
- throw new Error(`No free SOCKS port in range ${portRange[0]}-${portRange[1]}: ${lastErr?.message ?? 'all in use'}`);
247
+ mux.unref();
248
+ logForDebugging(`Mux proxy (HTTP+SOCKS) listening on localhost:${muxPort}`);
249
+ return muxPort;
245
250
  }
246
251
  // ============================================================================
247
252
  // Public Module Functions (will be exported via namespace)
@@ -285,6 +290,90 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
285
290
  }
286
291
  // Register cleanup handlers first time
287
292
  registerCleanup();
293
+ // Windows: apply the file-deny stamp set BEFORE any sandboxed
294
+ // child can be spawned. Synchronous (spawnSync) and independent
295
+ // of the network proxies, so do it here rather than inside the
296
+ // initializationPromise. Throws on any failure (including a
297
+ // partial — exit 2 means at least one input was skipped):
298
+ // fail-closed at session start.
299
+ if (getPlatform() === 'windows') {
300
+ // Separate-user opt-in: refuse early when the config asks for
301
+ // it but the account isn't provisioned. Doing this at
302
+ // initialize() (not wrap-time) means the host gets a single
303
+ // actionable error before any per-exec work happens, instead of
304
+ // exit-15 on every command.
305
+ if (runtimeConfig.windows?.asSandboxUser) {
306
+ const u = getWindowsSandboxUserStatus();
307
+ if (!u.provisioned || !u.credPresent) {
308
+ config = undefined;
309
+ throw new Error(`windows.asSandboxUser is set but the sandbox user is not ` +
310
+ `provisioned (user=${u.provisioned}, cred=${u.credPresent}). ` +
311
+ `Run \`npx sandbox-runtime windows-install\` (one UAC ` +
312
+ `prompt) to provision it.`);
313
+ }
314
+ // schannel-level trust under the sandbox user is install-time
315
+ // (cert lifecycle = sandbox-user lifecycle), not per-session.
316
+ // The env-var trust layer covers OpenSSL clients regardless,
317
+ // but System32 curl / IWR / .NET / default-backend git only
318
+ // trust what's in the sandbox user's `CurrentUser\Root` —
319
+ // which `srt-win exec` does not (and must not) write. Gate
320
+ // only on `asSandboxUser`: the same-user path lands on the
321
+ // REAL user's Root, which is out of scope (env-var trust
322
+ // only). Compare thumbprints so a stale install-time CA
323
+ // doesn't pass the gate while schannel rejects the session's
324
+ // proxy-minted leaves.
325
+ if (runtimeConfig.network.tlsTerminate && mitmCA) {
326
+ const installed = getWindowsSandboxCaCert(u);
327
+ const sessionThumb = new X509Certificate(mitmCA.certPem).fingerprint
328
+ .replace(/:/g, '')
329
+ .toUpperCase();
330
+ if (!installed) {
331
+ config = undefined;
332
+ throw new Error(`tlsTerminate with windows.asSandboxUser requires the ` +
333
+ `sandbox to be installed with this CA (thumb=` +
334
+ `${sessionThumb}): run \`srt-win user trust-ca ` +
335
+ `${mitmCA.certPath}\`. Per-exec installs into the ` +
336
+ `sandbox user's Root store are not supported.`);
337
+ }
338
+ if (installed.thumb !== sessionThumb) {
339
+ config = undefined;
340
+ throw new Error(`tlsTerminate with windows.asSandboxUser: the sandbox's ` +
341
+ `installed CA (thumb=${installed.thumb}) doesn't match ` +
342
+ `this session's CA (thumb=${sessionThumb}). Run ` +
343
+ `\`srt-win user trust-ca ${mitmCA.certPath}\` to ` +
344
+ `update it.`);
345
+ }
346
+ }
347
+ }
348
+ try {
349
+ const deny = computeWindowsFsDenySet(runtimeConfig);
350
+ if (deny.denyRead.length > 0 || deny.denyWrite.length > 0) {
351
+ const group = getWindowsGroupRef();
352
+ stampWindowsAcl({
353
+ group,
354
+ denyRead: deny.denyRead,
355
+ denyWrite: deny.denyWrite,
356
+ });
357
+ // Only record the set AFTER a successful stamp — the
358
+ // catch below clears `config`, and a non-undefined
359
+ // stampedSet would leave reset()/updateConfig() seeing a
360
+ // stamp that never landed.
361
+ windowsFsStampedSet = deny;
362
+ windowsFsStampedGroup = group;
363
+ logForDebugging(`[Sandbox Windows] file deny stamped: ` +
364
+ `${deny.denyRead.length} denyRead, ${deny.denyWrite.length} denyWrite`);
365
+ }
366
+ windowsFsRawInputs = rawWindowsFsInputs(runtimeConfig);
367
+ }
368
+ catch (e) {
369
+ // Best-effort release of whatever WAS stamped before the
370
+ // failure (exit-2 partial stamps the resolvable inputs;
371
+ // harmless if nothing was stamped — no holds for this PID).
372
+ restoreWindowsAcl({ group: getWindowsGroupRef() });
373
+ config = undefined;
374
+ throw e;
375
+ }
376
+ }
288
377
  // Initialize network infrastructure
289
378
  initializationPromise = (async () => {
290
379
  try {
@@ -302,27 +391,23 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
302
391
  config.network.httpProxyPort !== undefined
303
392
  ? undefined
304
393
  : randomBytes(16).toString('hex');
305
- let httpProxyPort;
394
+ // The mux front-end serves both protocols on one port. Each side's
395
+ // reported port is the external override if configured, else the mux
396
+ // port — so the public config.network.{http,socks}ProxyPort contract
397
+ // is unchanged. The mux is skipped only when BOTH are external.
398
+ const needLocalProxy = config.network.httpProxyPort === undefined ||
399
+ config.network.socksProxyPort === undefined;
400
+ const muxPort = needLocalProxy
401
+ ? await startMuxProxyServer(sandboxAskCallback, portRange)
402
+ : undefined;
403
+ const httpProxyPort = config.network.httpProxyPort ?? muxPort;
404
+ const socksProxyPort = config.network.socksProxyPort ?? muxPort;
306
405
  if (config.network.httpProxyPort !== undefined) {
307
- // Use external HTTP proxy (don't start a server)
308
- httpProxyPort = config.network.httpProxyPort;
309
406
  logForDebugging(`Using external HTTP proxy on port ${httpProxyPort}`);
310
407
  }
311
- else {
312
- // Start local HTTP proxy
313
- httpProxyPort = await startHttpProxyServer(sandboxAskCallback, portRange, new Set());
314
- }
315
- let socksProxyPort;
316
408
  if (config.network.socksProxyPort !== undefined) {
317
- // Use external SOCKS proxy (don't start a server)
318
- socksProxyPort = config.network.socksProxyPort;
319
409
  logForDebugging(`Using external SOCKS proxy on port ${socksProxyPort}`);
320
410
  }
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
411
  // Initialize platform-specific infrastructure
327
412
  let linuxBridge;
328
413
  if (getPlatform() === 'linux') {
@@ -408,12 +493,99 @@ function checkDependencies(ripgrepConfig) {
408
493
  }
409
494
  return { errors, warnings };
410
495
  }
496
+ /**
497
+ * Build the read-deny / env-unset / env-set maps implied by the
498
+ * `credentials` config.
499
+ *
500
+ * Only explicitly declared sources are restricted: `mode: 'deny'` file
501
+ * entries join the read-deny set, `mode: 'deny'` env vars are unset, and
502
+ * `mode: 'mask'` env vars are set to a per-session sentinel registered in
503
+ * {@link sentinelRegistry}. A masked var with no value in the host
504
+ * environment is skipped — there is nothing to protect, and emitting an
505
+ * unset var would change tool behaviour (presence checks would pass where
506
+ * they didn't before).
507
+ */
508
+ function getCredentialRestrictions(credentials, allowedDomains) {
509
+ if (!credentials) {
510
+ return {
511
+ denyReadPaths: [],
512
+ unsetEnvVars: [],
513
+ setEnvVars: {},
514
+ maskedFileBinds: [],
515
+ maskedFileStoreDir: undefined,
516
+ };
517
+ }
518
+ const denyReadPaths = getCredentialDenyReadPaths(credentials);
519
+ const unsetEnvVars = [];
520
+ const setEnvVars = {};
521
+ for (const v of credentials.envVars ?? []) {
522
+ if (v.mode === 'deny') {
523
+ unsetEnvVars.push(v.name);
524
+ }
525
+ else if (v.mode === 'mask') {
526
+ const real = process.env[v.name];
527
+ if (real === undefined)
528
+ continue;
529
+ // Effective injectHosts: per-entry narrows; if unset, default to
530
+ // every reachable host (network.allowedDomains). injectHosts is an
531
+ // *optional narrowing*, not a required allowlist. Trade-off: a
532
+ // masked credential with no injectHosts is injectable at every host
533
+ // the sandbox can reach — narrow it explicitly when the credential
534
+ // should only go to a subset.
535
+ const injectHosts = v.injectHosts ?? allowedDomains ?? [];
536
+ setEnvVars[v.name] = sentinelRegistry.register(v.name, real, injectHosts);
537
+ }
538
+ }
539
+ // Masked files: read the real bytes on the host, register a sentinel,
540
+ // write it to a fake file in the manager-owned temp dir. Missing/unreadable
541
+ // entries are skipped (same posture as an unset masked env var).
542
+ const files = credentials.files ?? [];
543
+ const maskedFileBinds = buildMaskedFileBinds(files, allowedDomains ?? [], sentinelRegistry, maskedFileStore);
544
+ return {
545
+ denyReadPaths,
546
+ unsetEnvVars: [...new Set(unsetEnvVars)],
547
+ setEnvVars,
548
+ maskedFileBinds,
549
+ maskedFileStoreDir: maskedFileStore.dirPath,
550
+ };
551
+ }
552
+ /**
553
+ * Pure (side-effect-free) chokepoint for credential file-deny
554
+ * paths — `credentials.files` entries with `mode: 'deny'`. Any
555
+ * code that needs the credential→denyRead contribution routes
556
+ * through here so a comparison predicate can read it without
557
+ * touching {@link sentinelRegistry}.
558
+ */
559
+ function getCredentialDenyReadPaths(credentials) {
560
+ const files = credentials?.files ?? [];
561
+ return [...new Set(files.filter(f => f.mode === 'deny').map(f => f.path))];
562
+ }
563
+ /** Order-insensitive string-set equality. */
564
+ function setEq(a, b) {
565
+ if (a.length !== b.length)
566
+ return false;
567
+ const bs = new Set(b);
568
+ return a.every(v => bs.has(v));
569
+ }
570
+ /**
571
+ * Union the explicit `filesystem.denyRead` with credential-derived
572
+ * deny paths. The single source of "what files does this config
573
+ * want read-denied" — all platforms route through here so a new
574
+ * credential kind that contributes deny paths reaches every
575
+ * backend.
576
+ */
577
+ function unionDenyReadPaths(denyRead, credentialRestrictions) {
578
+ return [...new Set([...denyRead, ...credentialRestrictions.denyReadPaths])];
579
+ }
411
580
  function getFsReadConfig() {
412
- if (!config) {
581
+ if (!config || config.filesystem.disabled) {
413
582
  return { denyOnly: [], allowWithinDeny: [] };
414
583
  }
584
+ // Credential deny paths are unioned with the caller's denyRead — never
585
+ // replacing it — so explicit filesystem restrictions always survive.
586
+ const rawDenyRead = unionDenyReadPaths(config.filesystem.denyRead, getCredentialRestrictions(config.credentials, config.network.allowedDomains));
415
587
  const denyPaths = [];
416
- for (const p of config.filesystem.denyRead) {
588
+ for (const p of rawDenyRead) {
417
589
  const stripped = removeTrailingGlobSuffix(p);
418
590
  if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
419
591
  // Expand glob to concrete paths on Linux (bubblewrap doesn't support globs)
@@ -447,6 +619,9 @@ function getFsWriteConfig() {
447
619
  if (!config) {
448
620
  return { allowOnly: getDefaultWritePaths(), denyWithinAllow: [] };
449
621
  }
622
+ if (config.filesystem.disabled) {
623
+ return { allowOnly: ['/'], denyWithinAllow: [] };
624
+ }
450
625
  // Filter out glob patterns on Linux/WSL for allowWrite (bubblewrap doesn't support globs)
451
626
  const allowPaths = config.filesystem.allowWrite
452
627
  .map(path => removeTrailingGlobSuffix(path))
@@ -474,6 +649,91 @@ function getFsWriteConfig() {
474
649
  denyWithinAllow: denyPaths,
475
650
  };
476
651
  }
652
+ /**
653
+ * Build the Windows file-deny set from `runtimeConfig`. Globs are
654
+ * expanded to concrete file paths (point-in-time — a file
655
+ * appearing after this returns is NOT covered). Throws on any
656
+ * directory match (file-only for now) and on any unsupported
657
+ * config field that would otherwise be silently dropped.
658
+ *
659
+ * `denyRead` ← `filesystem.denyRead` ∪ `credentials`-derived deny
660
+ * paths (via {@link getCredentialDenyReadPaths}).
661
+ * `denyWrite` ← `filesystem.denyWrite`.
662
+ *
663
+ * Not supported on Windows (throws if non-empty so the caller
664
+ * never silently runs with a weaker-than-configured policy):
665
+ * - `filesystem.allowRead` (re-allow within a denied region)
666
+ * - `filesystem.allowWrite` as a write allow-list — the Windows
667
+ * backend is deny-listed only; the sandboxed child writes
668
+ * wherever the host user can, minus `denyWrite`.
669
+ */
670
+ function computeWindowsFsDenySet(c) {
671
+ const fs = c.filesystem;
672
+ // filesystem.disabled bypasses ALL filesystem rule generation —
673
+ // same as the macOS/Linux wrapWithSandbox path (readConfig /
674
+ // writeConfig left undefined). On Windows this means no ACL
675
+ // stamp; credential FILE denies are dropped along with the rest
676
+ // (credential ENV scrubbing is independent and still applied at
677
+ // wrap time). Returning empty here means initialize() applies no
678
+ // stamp.
679
+ if (fs?.disabled) {
680
+ return { denyRead: [], denyWrite: [] };
681
+ }
682
+ if (fs?.allowRead?.length) {
683
+ throw new Error(`filesystem.allowRead (re-allow within denyRead) is not supported ` +
684
+ `on Windows. Remove the entries or narrow filesystem.denyRead to ` +
685
+ `exclude them.`);
686
+ }
687
+ if (fs?.allowWrite?.length) {
688
+ throw new Error(`filesystem.allowWrite is not supported on Windows — the Windows ` +
689
+ `sandbox is deny-listed only (the child writes wherever the host ` +
690
+ `user can, minus filesystem.denyWrite). Remove the allowWrite ` +
691
+ `entries.`);
692
+ }
693
+ const denyRead = expandWindowsFsDenyPaths([
694
+ ...new Set([
695
+ ...(fs?.denyRead ?? []),
696
+ ...getCredentialDenyReadPaths(c.credentials),
697
+ ]),
698
+ ]);
699
+ const denyWrite = expandWindowsFsDenyPaths(fs?.denyWrite ?? []);
700
+ return { denyRead, denyWrite };
701
+ }
702
+ /**
703
+ * Snapshot the raw config fields that feed
704
+ * {@link computeWindowsFsDenySet}. Used by updateConfig() to
705
+ * short-circuit the resolved-set diff (which re-runs glob
706
+ * expansion) when nothing relevant changed.
707
+ */
708
+ function rawWindowsFsInputs(c) {
709
+ // Keyed exactly on what {@link computeWindowsFsDenySet} reads:
710
+ // disabled, denyRead, denyWrite, and the credential file-deny
711
+ // paths. `network.allowedDomains` does NOT feed file-deny
712
+ // (only mask injectHosts), so a network-only updateConfig
713
+ // hits the cache.
714
+ return {
715
+ disabled: c.filesystem.disabled ?? false,
716
+ denyRead: [...c.filesystem.denyRead],
717
+ denyWrite: [...c.filesystem.denyWrite],
718
+ credFiles: getCredentialDenyReadPaths(c.credentials),
719
+ };
720
+ }
721
+ function sameRawWindowsFsInputs(a, b) {
722
+ return (a.disabled === b.disabled &&
723
+ setEq(a.denyRead, b.denyRead) &&
724
+ setEq(a.denyWrite, b.denyWrite) &&
725
+ setEq(a.credFiles, b.credFiles));
726
+ }
727
+ /**
728
+ * True when `newConfig`'s file-deny inputs match what was
729
+ * stamped at initialize(). Compares raw inputs only (cheap,
730
+ * order-insensitive); never re-expands globs — updateConfig is
731
+ * warn-only on Windows and the resolved set wouldn't be used.
732
+ */
733
+ function sameWindowsStampSet(newConfig) {
734
+ return (windowsFsRawInputs !== undefined &&
735
+ sameRawWindowsFsInputs(windowsFsRawInputs, rawWindowsFsInputs(newConfig)));
736
+ }
477
737
  function getNetworkRestrictionConfig() {
478
738
  if (!config) {
479
739
  return {};
@@ -563,6 +823,22 @@ async function waitForNetworkInitialization() {
563
823
  }
564
824
  async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
565
825
  const platform = getPlatform();
826
+ // filesystem.disabled bypasses ALL filesystem rule generation. Both
827
+ // platform wrappers treat readConfig/writeConfig === undefined as "no
828
+ // filesystem restrictions" (seatbelt emits `(allow file-write*)`; bwrap
829
+ // skips the `--ro-bind / /` root and all path binds).
830
+ //
831
+ // Precedence: when a caller passes a per-call filesystem override at all,
832
+ // its `disabled` (defaulting to false) wins outright. A global
833
+ // disabled=true must not silently discard a per-call tightening that
834
+ // omits the new key.
835
+ const fsDisabled = customConfig?.filesystem !== undefined
836
+ ? (customConfig.filesystem.disabled ?? false)
837
+ : (config?.filesystem.disabled ?? false);
838
+ // Credential env handling is independent of filesystem policy: unsetEnvVars /
839
+ // setEnvVars must be applied even when fsDisabled (the credential file
840
+ // deny-reads are dropped, but env scrubbing still happens).
841
+ const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
566
842
  // Get configs - use custom if provided, otherwise fall back to main config
567
843
  // If neither exists, defaults to empty arrays (most restrictive)
568
844
  // Always include default system write paths (like /dev/null, /tmp/claude)
@@ -570,52 +846,62 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
570
846
  // Strip trailing /** and filter remaining globs on Linux (bwrap needs
571
847
  // real paths, not globs; macOS subpath matching is also recursive so
572
848
  // stripping is harmless there).
573
- const stripWriteGlobs = (paths) => paths
574
- .map(p => removeTrailingGlobSuffix(p))
575
- .filter(p => {
576
- if (getPlatform() === 'linux' && containsGlobChars(p)) {
577
- logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`);
578
- return false;
579
- }
580
- return true;
581
- });
582
- const userAllowWrite = stripWriteGlobs(customConfig?.filesystem?.allowWrite ?? config?.filesystem.allowWrite ?? []);
583
- const writeConfig = {
584
- allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
585
- denyWithinAllow: stripWriteGlobs(customConfig?.filesystem?.denyWrite ?? config?.filesystem.denyWrite ?? []),
586
- };
587
- const rawDenyRead = customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [];
588
- const expandedDenyRead = [];
589
- for (const p of rawDenyRead) {
590
- const stripped = removeTrailingGlobSuffix(p);
591
- if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
592
- expandedDenyRead.push(...expandGlobPattern(p));
593
- }
594
- else {
595
- expandedDenyRead.push(stripped);
849
+ let writeConfig;
850
+ let readConfig;
851
+ if (!fsDisabled) {
852
+ const stripWriteGlobs = (paths) => paths
853
+ .map(p => removeTrailingGlobSuffix(p))
854
+ .filter(p => {
855
+ if (getPlatform() === 'linux' && containsGlobChars(p)) {
856
+ logForDebugging(`[Sandbox] Skipping glob write pattern on Linux: ${p}`);
857
+ return false;
858
+ }
859
+ return true;
860
+ });
861
+ const userAllowWrite = stripWriteGlobs(customConfig?.filesystem?.allowWrite ??
862
+ config?.filesystem.allowWrite ??
863
+ []);
864
+ writeConfig = {
865
+ allowOnly: [...getDefaultWritePaths(), ...userAllowWrite],
866
+ denyWithinAllow: stripWriteGlobs(customConfig?.filesystem?.denyWrite ??
867
+ config?.filesystem.denyWrite ??
868
+ []),
869
+ };
870
+ // Credential deny paths are unioned with the caller's denyRead — never
871
+ // replacing it — so explicit filesystem restrictions always survive.
872
+ const rawDenyRead = unionDenyReadPaths(customConfig?.filesystem?.denyRead ?? config?.filesystem.denyRead ?? [], credentialRestrictions);
873
+ const expandedDenyRead = [];
874
+ for (const p of rawDenyRead) {
875
+ const stripped = removeTrailingGlobSuffix(p);
876
+ if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
877
+ expandedDenyRead.push(...expandGlobPattern(p));
878
+ }
879
+ else {
880
+ expandedDenyRead.push(stripped);
881
+ }
596
882
  }
597
- }
598
- const rawAllowRead = customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [];
599
- const expandedAllowRead = [];
600
- for (const p of rawAllowRead) {
601
- const stripped = removeTrailingGlobSuffix(p);
602
- if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
603
- expandedAllowRead.push(...expandGlobPattern(p));
883
+ const rawAllowRead = customConfig?.filesystem?.allowRead ?? config?.filesystem.allowRead ?? [];
884
+ const expandedAllowRead = [];
885
+ for (const p of rawAllowRead) {
886
+ const stripped = removeTrailingGlobSuffix(p);
887
+ if (getPlatform() === 'linux' && containsGlobChars(stripped)) {
888
+ expandedAllowRead.push(...expandGlobPattern(p));
889
+ }
890
+ else {
891
+ expandedAllowRead.push(stripped);
892
+ }
604
893
  }
605
- else {
606
- expandedAllowRead.push(stripped);
894
+ // The TLS-termination CA cert and the trust bundle the env vars point at
895
+ // (NODE_EXTRA_CA_CERTS etc.) must be readable by the child, even if their
896
+ // paths fall under a user-configured denyRead.
897
+ if (mitmCA) {
898
+ expandedAllowRead.push(mitmCA.certPath, mitmCA.trustBundlePath);
607
899
  }
900
+ readConfig = {
901
+ denyOnly: expandedDenyRead,
902
+ allowWithinDeny: expandedAllowRead,
903
+ };
608
904
  }
609
- // The TLS-termination CA cert must be readable by the child so the trust
610
- // env vars (NODE_EXTRA_CA_CERTS etc.) resolve, even if its path falls
611
- // under a user-configured denyRead.
612
- if (mitmCA) {
613
- expandedAllowRead.push(mitmCA.certPath);
614
- }
615
- const readConfig = {
616
- denyOnly: expandedDenyRead,
617
- allowWithinDeny: expandedAllowRead,
618
- };
619
905
  // Check if network config is specified - this determines if we need network restrictions
620
906
  // Network restriction is needed when:
621
907
  // 1. customConfig has network.allowedDomains defined (even if empty array = block all)
@@ -649,9 +935,12 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
649
935
  httpProxyPort: needsNetworkProxy ? getProxyPort() : undefined,
650
936
  socksProxyPort: needsNetworkProxy ? getSocksProxyPort() : undefined,
651
937
  proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
652
- caCertPath: mitmCA?.certPath,
938
+ caCertPath: mitmCA?.trustBundlePath,
653
939
  readConfig,
654
940
  writeConfig,
941
+ unsetEnvVars: credentialRestrictions.unsetEnvVars,
942
+ setEnvVars: credentialRestrictions.setEnvVars,
943
+ maskedFileBinds: credentialRestrictions.maskedFileBinds,
655
944
  allowUnixSockets: getAllowUnixSockets(),
656
945
  allowAllUnixSockets: getAllowAllUnixSockets(),
657
946
  allowLocalBinding: getAllowLocalBinding(),
@@ -682,9 +971,13 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
682
971
  ? managerContext?.socksProxyPort
683
972
  : undefined,
684
973
  proxyAuthToken: needsNetworkProxy ? proxyAuthToken : undefined,
685
- caCertPath: mitmCA?.certPath,
974
+ caCertPath: mitmCA?.trustBundlePath,
686
975
  readConfig,
687
976
  writeConfig,
977
+ unsetEnvVars: credentialRestrictions.unsetEnvVars,
978
+ setEnvVars: credentialRestrictions.setEnvVars,
979
+ maskedFileBinds: credentialRestrictions.maskedFileBinds,
980
+ maskedFileStoreDir: credentialRestrictions.maskedFileStoreDir,
688
981
  enableWeakerNestedSandbox: getEnableWeakerNestedSandbox(),
689
982
  allowAllUnixSockets: getAllowAllUnixSockets(),
690
983
  binShell,
@@ -730,13 +1023,104 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
730
1023
  if (hasNetworkConfig) {
731
1024
  await waitForNetworkInitialization();
732
1025
  }
1026
+ const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
1027
+ // Per-exec FILE denies (customConfig only — the session-level
1028
+ // config's denies were already stamped at initialize()).
1029
+ // Unlike the session-level set, paths are passed through
1030
+ // VERBATIM (normalized only): no glob expansion, no
1031
+ // existsSync filter. `srt-win exec`'s
1032
+ // `canonicalize_deny_targets` is the authority — it
1033
+ // hard-fails on glob/dir/nonexistent so a missing path is a
1034
+ // visible caller error, not a silent skip (the session-level
1035
+ // expand-and-drop-missing was for tolerant point-in-time
1036
+ // globs at init; per-exec is "deny THIS one command" and a
1037
+ // path that doesn't resolve is a bug the caller must see).
1038
+ //
1039
+ // The dedup against `windowsFsStampedSet` is an OPTIMIZATION,
1040
+ // not a correctness gate: re-stamping a session-held path
1041
+ // under the exec's distinct holder is refcount-safe but
1042
+ // wastes a SetSecurityInfo round-trip. The mask-escalation /
1043
+ // hardlink-alias guard lives in srt-win's `ensure_stamped`
1044
+ // (`refuse_escalation = true`), NOT here — canonical-path
1045
+ // identity and concurrent holders are only visible to Rust.
1046
+ //
1047
+ // filesystem.disabled bypasses ALL filesystem rule generation
1048
+ // — including credential-derived file denies — same ordering
1049
+ // as session-level `computeWindowsFsDenySet` (credential ENV
1050
+ // scrubbing is independent and still applied at wrap time).
1051
+ // allowRead/allowWrite throw, also matching session-level:
1052
+ // the Windows file-deny sandbox is deny-only.
1053
+ const fsCfg = customConfig?.filesystem;
1054
+ let perExecDenyRead = [];
1055
+ let perExecDenyWrite = [];
1056
+ if (!fsCfg?.disabled) {
1057
+ if (fsCfg?.allowRead?.length) {
1058
+ throw new Error(`Per-exec filesystem.allowRead (re-allow within denyRead) is ` +
1059
+ `not supported on Windows. Remove the entries or narrow ` +
1060
+ `filesystem.denyRead to exclude them.`);
1061
+ }
1062
+ if (fsCfg?.allowWrite?.length) {
1063
+ throw new Error(`Per-exec filesystem.allowWrite is not supported on Windows — ` +
1064
+ `the Windows sandbox is deny-listed only (the child writes ` +
1065
+ `wherever the host user can, minus filesystem.denyWrite). ` +
1066
+ `Remove the allowWrite entries.`);
1067
+ }
1068
+ const rawRead = [
1069
+ ...(fsCfg?.denyRead ?? []),
1070
+ ...getCredentialDenyReadPaths(customConfig?.credentials),
1071
+ ];
1072
+ const rawWrite = fsCfg?.denyWrite ?? [];
1073
+ // Skip on the dominant path (no per-exec fs or
1074
+ // credential-file deny) — this used to call
1075
+ // `computeWindowsFsDenySet` (glob walk + statSync per
1076
+ // match) on every exec, including with
1077
+ // `customConfig === undefined`.
1078
+ if (rawRead.length > 0 || rawWrite.length > 0) {
1079
+ const sessRead = new Set(windowsFsStampedSet?.denyRead ?? []);
1080
+ const sessWrite = new Set(windowsFsStampedSet?.denyWrite ?? []);
1081
+ const norm = (raw) => [
1082
+ ...new Set(raw.map(normalizePathForSandbox)),
1083
+ ];
1084
+ perExecDenyRead = norm(rawRead).filter(p => !sessRead.has(p));
1085
+ perExecDenyWrite = norm(rawWrite).filter(p => !sessRead.has(p) && !sessWrite.has(p));
1086
+ }
1087
+ }
1088
+ // Per-exec deny rides on argv (`acl stamp` reads stdin, but
1089
+ // exec's stdin belongs to the child). The CreateProcessW
1090
+ // length check lives in `wrapCommandWithSandboxWindows`
1091
+ // where the full argv (incl. shell + user command) is known.
1092
+ //
1093
+ // Credential env restrictions are passed INTO the wrapper so it
1094
+ // can apply them BEFORE merging the proxy env (same precedence
1095
+ // as the macOS/Linux `env -u … VAR=… sandbox-exec` order — the
1096
+ // sandbox's own proxy plumbing must survive a caller listing
1097
+ // e.g. HTTPS_PROXY as a denied credential). The `denyReadPaths`
1098
+ // half of the SESSION-level credentials is already unioned into
1099
+ // the stamp set at initialize() time via
1100
+ // `computeWindowsFsDenySet`.
733
1101
  return wrapCommandWithSandboxWindows({
734
1102
  command,
735
1103
  group: getWindowsGroupRef(),
1104
+ sublayerGuid: config?.windows?.wfpSublayerGuid,
736
1105
  httpProxyPort: hasNetworkConfig ? getProxyPort() : undefined,
737
1106
  socksProxyPort: hasNetworkConfig ? getSocksProxyPort() : undefined,
738
1107
  proxyAuthToken: hasNetworkConfig ? proxyAuthToken : undefined,
739
- binShell,
1108
+ unsetEnvVars: credentialRestrictions.unsetEnvVars,
1109
+ setEnvVars: credentialRestrictions.setEnvVars,
1110
+ // Engage the session-level fence only when this session
1111
+ // actually stamped — keeps `srt-win exec` standalone (no
1112
+ // state-DB dependency) when no file-deny is configured. The
1113
+ // per-exec deny below opens its own fence under the exec's
1114
+ // own PID regardless.
1115
+ holderPid: windowsFsStampedSet ? process.pid : undefined,
1116
+ denyRead: perExecDenyRead,
1117
+ denyWrite: perExecDenyWrite,
1118
+ // Opt-in two-hop separate-user launch. Additive — defaults
1119
+ // false, the same-user deny-only-group path is unchanged.
1120
+ // Provisioning was checked at initialize().
1121
+ asSandboxUser: config?.windows?.asSandboxUser ?? false,
1122
+ caCertPath: mitmCA?.trustBundlePath,
1123
+ binShell: parseWindowsBinShell(binShell),
740
1124
  });
741
1125
  }
742
1126
  // macOS/Linux: delegate to the existing string wrapper, then put
@@ -765,12 +1149,33 @@ function getConfig() {
765
1149
  *
766
1150
  * Filesystem changes (denyRead/denyWrite) are NOT applied live:
767
1151
  * macOS bakes them into the seatbelt profile at wrap time, and
768
- * Windows will need an explicit re-stamp. To change FS
769
- * restrictions, reset() then initialize() with the new config.
1152
+ * Windows applies the ACL stamp once at `initialize()` (a live
1153
+ * swap would mean releasing all of this holder's claims and
1154
+ * re-stamping, which opens an unprotected window). To change FS
1155
+ * restrictions, `reset()` then `initialize()` with the new
1156
+ * config; on Windows, calling this with a config whose file-deny
1157
+ * inputs (`filesystem.denyRead`/`denyWrite`, `credentials.files`)
1158
+ * differ from those passed at `initialize()` logs a warning and
1159
+ * the stamped set stays as-is.
770
1160
  *
771
1161
  * @param newConfig - The new configuration to use
772
1162
  */
773
1163
  function updateConfig(newConfig) {
1164
+ if (getPlatform() === 'windows' &&
1165
+ config &&
1166
+ (newConfig.windows?.groupSid !== config.windows?.groupSid ||
1167
+ newConfig.windows?.groupName !== config.windows?.groupName)) {
1168
+ throw new Error('Changing the Windows sandbox group requires reset() and ' +
1169
+ 're-initialize().');
1170
+ }
1171
+ if (getPlatform() === 'windows' &&
1172
+ config &&
1173
+ !sameWindowsStampSet(newConfig)) {
1174
+ logForDebugging(`[Sandbox Windows] updateConfig: the resolved file-deny set ` +
1175
+ `(filesystem.denyRead/denyWrite ∪ credentials.files) changed but ` +
1176
+ `the ACL stamp is session-wide — call reset() then initialize() ` +
1177
+ `to apply. The previously-stamped set stays in effect.`, { level: 'warn' });
1178
+ }
774
1179
  // Deep clone the config to avoid mutations. structuredClone cannot clone
775
1180
  // functions, so pull filterRequest out, clone the rest, and put it back —
776
1181
  // a function reference is immutable in the sense that matters here.
@@ -907,6 +1312,39 @@ function forceCloseHttpServer(server) {
907
1312
  });
908
1313
  }
909
1314
  async function reset() {
1315
+ // Windows: release this session's file-deny stamps. Best-effort
1316
+ // — log anomalies (relocated/missing/tampered/…) rather than
1317
+ // throw, so teardown always completes. The on-disk hash-ACE
1318
+ // marker means a stamp left in place is recoverable later via
1319
+ // `srt-win acl recover`.
1320
+ if (windowsFsStampedSet) {
1321
+ const r = restoreWindowsAcl({
1322
+ group: windowsFsStampedGroup ?? getWindowsGroupRef(),
1323
+ });
1324
+ if (r) {
1325
+ for (const e of r.paths ?? []) {
1326
+ if (!WINDOWS_ACL_PATH_OK.has(e.status)) {
1327
+ const tail = e.status === 'missing'
1328
+ ? ' — file no longer exists; snapshot row kept for tracking'
1329
+ : (e.movedTo ? ` (now at '${e.movedTo}')` : '') +
1330
+ ' — stamp left in place; resolve and run ' +
1331
+ '`srt-win acl recover` to clear';
1332
+ logForDebugging(`[Sandbox Windows] file-deny restore: '${e.path}' ` +
1333
+ `${e.status}${tail}`, { level: 'warn' });
1334
+ }
1335
+ }
1336
+ for (const e of r.parents ?? []) {
1337
+ if (!WINDOWS_ACL_PARENT_OK.has(e.status)) {
1338
+ logForDebugging(`[Sandbox Windows] file-deny restore: parent ` +
1339
+ `'${e.path}' ${e.status}` +
1340
+ (e.error ? `: ${e.error}` : ''), { level: 'warn' });
1341
+ }
1342
+ }
1343
+ }
1344
+ }
1345
+ windowsFsStampedSet = undefined;
1346
+ windowsFsStampedGroup = undefined;
1347
+ windowsFsRawInputs = undefined;
910
1348
  // Clean up any leftover bwrap mount points. Force past the
911
1349
  // active-sandbox counter — reset() means the session is over.
912
1350
  cleanupBwrapMountPoints({ force: true });
@@ -951,6 +1389,13 @@ async function reset() {
951
1389
  if (mitmCA) {
952
1390
  closePromises.push(disposeMitmCA(mitmCA));
953
1391
  }
1392
+ if (muxProxyServer) {
1393
+ closePromises.push(muxProxyServer.close().catch((error) => {
1394
+ logForDebugging(`Error closing mux proxy server: ${error.message}`, {
1395
+ level: 'error',
1396
+ });
1397
+ }));
1398
+ }
954
1399
  if (httpProxyServer) {
955
1400
  closePromises.push(forceCloseHttpServer(httpProxyServer));
956
1401
  }
@@ -965,6 +1410,7 @@ async function reset() {
965
1410
  // Wait for all servers to close
966
1411
  await Promise.all(closePromises);
967
1412
  // Clear references
1413
+ muxProxyServer = undefined;
968
1414
  httpProxyServer = undefined;
969
1415
  proxyAuthToken = undefined;
970
1416
  socksProxyServer = undefined;
@@ -972,6 +1418,8 @@ async function reset() {
972
1418
  initializationPromise = undefined;
973
1419
  parentProxy = undefined;
974
1420
  mitmCA = undefined;
1421
+ sentinelRegistry.clear();
1422
+ maskedFileStore.dispose();
975
1423
  }
976
1424
  function getSandboxViolationStore() {
977
1425
  return sandboxViolationStore;
@@ -1002,7 +1450,7 @@ function annotateStderrWithSandboxFailures(command, stderr) {
1002
1450
  function getLinuxGlobPatternWarnings() {
1003
1451
  // Only warn on Linux/WSL (bubblewrap doesn't support globs)
1004
1452
  // macOS supports glob patterns via regex conversion
1005
- if (getPlatform() !== 'linux' || !config) {
1453
+ if (getPlatform() !== 'linux' || !config || config.filesystem.disabled) {
1006
1454
  return [];
1007
1455
  }
1008
1456
  const globPatterns = [];
@@ -1053,6 +1501,8 @@ export const SandboxManager = {
1053
1501
  cleanupAfterCommand,
1054
1502
  reset,
1055
1503
  getMitmCA: () => mitmCA,
1504
+ getSentinelRegistry: () => sentinelRegistry,
1505
+ getMaskedFileStore: () => maskedFileStore,
1056
1506
  getSandboxViolationStore,
1057
1507
  annotateStderrWithSandboxFailures,
1058
1508
  getLinuxGlobPatternWarnings,