@sysid/sandbox-runtime-improved 0.0.61-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 (56) hide show
  1. package/README.md +76 -15
  2. package/dist/cli.js +12 -18
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +4 -4
  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 +100 -15
  9. package/dist/sandbox/credential-mask-files.d.ts.map +1 -1
  10. package/dist/sandbox/credential-mask-files.js +172 -20
  11. package/dist/sandbox/credential-mask-files.js.map +1 -1
  12. package/dist/sandbox/http-proxy.d.ts +1 -1
  13. package/dist/sandbox/http-proxy.d.ts.map +1 -1
  14. package/dist/sandbox/http-proxy.js +20 -0
  15. package/dist/sandbox/http-proxy.js.map +1 -1
  16. package/dist/sandbox/linux-sandbox-utils.d.ts +5 -0
  17. package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
  18. package/dist/sandbox/linux-sandbox-utils.js +29 -19
  19. package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
  20. package/dist/sandbox/linux-violation-monitor.d.ts +48 -0
  21. package/dist/sandbox/linux-violation-monitor.d.ts.map +1 -0
  22. package/dist/sandbox/linux-violation-monitor.js +156 -0
  23. package/dist/sandbox/linux-violation-monitor.js.map +1 -0
  24. package/dist/sandbox/macos-sandbox-utils.js +3 -3
  25. package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
  26. package/dist/sandbox/mitm-ca.d.ts +52 -1
  27. package/dist/sandbox/mitm-ca.d.ts.map +1 -1
  28. package/dist/sandbox/mitm-ca.js +143 -11
  29. package/dist/sandbox/mitm-ca.js.map +1 -1
  30. package/dist/sandbox/mitm-leaf.d.ts +1 -1
  31. package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
  32. package/dist/sandbox/mitm-leaf.js +21 -32
  33. package/dist/sandbox/mitm-leaf.js.map +1 -1
  34. package/dist/sandbox/sandbox-config.d.ts +231 -40
  35. package/dist/sandbox/sandbox-config.d.ts.map +1 -1
  36. package/dist/sandbox/sandbox-config.js +161 -32
  37. package/dist/sandbox/sandbox-config.js.map +1 -1
  38. package/dist/sandbox/sandbox-manager.d.ts +1 -1
  39. package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
  40. package/dist/sandbox/sandbox-manager.js +331 -252
  41. package/dist/sandbox/sandbox-manager.js.map +1 -1
  42. package/dist/sandbox/sandbox-utils.d.ts +13 -0
  43. package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
  44. package/dist/sandbox/sandbox-utils.js +32 -7
  45. package/dist/sandbox/sandbox-utils.js.map +1 -1
  46. package/dist/sandbox/windows-sandbox-utils.d.ts +353 -297
  47. package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
  48. package/dist/sandbox/windows-sandbox-utils.js +516 -396
  49. package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
  50. package/dist/utils/shell-quote.d.ts +27 -0
  51. package/dist/utils/shell-quote.d.ts.map +1 -0
  52. package/dist/utils/shell-quote.js +47 -0
  53. package/dist/utils/shell-quote.js.map +1 -0
  54. package/package.json +1 -3
  55. package/vendor/seccomp/arm64/apply-seccomp +0 -0
  56. package/vendor/seccomp/x64/apply-seccomp +0 -0
@@ -4,7 +4,7 @@ import { createMuxProxyServer } from './mux-proxy.js';
4
4
  import { listenInRange } from './listen-in-range.js';
5
5
  import { SentinelRegistry } from './credential-sentinel.js';
6
6
  import { MaskedFileStore, buildMaskedFileBinds, } from './credential-mask-files.js';
7
- import { createMitmCA, disposeMitmCA } from './mitm-ca.js';
7
+ import { createMitmCA, CRL_PATH, disposeMitmCA, } from './mitm-ca.js';
8
8
  import { logForDebugging } from '../utils/debug.js';
9
9
  import { whichSync } from '../utils/which.js';
10
10
  import { getPlatform, getWslVersion } from '../utils/platform.js';
@@ -12,12 +12,14 @@ import * as fs from 'fs';
12
12
  import { randomBytes, X509Certificate } from 'node:crypto';
13
13
  import { wrapCommandWithSandboxLinux, initializeLinuxNetworkBridge, checkLinuxDependencies, cleanupBwrapMountPoints, } from './linux-sandbox-utils.js';
14
14
  import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, } from './macos-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';
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';
17
+ import { getDefaultWritePaths, containsGlobChars, removeTrailingGlobSuffix, expandGlobPattern, ensureSandboxTmpdir, } from './sandbox-utils.js';
17
18
  import { SandboxViolationStore } from './sandbox-violation-store.js';
18
19
  import { canonicalizeHost, isValidHost, redactUrl, resolveParentProxy, } from './parent-proxy.js';
19
20
  import { matchesDomainPattern } from './domain-pattern.js';
20
21
  import { EOL } from 'node:os';
22
+ import { dirname } from 'node:path';
21
23
  // ============================================================================
22
24
  // Private Module State
23
25
  // ============================================================================
@@ -29,27 +31,36 @@ let managerContext;
29
31
  let initializationPromise;
30
32
  let cleanupRegistered = false;
31
33
  let logMonitorShutdown;
34
+ let linuxMonitor;
32
35
  let parentProxy;
33
36
  let mitmCA;
34
37
  // Per-session proxy auth token. Generated at proxy start, exported only into
35
38
  // the sandbox child env, checked on every CONNECT/request — so a host process
36
39
  // dialing 127.0.0.1:<proxyPort> can't reach the filter callback.
37
40
  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()).
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()).
43
44
  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;
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
49
  // The RAW config inputs that produced `windowsFsStampedSet`.
50
50
  // updateConfig() compares these (not the resolved set) so it never
51
51
  // re-expands globs — see `sameWindowsStampSet`.
52
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;
53
64
  const sandboxViolationStore = new SandboxViolationStore();
54
65
  // Per-session sentinel↔real-value map for masked credentials. Lives only in
55
66
  // process memory; never written to disk or logged. Cleared on reset().
@@ -288,88 +299,165 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
288
299
  logMonitorShutdown = startMacOSSandboxLogMonitor(sandboxViolationStore.addViolation.bind(sandboxViolationStore), config.ignoreViolations);
289
300
  logForDebugging('Started macOS sandbox log monitor');
290
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
+ }
291
319
  // Register cleanup handlers first time
292
320
  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.
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.
299
325
  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) {
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) {
308
353
  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.`);
354
+ throw e;
313
355
  }
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
- }
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.`);
346
384
  }
347
385
  }
386
+ // Filesystem grants/denies — additive sandbox-user ACEs.
348
387
  try {
349
- const deny = computeWindowsFsDenySet(runtimeConfig);
350
- if (deny.denyRead.length > 0 || deny.denyWrite.length > 0) {
351
- const group = getWindowsGroupRef();
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) {
352
426
  stampWindowsAcl({
353
- group,
354
- denyRead: deny.denyRead,
355
- denyWrite: deny.denyWrite,
427
+ sandboxUserSid: sb,
428
+ denyRead: acc.denyRead,
429
+ denyWrite: acc.denyWrite,
430
+ srtWin,
356
431
  });
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`);
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`);
365
449
  }
366
450
  windowsFsRawInputs = rawWindowsFsInputs(runtimeConfig);
367
451
  }
368
452
  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() });
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;
373
461
  config = undefined;
374
462
  throw e;
375
463
  }
@@ -402,6 +490,18 @@ async function initialize(runtimeConfig, sandboxAskCallback, enableLogMonitor =
402
490
  : undefined;
403
491
  const httpProxyPort = config.network.httpProxyPort ?? muxPort;
404
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
+ }
405
505
  if (config.network.httpProxyPort !== undefined) {
406
506
  logForDebugging(`Using external HTTP proxy on port ${httpProxyPort}`);
407
507
  }
@@ -444,16 +544,6 @@ function isSupportedPlatform() {
444
544
  }
445
545
  return platform === 'macos' || platform === 'windows';
446
546
  }
447
- /**
448
- * Resolve the Windows group reference from config. Used by both the
449
- * dependency check and `wrapWithSandbox` so they agree.
450
- */
451
- function getWindowsGroupRef() {
452
- return {
453
- groupName: config?.windows?.groupName ?? DEFAULT_WINDOWS_GROUP_NAME,
454
- groupSid: config?.windows?.groupSid,
455
- };
456
- }
457
547
  function isSandboxingEnabled() {
458
548
  // Sandboxing is enabled if config has been set (via initialize())
459
549
  return config !== undefined;
@@ -487,7 +577,18 @@ function checkDependencies(ripgrepConfig) {
487
577
  warnings.push(...linuxDeps.warnings);
488
578
  }
489
579
  else if (platform === 'windows') {
490
- 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
+ });
491
592
  errors.push(...winDeps.errors);
492
593
  warnings.push(...winDeps.warnings);
493
594
  }
@@ -539,10 +640,13 @@ function getCredentialRestrictions(credentials, allowedDomains) {
539
640
  // Masked files: read the real bytes on the host, register a sentinel,
540
641
  // write it to a fake file in the manager-owned temp dir. Missing/unreadable
541
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.
542
646
  const files = credentials.files ?? [];
543
- const maskedFileBinds = buildMaskedFileBinds(files, allowedDomains ?? [], sentinelRegistry, maskedFileStore);
647
+ const { binds: maskedFileBinds, degradeToDenyPaths } = buildMaskedFileBinds(files, allowedDomains ?? [], sentinelRegistry, maskedFileStore);
544
648
  return {
545
- denyReadPaths,
649
+ denyReadPaths: [...new Set([...denyReadPaths, ...degradeToDenyPaths])],
546
650
  unsetEnvVars: [...new Set(unsetEnvVars)],
547
651
  setEnvVars,
548
652
  maskedFileBinds,
@@ -560,13 +664,6 @@ function getCredentialDenyReadPaths(credentials) {
560
664
  const files = credentials?.files ?? [];
561
665
  return [...new Set(files.filter(f => f.mode === 'deny').map(f => f.path))];
562
666
  }
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
667
  /**
571
668
  * Union the explicit `filesystem.denyRead` with credential-derived
572
669
  * deny paths. The single source of "what files does this config
@@ -650,78 +747,81 @@ function getFsWriteConfig() {
650
747
  };
651
748
  }
652
749
  /**
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`.
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).
662
755
  *
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`.
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.
669
762
  */
670
- function computeWindowsFsDenySet(c) {
763
+ function computeWindowsFsAccessSet(c) {
671
764
  const fs = c.filesystem;
672
765
  // filesystem.disabled bypasses ALL filesystem rule generation —
673
766
  // same as the macOS/Linux wrapWithSandbox path (readConfig /
674
767
  // 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.
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).
679
772
  if (fs?.disabled) {
680
- return { denyRead: [], denyWrite: [] };
773
+ return { grantRead: [], grantWrite: [], denyRead: [], denyWrite: [] };
681
774
  }
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([
775
+ const expand = expandWindowsFsPaths;
776
+ const denyRead = expand([
694
777
  ...new Set([
695
778
  ...(fs?.denyRead ?? []),
696
779
  ...getCredentialDenyReadPaths(c.credentials),
697
780
  ]),
698
781
  ]);
699
- const denyWrite = expandWindowsFsDenyPaths(fs?.denyWrite ?? []);
700
- return { denyRead, denyWrite };
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
+ };
701
793
  }
702
794
  /**
703
795
  * Snapshot the raw config fields that feed
704
- * {@link computeWindowsFsDenySet}. Used by updateConfig() to
796
+ * {@link computeWindowsFsAccessSet}. Used by updateConfig() to
705
797
  * short-circuit the resolved-set diff (which re-runs glob
706
798
  * expansion) when nothing relevant changed.
707
799
  */
708
800
  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.
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.
714
804
  return {
715
805
  disabled: c.filesystem.disabled ?? false,
716
806
  denyRead: [...c.filesystem.denyRead],
717
807
  denyWrite: [...c.filesystem.denyWrite],
808
+ allowRead: [...(c.filesystem.allowRead ?? [])],
809
+ allowWrite: [...c.filesystem.allowWrite],
718
810
  credFiles: getCredentialDenyReadPaths(c.credentials),
719
811
  };
720
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
+ }
721
819
  function sameRawWindowsFsInputs(a, b) {
722
820
  return (a.disabled === b.disabled &&
723
821
  setEq(a.denyRead, b.denyRead) &&
724
822
  setEq(a.denyWrite, b.denyWrite) &&
823
+ setEq(a.allowRead, b.allowRead) &&
824
+ setEq(a.allowWrite, b.allowWrite) &&
725
825
  setEq(a.credFiles, b.credFiles));
726
826
  }
727
827
  /**
@@ -987,6 +1087,7 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
987
1087
  seccompConfig: getSeccompConfig(),
988
1088
  bwrapPath: config?.bwrapPath,
989
1089
  socatPath: config?.socatPath,
1090
+ observeSocketPath: linuxMonitor?.observeSocketPath,
990
1091
  abortSignal,
991
1092
  });
992
1093
  case 'windows':
@@ -1008,14 +1109,22 @@ async function wrapWithSandbox(command, binShell, customConfig, abortSignal) {
1008
1109
  * `spawn(argv[0], argv.slice(1), {shell: false, env})`.
1009
1110
  *
1010
1111
  * On Windows this is the ONLY supported wrap method (see
1011
- * {@link wrapWithSandbox}); `env` carries the full proxy set that the
1012
- * sandboxed child inherits (`srt-win exec` forwards its environment
1013
- * 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
1014
1116
  * macOS/Linux `argv` is `[binShell, '-c', <wrapWithSandbox result>]`
1015
1117
  * (proxy env is baked into that command) and `env` is the unchanged
1016
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.
1017
1126
  */
1018
- async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal) {
1127
+ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal, cwd) {
1019
1128
  const platform = getPlatform();
1020
1129
  if (platform === 'windows') {
1021
1130
  const hasNetworkConfig = customConfig?.network?.allowedDomains !== undefined ||
@@ -1026,44 +1135,34 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
1026
1135
  const credentialRestrictions = getCredentialRestrictions(customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? config?.network?.allowedDomains);
1027
1136
  // Per-exec FILE denies (customConfig only — the session-level
1028
1137
  // 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).
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.
1038
1144
  //
1039
1145
  // The dedup against `windowsFsStampedSet` is an OPTIMIZATION,
1040
1146
  // 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.
1147
+ // under the exec's distinct holder is refcount-safe but wastes
1148
+ // a SetSecurityInfo round-trip.
1046
1149
  //
1047
1150
  // filesystem.disabled bypasses ALL filesystem rule generation
1048
1151
  // — 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.
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.
1053
1158
  const fsCfg = customConfig?.filesystem;
1054
1159
  let perExecDenyRead = [];
1055
1160
  let perExecDenyWrite = [];
1056
1161
  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.`);
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()).`);
1067
1166
  }
1068
1167
  const rawRead = [
1069
1168
  ...(fsCfg?.denyRead ?? []),
@@ -1071,18 +1170,13 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
1071
1170
  ];
1072
1171
  const rawWrite = fsCfg?.denyWrite ?? [];
1073
1172
  // 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`.
1173
+ // credential-file deny).
1078
1174
  if (rawRead.length > 0 || rawWrite.length > 0) {
1079
1175
  const sessRead = new Set(windowsFsStampedSet?.denyRead ?? []);
1080
1176
  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));
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));
1086
1180
  }
1087
1181
  }
1088
1182
  // Per-exec deny rides on argv (`acl stamp` reads stdin, but
@@ -1090,37 +1184,31 @@ async function wrapWithSandboxArgv(command, binShell, customConfig, abortSignal)
1090
1184
  // length check lives in `wrapCommandWithSandboxWindows`
1091
1185
  // where the full argv (incl. shell + user command) is known.
1092
1186
  //
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`.
1187
+ // The `denyReadPaths` half of the SESSION-level credentials
1188
+ // is already unioned into the stamp set at initialize() time
1189
+ // via `computeWindowsFsAccessSet`.
1101
1190
  return wrapCommandWithSandboxWindows({
1102
1191
  command,
1103
- group: getWindowsGroupRef(),
1104
- sublayerGuid: config?.windows?.wfpSublayerGuid,
1105
1192
  httpProxyPort: hasNetworkConfig ? getProxyPort() : undefined,
1106
1193
  socksProxyPort: hasNetworkConfig ? getSocksProxyPort() : undefined,
1107
1194
  proxyAuthToken: hasNetworkConfig ? proxyAuthToken : undefined,
1108
- unsetEnvVars: credentialRestrictions.unsetEnvVars,
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.
1109
1199
  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
1200
  denyRead: perExecDenyRead,
1117
1201
  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,
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,
1122
1207
  caCertPath: mitmCA?.trustBundlePath,
1123
1208
  binShell: parseWindowsBinShell(binShell),
1209
+ srtWin: customConfig?.windows?.srtWin
1210
+ ? resolveSrtWin(customConfig.windows.srtWin)
1211
+ : (srtWinSpawn ?? resolveSrtWin(config?.windows?.srtWin)),
1124
1212
  });
1125
1213
  }
1126
1214
  // macOS/Linux: delegate to the existing string wrapper, then put
@@ -1140,7 +1228,7 @@ function getConfig() {
1140
1228
  * Update the sandbox configuration in place.
1141
1229
  *
1142
1230
  * **Network/allowlist changes are a live swap**: the running
1143
- * http/socks proxies read `config.network.allowedDomains` /
1231
+ * mux proxy reads `config.network.allowedDomains` /
1144
1232
  * `deniedDomains` per-request (via `filterNetworkRequest`), so
1145
1233
  * reassigning `config` here takes effect on the next connection
1146
1234
  * with no proxy rebind and no port change — on every platform,
@@ -1149,32 +1237,20 @@ function getConfig() {
1149
1237
  *
1150
1238
  * Filesystem changes (denyRead/denyWrite) are NOT applied live:
1151
1239
  * macOS bakes them into the seatbelt profile at wrap time, and
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.
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.
1160
1243
  *
1161
1244
  * @param newConfig - The new configuration to use
1162
1245
  */
1163
1246
  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
1247
  if (getPlatform() === 'windows' &&
1172
1248
  config &&
1173
1249
  !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' });
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' });
1178
1254
  }
1179
1255
  // Deep clone the config to avoid mutations. structuredClone cannot clone
1180
1256
  // functions, so pull filterRequest out, clone the rest, and put it back —
@@ -1312,39 +1388,38 @@ function forceCloseHttpServer(server) {
1312
1388
  });
1313
1389
  }
1314
1390
  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
- }
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' });
1342
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);
1343
1416
  }
1344
1417
  }
1345
1418
  windowsFsStampedSet = undefined;
1346
- windowsFsStampedGroup = undefined;
1419
+ windowsFsSbUserSid = undefined;
1347
1420
  windowsFsRawInputs = undefined;
1421
+ srtWinSpawn = undefined;
1422
+ // windowsWfpVerified is NOT cleared — per-process, not per-session.
1348
1423
  // Clean up any leftover bwrap mount points. Force past the
1349
1424
  // active-sandbox counter — reset() means the session is over.
1350
1425
  cleanupBwrapMountPoints({ force: true });
@@ -1353,6 +1428,10 @@ async function reset() {
1353
1428
  logMonitorShutdown();
1354
1429
  logMonitorShutdown = undefined;
1355
1430
  }
1431
+ if (linuxMonitor) {
1432
+ linuxMonitor.stop();
1433
+ linuxMonitor = undefined;
1434
+ }
1356
1435
  if (managerContext?.linuxBridge) {
1357
1436
  const { httpSocketPath, socksSocketPath, httpBridgeProcess, socksBridgeProcess, } = managerContext.linuxBridge;
1358
1437
  // Kill both bridges and wait for them to exit