@sysid/sandbox-runtime-improved 0.0.56-sysid.1 → 0.0.64-sysid.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +106 -4
  2. package/dist/cli.js +12 -18
  3. package/dist/cli.js.map +1 -1
  4. package/dist/index.d.ts +5 -5
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/sandbox/credential-mask-files.d.ts +157 -0
  9. package/dist/sandbox/credential-mask-files.d.ts.map +1 -0
  10. package/dist/sandbox/credential-mask-files.js +298 -0
  11. package/dist/sandbox/credential-mask-files.js.map +1 -0
  12. package/dist/sandbox/credential-sentinel.d.ts +72 -0
  13. package/dist/sandbox/credential-sentinel.d.ts.map +1 -0
  14. package/dist/sandbox/credential-sentinel.js +130 -0
  15. package/dist/sandbox/credential-sentinel.js.map +1 -0
  16. package/dist/sandbox/domain-pattern.d.ts +36 -0
  17. package/dist/sandbox/domain-pattern.d.ts.map +1 -0
  18. package/dist/sandbox/domain-pattern.js +60 -0
  19. package/dist/sandbox/domain-pattern.js.map +1 -0
  20. package/dist/sandbox/http-proxy.d.ts +33 -2
  21. package/dist/sandbox/http-proxy.d.ts.map +1 -1
  22. package/dist/sandbox/http-proxy.js +30 -2
  23. package/dist/sandbox/http-proxy.js.map +1 -1
  24. package/dist/sandbox/linux-sandbox-utils.d.ts +20 -0
  25. package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
  26. package/dist/sandbox/linux-sandbox-utils.js +134 -65
  27. package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
  28. package/dist/sandbox/linux-violation-monitor.d.ts +48 -0
  29. package/dist/sandbox/linux-violation-monitor.d.ts.map +1 -0
  30. package/dist/sandbox/linux-violation-monitor.js +156 -0
  31. package/dist/sandbox/linux-violation-monitor.js.map +1 -0
  32. package/dist/sandbox/listen-in-range.d.ts +12 -0
  33. package/dist/sandbox/listen-in-range.d.ts.map +1 -0
  34. package/dist/sandbox/listen-in-range.js +43 -0
  35. package/dist/sandbox/listen-in-range.js.map +1 -0
  36. package/dist/sandbox/macos-sandbox-utils.d.ts +11 -0
  37. package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
  38. package/dist/sandbox/macos-sandbox-utils.js +72 -17
  39. package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
  40. package/dist/sandbox/mitm-ca.d.ts +69 -2
  41. package/dist/sandbox/mitm-ca.d.ts.map +1 -1
  42. package/dist/sandbox/mitm-ca.js +188 -16
  43. package/dist/sandbox/mitm-ca.js.map +1 -1
  44. package/dist/sandbox/mitm-leaf.d.ts +1 -1
  45. package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
  46. package/dist/sandbox/mitm-leaf.js +26 -19
  47. package/dist/sandbox/mitm-leaf.js.map +1 -1
  48. package/dist/sandbox/mux-proxy.d.ts +59 -0
  49. package/dist/sandbox/mux-proxy.d.ts.map +1 -0
  50. package/dist/sandbox/mux-proxy.js +159 -0
  51. package/dist/sandbox/mux-proxy.js.map +1 -0
  52. package/dist/sandbox/request-filter.d.ts +11 -1
  53. package/dist/sandbox/request-filter.d.ts.map +1 -1
  54. package/dist/sandbox/request-filter.js.map +1 -1
  55. package/dist/sandbox/sandbox-config.d.ts +461 -41
  56. package/dist/sandbox/sandbox-config.d.ts.map +1 -1
  57. package/dist/sandbox/sandbox-config.js +342 -26
  58. package/dist/sandbox/sandbox-config.js.map +1 -1
  59. package/dist/sandbox/sandbox-manager.d.ts +5 -1
  60. package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
  61. package/dist/sandbox/sandbox-manager.js +697 -211
  62. package/dist/sandbox/sandbox-manager.js.map +1 -1
  63. package/dist/sandbox/sandbox-schemas.d.ts +15 -0
  64. package/dist/sandbox/sandbox-schemas.d.ts.map +1 -1
  65. package/dist/sandbox/sandbox-utils.d.ts +55 -6
  66. package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
  67. package/dist/sandbox/sandbox-utils.js +143 -30
  68. package/dist/sandbox/sandbox-utils.js.map +1 -1
  69. package/dist/sandbox/socks-proxy.d.ts +11 -5
  70. package/dist/sandbox/socks-proxy.d.ts.map +1 -1
  71. package/dist/sandbox/socks-proxy.js +13 -81
  72. package/dist/sandbox/socks-proxy.js.map +1 -1
  73. package/dist/sandbox/tls-terminate-proxy.d.ts +2 -2
  74. package/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -1
  75. package/dist/sandbox/tls-terminate-proxy.js +31 -5
  76. package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
  77. package/dist/sandbox/windows-sandbox-utils.d.ts +508 -116
  78. package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
  79. package/dist/sandbox/windows-sandbox-utils.js +721 -209
  80. package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
  81. package/dist/utils/shell-quote.d.ts +27 -0
  82. package/dist/utils/shell-quote.d.ts.map +1 -0
  83. package/dist/utils/shell-quote.js +47 -0
  84. package/dist/utils/shell-quote.js.map +1 -0
  85. package/package.json +7 -6
  86. package/vendor/seccomp/arm64/apply-seccomp +0 -0
  87. package/vendor/seccomp/build.ts +8 -30
  88. package/vendor/seccomp/x64/apply-seccomp +0 -0
  89. package/vendor/srt-win/build.ts +21 -0
  90. package/dist/vendor/seccomp/Dockerfile.build +0 -6
  91. package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
  92. package/dist/vendor/seccomp/build.ts +0 -91
  93. package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
  94. package/dist/vendor/seccomp-src/apply-seccomp.c +0 -280
  95. package/dist/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  96. package/dist/vendor/srt-win/Cargo.lock +0 -361
  97. package/dist/vendor/srt-win/Cargo.toml +0 -46
  98. package/dist/vendor/srt-win/ci/cleanup.ps1 +0 -49
  99. package/dist/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  100. package/dist/vendor/srt-win/ci/smoke.ps1 +0 -307
  101. package/dist/vendor/srt-win/src/job.rs +0 -102
  102. package/dist/vendor/srt-win/src/launch.rs +0 -732
  103. package/dist/vendor/srt-win/src/lib.rs +0 -20
  104. package/dist/vendor/srt-win/src/main.rs +0 -669
  105. package/dist/vendor/srt-win/src/self_protect.rs +0 -169
  106. package/dist/vendor/srt-win/src/sid.rs +0 -296
  107. package/dist/vendor/srt-win/src/token.rs +0 -341
  108. package/dist/vendor/srt-win/src/util.rs +0 -42
  109. package/dist/vendor/srt-win/src/wfp.rs +0 -992
  110. package/dist/vendor/srt-win/src/winsta.rs +0 -209
  111. package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
  112. package/vendor/seccomp-src/apply-seccomp.c +0 -280
  113. package/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  114. package/vendor/srt-win/Cargo.lock +0 -361
  115. package/vendor/srt-win/Cargo.toml +0 -46
  116. package/vendor/srt-win/ci/cleanup.ps1 +0 -49
  117. package/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  118. package/vendor/srt-win/ci/smoke.ps1 +0 -307
  119. package/vendor/srt-win/src/job.rs +0 -102
  120. package/vendor/srt-win/src/launch.rs +0 -732
  121. package/vendor/srt-win/src/lib.rs +0 -20
  122. package/vendor/srt-win/src/main.rs +0 -669
  123. package/vendor/srt-win/src/self_protect.rs +0 -169
  124. package/vendor/srt-win/src/sid.rs +0 -296
  125. package/vendor/srt-win/src/token.rs +0 -341
  126. package/vendor/srt-win/src/util.rs +0 -42
  127. package/vendor/srt-win/src/wfp.rs +0 -992
  128. package/vendor/srt-win/src/winsta.rs +0 -209
  129. package/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
@@ -1,33 +1,90 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as net from 'node:net';
2
3
  import * as path from 'node:path';
3
4
  import { spawnSync } from 'node:child_process';
5
+ import { once } from 'node:events';
4
6
  import { fileURLToPath } from 'node:url';
5
7
  import { logForDebugging } from '../utils/debug.js';
6
- import { generateProxyEnvVars } from './sandbox-utils.js';
8
+ import { generateProxyEnvVars, normalizePathForSandbox, containsGlobCharsWin, expandGlobPattern, } from './sandbox-utils.js';
9
+ // Re-export so existing tests (glob-expand.test.ts) and any
10
+ // out-of-tree caller keep their import path.
11
+ export { containsGlobCharsWin, stripExtendedPathPrefix, } from './sandbox-utils.js';
7
12
  /**
8
13
  * Windows sandbox backend.
9
14
  *
10
15
  * Network isolation is enforced by `srt-win.exe` — a Rust helper that
11
- * manages a local discriminator group, a machine-wide WFP filter set
12
- * keyed on that group's SID, and an `exec` subcommand that spawns the
13
- * target under a restricted token (group flipped deny-only) inside a
14
- * hardened job. The sandboxed child reaches the host only via the JS
15
- * http/socks proxies, which `srt-win exec` points at via env vars.
16
+ * provisions a dedicated `srt-sandbox` local user account, installs a
17
+ * machine-wide WFP filter set keyed on that account's SID, and
18
+ * provides an `exec` subcommand that spawns the target via a two-hop
19
+ * launch (broker `CreateProcessWithLogonW(runner)` runner
20
+ * restricted-token child) under `srt-sandbox`. The sandboxed child
21
+ * reaches the host only via the JS mux proxy, which the caller
22
+ * passes in via `--env`.
23
+ *
24
+ * The separate-user account structurally closes the surrogate-spawn
25
+ * class (schtasks, `PROC_THREAD_ATTRIBUTE_PARENT_PROCESS`, BITS,
26
+ * RunAs="Interactive User" COM): the child's token carries a
27
+ * different user SID, so it cannot reach real-user processes, tasks
28
+ * register under `srt-sandbox`, and the user-SID WFP filter fences
29
+ * `srt-sandbox` egress regardless of how the child was spawned.
16
30
  *
17
31
  * This module is a thin wrapper around the `srt-win` CLI; all status
18
- * comes from live enumeration (group via `LookupAccountNameW` +
19
- * token-membership check; WFP via providerData-tag enumeration under
20
- * the configured sublayer). There is no marker file.
32
+ * comes from live enumeration. There is no marker file.
21
33
  *
22
- * Filesystem restrictions are NOT enforced on Windows yet.
34
+ * Filesystem rules (`denyRead`/`denyWrite`/`allowRead`/`allowWrite`)
35
+ * are enforced via additive explicit ACEs for `<sb-SID>` — see
36
+ * {@link grantWindowsAcl} / {@link stampWindowsAcl}.
23
37
  */
24
38
  // ────────────────────────────────────────────────────────────────────
25
39
  // Types
26
40
  // ────────────────────────────────────────────────────────────────────
27
- export const DEFAULT_WINDOWS_GROUP_NAME = 'sandbox-runtime-net';
28
41
  export const DEFAULT_WINDOWS_PROXY_PORT_RANGE = [
29
42
  60080, 60089,
30
43
  ];
44
+ /**
45
+ * Adapter from the cross-platform `binShell?: string` surface
46
+ * ({@link SandboxManager.wrapWithSandboxArgv}) to the Windows
47
+ * discriminated union. Throws on any value outside the recognised
48
+ * set — there is no silent fallback to cmd.exe.
49
+ *
50
+ * Uses `path.win32` explicitly so the function (and its unit test)
51
+ * is platform-independent.
52
+ */
53
+ export function parseWindowsBinShell(raw) {
54
+ if (raw === undefined)
55
+ return { kind: 'cmd' };
56
+ // bash/sh: path semantics — match on basename, keep the caller's
57
+ // absolute path verbatim.
58
+ const base = path.win32.basename(raw).toLowerCase();
59
+ if (base === 'bash' ||
60
+ base === 'bash.exe' ||
61
+ base === 'sh' ||
62
+ base === 'sh.exe') {
63
+ if (!path.win32.isAbsolute(raw)) {
64
+ throw new Error(`binShell bash path must be absolute (got ${JSON.stringify(raw)}); ` +
65
+ `pass the resolved Git Bash install path`);
66
+ }
67
+ return { kind: 'bash', path: raw };
68
+ }
69
+ // cmd/powershell/pwsh: token semantics — match on the FULL string,
70
+ // not basename, so an absolute path to pwsh.exe (whose path we'd
71
+ // otherwise discard) falls through to the explicit throw rather
72
+ // than silently degrading to a PATH lookup.
73
+ switch (raw.toLowerCase()) {
74
+ case 'pwsh':
75
+ case 'pwsh.exe':
76
+ return { kind: 'pwsh' };
77
+ case 'powershell':
78
+ case 'powershell.exe':
79
+ return { kind: 'powershell' };
80
+ case 'cmd':
81
+ case 'cmd.exe':
82
+ return { kind: 'cmd' };
83
+ default:
84
+ throw new Error(`unrecognised binShell ${JSON.stringify(raw)}: expected ` +
85
+ `'cmd' | 'powershell' | 'pwsh' or an absolute path to bash.exe/sh.exe`);
86
+ }
87
+ }
31
88
  // ────────────────────────────────────────────────────────────────────
32
89
  // Binary resolution
33
90
  // ────────────────────────────────────────────────────────────────────
@@ -36,12 +93,22 @@ function repoRoot() {
36
93
  const here = path.dirname(fileURLToPath(import.meta.url));
37
94
  return path.resolve(here, '..', '..');
38
95
  }
96
+ const nodeArchToDir = { x64: 'x64', arm64: 'arm64' };
39
97
  /**
40
- * Locate `srt-win.exe`. Resolution order:
41
- * 1. `SRT_WIN_PATH` env var (CI sets this to the freshly-built binary).
42
- * 2. `<repo>/vendor/srt-win/target/release/srt-win.exe` (local cargo build).
43
- * 3. `<repo>/dist/vendor/srt-win/target/release/srt-win.exe`
44
- * (post-`npm run build` shape, when running from compiled output).
98
+ * Locate the packaged `srt-win.exe`. Resolution order:
99
+ * 1. `<root>/vendor/srt-win/{arch}/srt-win.exe` (prebuilt published npm
100
+ * package, or after `npm run build:srt-win` locally).
101
+ * 2. `<root>/vendor/srt-win-src/target/release/srt-win.exe` (local
102
+ * `cargo build --release` fallback for development).
103
+ *
104
+ * `<root>` is {@link repoRoot} — `__dirname/../..`, which resolves to the
105
+ * repo root from `src/sandbox/` and `dist/sandbox/` alike, and to the
106
+ * package root when installed under `node_modules`.
107
+ *
108
+ * Callers that ship their own binary (or a multicall binary that
109
+ * routes on `argv[1] == `{@link SRT_WIN_DISPATCH_ARG1}) pass
110
+ * `windows.srtWin` instead of relying on this lookup — see
111
+ * {@link resolveSrtWin}.
45
112
  *
46
113
  * Resolution via the optional `@anthropic-ai/sandbox-runtime-win32-*`
47
114
  * platform packages is added separately.
@@ -49,50 +116,75 @@ function repoRoot() {
49
116
  * @throws if none exist.
50
117
  */
51
118
  export function getSrtWinPath() {
52
- const envPath = process.env.SRT_WIN_PATH;
53
- if (envPath && fs.existsSync(envPath)) {
54
- return envPath;
55
- }
56
119
  const root = repoRoot();
57
- const candidates = [
58
- path.join(root, 'vendor', 'srt-win', 'target', 'release', 'srt-win.exe'),
59
- path.join(root, 'dist', 'vendor', 'srt-win', 'target', 'release', 'srt-win.exe'),
60
- ];
120
+ const arch = nodeArchToDir[process.arch];
121
+ const candidates = [];
122
+ if (arch) {
123
+ candidates.push(path.join(root, 'vendor', 'srt-win', arch, 'srt-win.exe'));
124
+ }
125
+ candidates.push(path.join(root, 'vendor', 'srt-win-src', 'target', 'release', 'srt-win.exe'));
61
126
  for (const c of candidates) {
62
127
  if (fs.existsSync(c))
63
128
  return c;
64
129
  }
65
- throw new Error(`srt-win.exe not found. Set SRT_WIN_PATH or build with ` +
66
- `\`cargo build --release --manifest-path vendor/srt-win/Cargo.toml\`. ` +
67
- `Looked in: ${[envPath, ...candidates].filter(Boolean).join(', ')}`);
130
+ throw new Error(`srt-win.exe not found. Set windows.srtWin.path or build with ` +
131
+ `\`cargo build --release --manifest-path vendor/srt-win-src/Cargo.toml\`. ` +
132
+ `Looked in: ${candidates.join(', ')}`);
68
133
  }
69
- // ────────────────────────────────────────────────────────────────────
70
- // Internal: spawn helpers
71
- // ────────────────────────────────────────────────────────────────────
72
- function groupRefArgs(ref) {
73
- if (ref.groupSid)
74
- return ['--group-sid', ref.groupSid];
75
- return ['--name', ref.groupName ?? DEFAULT_WINDOWS_GROUP_NAME];
134
+ /**
135
+ * `argv[1]` sentinel a multicall embedder's dispatcher matches
136
+ * against to route into `srt_win::run_from_args`. Mirrors the Rust
137
+ * `srt_win::SRT_WIN_DISPATCH_ARG1`; the two MUST stay in sync.
138
+ * `run_from_args` strips it before clap, so the standalone binary
139
+ * accepts it harmlessly.
140
+ */
141
+ export const SRT_WIN_DISPATCH_ARG1 = '--srt-win';
142
+ /**
143
+ * Resolve the `srt-win` spawn target from config. When `cfg.path` is
144
+ * set it is used verbatim (no fallback to the packaged binary — an
145
+ * explicit override is a directive, not a hint) and
146
+ * {@link SRT_WIN_DISPATCH_ARG1} is prepended so a multicall
147
+ * dispatcher routes on `argv[1]`. When unset, falls back to
148
+ * {@link getSrtWinPath} with no sentinel (the packaged binary
149
+ * doesn't need it; `run_from_args` would strip it anyway).
150
+ */
151
+ export function resolveSrtWin(cfg) {
152
+ if (cfg?.path !== undefined) {
153
+ if (!fs.existsSync(cfg.path)) {
154
+ throw new Error(`windows.srtWin.path is set to '${cfg.path}' but the file does ` +
155
+ `not exist; remove srtWin.path to fall back to the packaged ` +
156
+ `binary`);
157
+ }
158
+ return { exe: cfg.path, prependArgs: [SRT_WIN_DISPATCH_ARG1] };
159
+ }
160
+ return { exe: getSrtWinPath(), prependArgs: [] };
76
161
  }
77
- function runSrtWin(args) {
78
- const exe = getSrtWinPath();
79
- const r = spawnSync(exe, args, { encoding: 'utf8', timeout: 15000 });
162
+ function runSrtWin(args, opts = {}) {
163
+ // Direct callers of the exported helpers may omit `srtWin`
164
+ // (backward-compat) fall back to the packaged-binary lookup.
165
+ // `SandboxManager` resolves once at `initialize()` and threads the
166
+ // handle, so this per-call resolve is only hit outside a session.
167
+ const { exe, prependArgs } = opts.srtWin ?? resolveSrtWin();
168
+ const r = spawnSync(exe, [...prependArgs, ...args], {
169
+ encoding: 'utf8',
170
+ timeout: opts.timeoutMs ?? 15000,
171
+ ...(opts.stdin !== undefined ? { input: opts.stdin } : {}),
172
+ });
80
173
  if (r.error) {
81
174
  throw new Error(`srt-win ${args[0]}: spawn failed: ${r.error.message}`);
82
175
  }
83
176
  return {
84
177
  status: r.status,
178
+ signal: r.signal,
85
179
  stdout: (r.stdout ?? '').trim(),
86
180
  stderr: (r.stderr ?? '').trim(),
87
181
  };
88
182
  }
89
- function runSrtWinJson(args) {
90
- const r = runSrtWin(args);
183
+ function runSrtWinJson(args, opts) {
184
+ const r = runSrtWin(args, opts);
91
185
  if (r.status !== 0) {
92
186
  throw new Error(`srt-win ${args.join(' ')} exited ${r.status}: ${r.stderr || r.stdout}`);
93
187
  }
94
- // Status subcommands print exactly one line of JSON to stdout. stderr
95
- // may carry `srt-win:` diagnostics — ignore it for parsing.
96
188
  try {
97
189
  return JSON.parse(r.stdout);
98
190
  }
@@ -101,113 +193,285 @@ function runSrtWinJson(args) {
101
193
  `${JSON.stringify(r.stdout)}: ${e.message}`);
102
194
  }
103
195
  }
104
- // ────────────────────────────────────────────────────────────────────
105
- // Status / install API
106
- // ────────────────────────────────────────────────────────────────────
107
196
  /**
108
- * Query the discriminator group's state in SAM and in the current
109
- * process's `TokenGroups`. `ready` means the group exists AND is
110
- * enabled in the caller's token (i.e. the logout/login dance has
111
- * happened). `created-not-on-token` means the install step ran but
112
- * a fresh logon is needed before {@link initialize} can succeed.
197
+ * As {@link runSrtWinJson} but parses stdout BEFORE checking the
198
+ * exit code, so a non-zero exit with the per-path JSON intact
199
+ * still surfaces every entry. For best-effort teardown helpers
200
+ * (`acl restore`/`acl revoke`).
113
201
  */
114
- export function getWindowsGroupStatus(ref) {
115
- return runSrtWinJson(['group', 'status', ...groupRefArgs(ref)]);
202
+ function runSrtWinJsonAllowFail(args, opts) {
203
+ const r = runSrtWin(args, opts);
204
+ let json;
205
+ try {
206
+ json = JSON.parse(r.stdout);
207
+ }
208
+ catch (e) {
209
+ throw new Error(`srt-win ${args.join(' ')}: unparseable JSON output ` +
210
+ `${JSON.stringify(r.stdout)}: ${e.message}`);
211
+ }
212
+ return { ok: r.status === 0, json, stderr: r.stderr };
116
213
  }
214
+ // ────────────────────────────────────────────────────────────────────
215
+ // Status / install API
216
+ // ────────────────────────────────────────────────────────────────────
117
217
  /**
118
- * Query the WFP filter set under the given sublayer. `installed` means
119
- * srt-win-tagged `permit-group` AND `block` filters are both present
120
- * under that sublayer. Detection is **tag-based** (providerData JSON);
121
- * filters installed by other tooling without the tag are not counted.
218
+ * Query the WFP filter set under the given sublayer via live BFE
219
+ * enumeration. `installed` means at least one srt-win-tagged
220
+ * `block-user` filter is present. Detection is **tag-based**
221
+ * (providerData JSON); filters installed by other tooling without the
222
+ * tag are not counted.
223
+ *
224
+ * BFE enumeration is admin-gated — a non-elevated caller gets
225
+ * `state:"cannot-read"` with a `hint` (not an error). The
226
+ * non-elevated readiness check is {@link verifyWindowsWfpEgress}.
122
227
  */
123
228
  export function getWindowsWfpStatus(opts = {}) {
124
229
  const args = ['wfp', 'status'];
125
230
  if (opts.sublayerGuid)
126
231
  args.push('--sublayer-guid', opts.sublayerGuid);
127
- const raw = runSrtWinJson(args);
232
+ const raw = runSrtWinJson(args, { srtWin: opts.srtWin });
128
233
  return {
129
234
  state: raw.state,
130
235
  filters: raw.filters,
131
236
  ...(raw.port_range && { portRange: raw.port_range }),
237
+ ...(raw.user_sid && { userSid: raw.user_sid }),
238
+ ...(raw.hint && { hint: raw.hint }),
132
239
  };
133
240
  }
134
241
  /**
135
- * One-shot install: creates the discriminator group, adds the
136
- * current user (or `userSid`), and installs the machine-wide WFP
137
- * filter set all in a single self-elevating process (one UAC
138
- * prompt). Idempotent.
242
+ * Behavioral proof that the WFP egress fence is active for the
243
+ * sandbox user. Binds a local listener on an ephemeral loopback port
244
+ * outside the WFP loopback-permit range, then spawns `srt-win
245
+ * runner` as the sandbox user (via `CreateProcessWithLogonW`) to
246
+ * attempt a direct TCP connect to it. The WFP block-user filter
247
+ * fires at `ALE_AUTH_CONNECT` — before any packet leaves — so an
248
+ * active fence yields WSAEACCES immediately and a missing fence lets
249
+ * the connect through (the kernel completes the handshake against
250
+ * the listening socket's backlog; no event-loop tick required, so
251
+ * the synchronous `runSrtWin` is safe). Does not require elevation
252
+ * and does not depend on any external host.
253
+ *
254
+ * `initialize()` calls this once per session, so a stale install
255
+ * (sandbox user provisioned but filters since removed) fails closed
256
+ * at session start instead of running every exec with full egress.
257
+ *
258
+ * @param opts.target overrides the probe target (skips the local
259
+ * listener bind).
260
+ * @param opts.proxyPortRange the WFP loopback-permit range the
261
+ * listener must avoid. Default
262
+ * {@link DEFAULT_WINDOWS_PROXY_PORT_RANGE}.
263
+ * @throws on any outcome other than `blocked` (exit 0).
264
+ */
265
+ export async function verifyWindowsWfpEgress(opts = {}) {
266
+ let target = opts.target;
267
+ let server;
268
+ if (!target) {
269
+ // Bind ephemeral; retry if it lands inside the WFP
270
+ // loopback-permit range (a port in-range would be PERMITted
271
+ // even with the fence active → false `connected`).
272
+ const [lo, hi] = opts.proxyPortRange ?? DEFAULT_WINDOWS_PROXY_PORT_RANGE;
273
+ for (let i = 0; i < 5; i++) {
274
+ const s = net.createServer();
275
+ s.listen(0, '127.0.0.1');
276
+ await once(s, 'listening');
277
+ const p = s.address().port;
278
+ if (p < lo || p > hi) {
279
+ server = s;
280
+ target = `127.0.0.1:${p}`;
281
+ break;
282
+ }
283
+ s.close();
284
+ }
285
+ if (!target) {
286
+ throw new Error(`verifyWindowsWfpEgress: could not bind a loopback ` +
287
+ `listener outside the WFP permit range [${lo},${hi}] in ` +
288
+ `5 attempts`);
289
+ }
290
+ }
291
+ try {
292
+ // 30s: first call after install may create the sandbox user's
293
+ // profile (LOGON_WITH_PROFILE) via CreateProcessWithLogonW —
294
+ // same budget as windowsTrustCa, plus the runner's own 2s
295
+ // connect timeout.
296
+ const r = runSrtWin(['wfp', 'verify', '--target', target], {
297
+ timeoutMs: 30000,
298
+ srtWin: opts.srtWin,
299
+ });
300
+ logForDebugging(`[Sandbox Windows] wfp verify exit=${r.status}: ${r.stderr || r.stdout}`);
301
+ let raw;
302
+ try {
303
+ raw = JSON.parse(r.stdout);
304
+ }
305
+ catch {
306
+ // status=null → spawnSync killed the child (timeout or external
307
+ // signal). Include signal + stderr so the CI log self-explains
308
+ // instead of just `exited null with unparseable output ""`.
309
+ throw new Error(`WFP egress fence could not be verified — \`srt-win wfp ` +
310
+ `verify\` exited ${r.status}` +
311
+ (r.signal ? ` (signal ${r.signal})` : '') +
312
+ ` with unparseable output ${JSON.stringify(r.stdout)} ` +
313
+ `(stderr: ${JSON.stringify(r.stderr)})`);
314
+ }
315
+ if (r.status === 3) {
316
+ throw new Error(`WFP egress fence is not active — direct outbound from the ` +
317
+ `sandbox user to ${raw.target} succeeded. Re-run ` +
318
+ `\`srt-win install\` (one UAC prompt). (${r.stderr})`);
319
+ }
320
+ if (r.status !== 0) {
321
+ throw new Error(`WFP egress fence could not be verified — probe to ` +
322
+ `${raw.target} was '${raw.egress_probe}' (exit ` +
323
+ `${r.status}). The fence may be absent. Re-run \`srt-win ` +
324
+ `install\`. (${r.stderr})`);
325
+ }
326
+ return { target: raw.target, stderr: r.stderr };
327
+ }
328
+ finally {
329
+ server?.close();
330
+ }
331
+ }
332
+ /**
333
+ * Query the sandbox user account's provisioning state. Each field
334
+ * is independently observed so a half-provisioned install (e.g.
335
+ * user exists but credential file missing) is distinguishable.
336
+ * Does not require elevation.
337
+ */
338
+ export function getWindowsSandboxUserStatus(opts = {}) {
339
+ const raw = runSrtWinJson(['user', 'status'], { srtWin: opts.srtWin });
340
+ return {
341
+ provisioned: raw.user.exists,
342
+ ...(raw.user.sid && { sid: raw.user.sid }),
343
+ groupExists: raw.user.group_exists,
344
+ ...(raw.user.group_sid && { groupSid: raw.user.group_sid }),
345
+ inBuiltinUsers: raw.user.in_builtin_users,
346
+ inSandboxGroup: raw.user.in_sandbox_group,
347
+ hiddenFromLogon: raw.user.hidden_from_logon,
348
+ credPresent: raw.cred_present,
349
+ ...(typeof raw.marker_version === 'number' && {
350
+ markerVersion: raw.marker_version,
351
+ }),
352
+ realUserSid: raw.real_user_sid,
353
+ ...(raw.ca_cert_thumb && { caCertThumb: raw.ca_cert_thumb }),
354
+ ...(raw.ca_cert_pem && { caCertPem: raw.ca_cert_pem }),
355
+ };
356
+ }
357
+ /**
358
+ * Read back the persistent MITM CA the sandbox was installed with
359
+ * (via `srt-win user trust-ca` / {@link windowsTrustCa}).
360
+ * Returns `null` when no CA was installed. The PEM is what `srt-win
361
+ * user status` reconstructs from the DER stored in `state.db`.
362
+ *
363
+ * On Windows, `tlsTerminate` requires this CA to be present in the
364
+ * sandbox user's `CurrentUser\Root` (schannel-level trust is an
365
+ * install-time concern, not per-session); the host calls this from
366
+ * `initialize()` to fail early with an actionable message when it
367
+ * isn't.
368
+ *
369
+ * @param status pass an already-fetched
370
+ * {@link getWindowsSandboxUserStatus} result to avoid a second
371
+ * `srt-win user status` spawn.
372
+ */
373
+ export function getWindowsSandboxCaCert(status, opts = {}) {
374
+ const u = status ?? getWindowsSandboxUserStatus(opts);
375
+ if (!u.caCertThumb || !u.caCertPem)
376
+ return null;
377
+ return { pem: u.caCertPem, thumb: u.caCertThumb };
378
+ }
379
+ /**
380
+ * Install (or replace) the MITM CA in the **sandbox user's**
381
+ * `CurrentUser\Root` and record it in `state.db` (so
382
+ * {@link getWindowsSandboxCaCert} surfaces its thumbprint + PEM).
383
+ * Thin wrapper around `srt-win user trust-ca <path>`. Does NOT
384
+ * require elevation. Persistent until {@link uninstallWindowsSandbox}
385
+ * deletes the sandbox user's profile.
386
+ *
387
+ * The CA has a separate lifecycle from {@link installWindowsSandbox}
388
+ * — install provisions the account/filters and never touches the CA;
389
+ * call this AFTER install when `tlsTerminate` will be used.
390
+ *
391
+ * @throws when the sandbox user is not provisioned, the file is not a
392
+ * parseable X.509 certificate, or the registry write into the
393
+ * sandbox user's hive fails.
394
+ */
395
+ export function windowsTrustCa(caCertPath, opts = {}) {
396
+ // 60s: first call may create the sandbox user's profile
397
+ // (LOGON_WITH_PROFILE) via the one-shot CreateProcessWithLogonW.
398
+ const r = runSrtWin(['user', 'trust-ca', caCertPath], {
399
+ timeoutMs: 60000,
400
+ srtWin: opts.srtWin,
401
+ });
402
+ logForDebugging(`[Sandbox Windows] user trust-ca exit=${r.status}: ${r.stderr || r.stdout}`);
403
+ if (r.status !== 0) {
404
+ throw new Error(`srt-win user trust-ca '${caCertPath}' failed (exit ` +
405
+ `${r.status}): ${r.stderr || r.stdout}`);
406
+ }
407
+ }
408
+ /**
409
+ * One-shot install: provisions the `srt-sandbox` user account and
410
+ * installs the user-SID-keyed WFP filter set — all in a single
411
+ * self-elevating process (one UAC prompt). Idempotent; re-running
412
+ * rotates the sandbox user's password.
139
413
  *
140
- * Network for the calling user is **not disrupted** before the
141
- * required logout: while the group is absent from the token, WFP
142
- * filter-0 (PERMIT non-members) matches and traffic flows normally.
143
- * After log-out/log-in, the group is enabled in the token and
144
- * filter-1 (PERMIT group-enabled) takes over for the broker; only
145
- * `srt-win exec` children (group flipped deny-only) fall through to
146
- * the loopback/BLOCK filters.
414
+ * Network for the calling user is **not disrupted**: the filters key
415
+ * on the `srt-sandbox` user's SID, so the broker, services, and
416
+ * every other principal fall through to default-permit. No logout
417
+ * is required.
147
418
  *
148
- * Returns the post-call group + WFP state. If the user cancels the
149
- * UAC prompt this returns `{cancelled: true, …}` rather than
150
- * throwing — cancellation is a user choice, not an error.
419
+ * Returns the post-call WFP + sandbox-user state. If the user
420
+ * cancels the UAC prompt this returns `{cancelled: true, …}` rather
421
+ * than throwing — cancellation is a user choice, not an error.
151
422
  *
152
- * @throws on group/WFP creation failure, or if filters already
153
- * exist under `sublayerGuid` with different configuration and
154
- * `force` is not set.
423
+ * @throws on user/WFP creation failure, or if filters already exist
424
+ * under `sublayerGuid` with a different port range and `force` is
425
+ * not set.
155
426
  */
156
427
  export function installWindowsSandbox(opts = {}) {
157
- const args = ['install', ...groupRefArgs(opts)];
158
- if (opts.userSid)
159
- args.push('--user-sid', opts.userSid);
428
+ const srtWin = opts.srtWin ?? resolveSrtWin();
429
+ const args = ['install'];
160
430
  if (opts.sublayerGuid)
161
431
  args.push('--sublayer-guid', opts.sublayerGuid);
162
432
  if (opts.proxyPortRange) {
163
433
  args.push('--proxy-port-range', `${opts.proxyPortRange[0]}-${opts.proxyPortRange[1]}`);
164
434
  }
435
+ if (opts.sandboxUser)
436
+ args.push('--sandbox-user', opts.sandboxUser);
165
437
  if (opts.force)
166
438
  args.push('--force');
167
- const r = runSrtWin(args);
439
+ const r = runSrtWin(args, { timeoutMs: 60000, srtWin });
168
440
  logForDebugging(`[Sandbox Windows] install exit=${r.status}: ${r.stderr || r.stdout}`);
169
441
  // srt-win install exit-code contract:
170
442
  // 0 ok
171
443
  // 10 user cancelled UAC elevation
172
- // 11 group create failed
173
444
  // 12 WFP install failed
174
445
  // 13 already installed with different config (use --force)
446
+ // 14 sandbox-user provisioning failed
175
447
  // 1 other error (stderr has detail)
176
448
  const out = r.stderr || r.stdout;
449
+ const readBack = () => ({
450
+ wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid, srtWin }),
451
+ user: getWindowsSandboxUserStatus({ srtWin }),
452
+ });
177
453
  switch (r.status) {
178
454
  case 0:
179
- break;
455
+ return readBack();
180
456
  case 10:
181
- return {
182
- group: getWindowsGroupStatus(opts),
183
- wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
184
- cancelled: true,
185
- };
186
- case 11:
187
- throw new Error(`srt-win install: group create failed: ${out}`);
457
+ return { ...readBack(), cancelled: true };
188
458
  case 12:
189
459
  throw new Error(`srt-win install: WFP filter install failed: ${out}`);
460
+ case 14:
461
+ throw new Error(`srt-win install: sandbox user provisioning failed: ${out}`);
190
462
  case 13:
191
463
  throw new Error(`srt-win install: filters already exist under this sublayer with ` +
192
- `different configuration (group SID or port range). ` +
193
- `Pass {force: true} to replace, or pick a different sublayerGuid. ` +
464
+ `a different port range or sandbox-user name. Pass ` +
465
+ `{force: true} to replace, or pick a different sublayerGuid. ` +
194
466
  `Output: ${out}`);
195
467
  default:
196
468
  throw new Error(`srt-win install failed (exit ${r.status}): ${out}`);
197
469
  }
198
- return {
199
- group: getWindowsGroupStatus(opts),
200
- wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
201
- };
202
470
  }
203
471
  /**
204
- * Remove the WFP filter set under `sublayerGuid` (one UAC prompt).
205
- * Idempotent.
206
- *
207
- * **Does NOT delete the discriminator group** — group membership is
208
- * persistent user state and removing it would force every user to
209
- * re-do the logout dance on the next install. Call
210
- * {@link deleteWindowsGroup} explicitly if you want full teardown.
472
+ * Remove the WFP filter set under `sublayerGuid` and the
473
+ * `srt-sandbox` account, its credential file, and the setup marker
474
+ * (one UAC prompt). Idempotent.
211
475
  *
212
476
  * @returns `{cancelled: true}` if the user dismissed UAC.
213
477
  */
@@ -215,7 +479,9 @@ export function uninstallWindowsSandbox(opts = {}) {
215
479
  const args = ['uninstall'];
216
480
  if (opts.sublayerGuid)
217
481
  args.push('--sublayer-guid', opts.sublayerGuid);
218
- const r = runSrtWin(args);
482
+ if (opts.keepUser)
483
+ args.push('--keep-user');
484
+ const r = runSrtWin(args, { srtWin: opts.srtWin });
219
485
  logForDebugging(`[Sandbox Windows] uninstall exit=${r.status}: ${r.stderr || r.stdout}`);
220
486
  if (r.status === 10)
221
487
  return { cancelled: true };
@@ -225,64 +491,249 @@ export function uninstallWindowsSandbox(opts = {}) {
225
491
  return {};
226
492
  }
227
493
  /**
228
- * Delete the discriminator group. Separate from
229
- * {@link uninstallWindowsSandbox} so that uninstall→reinstall
230
- * doesn't force a fresh logout for every member. **Requires
231
- * elevation.** Idempotent (no-op if the group doesn't exist).
494
+ * Resolve any Windows filesystem-config path list — `allowRead`/
495
+ * `allowWrite` grants and `denyRead`/`denyWrite` stamps — to
496
+ * concrete existing paths via the single platform-aware
497
+ * {@link normalizePathForSandbox} chokepoint (Linux/macOS parity:
498
+ * point-in-time expansion at session initialize, not per-exec).
499
+ * Glob patterns are expanded; non-glob paths are normalized and
500
+ * returned 1:1. Missing paths are dropped (statSync probe).
501
+ * Directory targets are accepted — the additive sandbox-user ACE
502
+ * carries `(OI)(CI)` so it covers the subtree.
232
503
  */
233
- export function deleteWindowsGroup(ref) {
234
- const r = runSrtWin(['group', 'delete', ...groupRefArgs(ref)]);
235
- if (r.status !== 0) {
236
- throw new Error(`srt-win group delete failed (exit ${r.status}). ` +
237
- `Requires elevation. Output: ${r.stderr || r.stdout}`);
504
+ export function expandWindowsFsPaths(patterns) {
505
+ const out = new Set();
506
+ for (const raw of patterns) {
507
+ const norm = normalizePathForSandbox(raw);
508
+ const candidates = containsGlobCharsWin(norm)
509
+ ? expandGlobPattern(norm, { caseInsensitive: true })
510
+ : [norm];
511
+ for (const c of candidates) {
512
+ const st = fs.statSync(c, { throwIfNoEntry: false });
513
+ if (!st)
514
+ continue;
515
+ out.add(c);
516
+ }
238
517
  }
239
- logForDebugging(`[Sandbox Windows] group delete: ${r.stderr || r.stdout}`);
518
+ return [...out];
240
519
  }
241
520
  /**
242
- * Granular primitive: create the discriminator group and add the
243
- * current user (or `userSid`). Most callers should use
244
- * {@link installWindowsSandbox} instead; this exists for
245
- * enterprise/CI flows that manage group and WFP separately.
246
- * **Requires elevation.** Idempotent.
521
+ * Apply the file-deny ACE set for one host session: an additive
522
+ * `(D;OICI;mask;;;<sb-SID>)` on the target plus a
523
+ * `(D;OICI;FILE_DELETE_CHILD;;;<sb-SID>)` on the parent no
524
+ * PROTECTED rewrite, no SD snapshot. Idempotent and refcounted via
525
+ * srt-win's `working_aces` table.
526
+ *
527
+ * Inputs are passed verbatim to `srt-win` (which canonicalizes and
528
+ * rejects globs). Callers that accept globs should pre-expand via
529
+ * {@link expandWindowsFsPaths}.
530
+ *
531
+ * @throws on exit ≠ 0 — including exit 2 (one or more inputs
532
+ * skipped). srt-win stamps the resolvable inputs before exiting
533
+ * 2, so on throw the caller should call {@link restoreWindowsAcl}
534
+ * to release whatever WAS stamped.
247
535
  */
248
- export function createWindowsGroup(ref) {
249
- const args = ['group', 'create', ...groupRefArgs(ref)];
250
- if (ref.userSid)
251
- args.push('--user-sid', ref.userSid);
252
- const r = runSrtWin(args);
536
+ export function stampWindowsAcl(opts) {
537
+ const holder = opts.holderPid ?? process.pid;
538
+ const stdin = JSON.stringify({
539
+ denyRead: opts.denyRead,
540
+ denyWrite: opts.denyWrite,
541
+ });
542
+ const r = runSrtWin([
543
+ 'acl',
544
+ 'stamp',
545
+ '--holder-pid',
546
+ `${holder}`,
547
+ '--sandbox-user-sid',
548
+ opts.sandboxUserSid,
549
+ ], { timeoutMs: 60000, stdin, srtWin: opts.srtWin });
550
+ logForDebugging(`[Sandbox Windows] acl stamp exit=${r.status}: ${r.stderr || r.stdout}`);
253
551
  if (r.status !== 0) {
254
- throw new Error(`srt-win group create failed (exit ${r.status}). ` +
255
- `This requires elevationrun as administrator. ` +
256
- `Output: ${r.stderr || r.stdout}`);
552
+ throw new Error(`srt-win acl stamp exited ${r.status} ` +
553
+ (r.status === 2 ? '(partial some inputs skipped)' : '(failed)') +
554
+ `: ${r.stderr || r.stdout}`);
257
555
  }
258
- logForDebugging(`[Sandbox Windows] group create: ${r.stderr || r.stdout}`);
259
556
  }
260
557
  /**
261
- * Granular primitive: install the machine-wide WFP filter set
262
- * under `sublayerGuid` keyed on the group SID. Most callers should
263
- * use {@link installWindowsSandbox} instead; this exists for
264
- * enterprise/CI flows that manage group and WFP separately.
265
- * **Requires elevation.** Idempotent re-running replaces any
266
- * existing srt-win-tagged filters under that sublayer.
558
+ * Release this holder's deny ACEs and remove the sandbox-user ACE
559
+ * on any path whose refcount falls to zero. Best-effort (does not
560
+ * throw on per-path anomalies); returns per-path outcomes for the
561
+ * caller to surface. Returns `undefined` only when `srt-win`
562
+ * itself failed (no JSON to parse).
267
563
  */
268
- export function createWindowsWfp(ref) {
269
- const args = ['wfp', 'install', ...groupRefArgs(ref)];
270
- if (ref.sublayerGuid)
271
- args.push('--sublayer-guid', ref.sublayerGuid);
272
- if (ref.proxyPortRange) {
273
- args.push('--proxy-port-range', `${ref.proxyPortRange[0]}-${ref.proxyPortRange[1]}`);
274
- }
275
- const r = runSrtWin(args);
564
+ export function restoreWindowsAcl(opts) {
565
+ const holder = opts.holderPid ?? process.pid;
566
+ // Don't let a teardown helper throw — the caller's reset() must
567
+ // complete. runSrtWinJsonAllowFail parses stdout before checking
568
+ // the exit code, so a non-zero exit with the per-path JSON intact
569
+ // still surfaces every entry to reset()'s loop. Only spawn-fail
570
+ // / unparseable output throws → log and return undefined.
571
+ try {
572
+ const r = runSrtWinJsonAllowFail([
573
+ 'acl',
574
+ 'restore',
575
+ '--holder-pid',
576
+ `${holder}`,
577
+ '--sandbox-user-sid',
578
+ opts.sandboxUserSid,
579
+ '--json',
580
+ ], { timeoutMs: 60000, srtWin: opts.srtWin });
581
+ if (!r.ok) {
582
+ logForDebugging(`[Sandbox Windows] acl restore exited non-zero (per-path ` +
583
+ `outcomes preserved): ${r.stderr}`, { level: 'error' });
584
+ }
585
+ // Pre- same-user-removal builds emit `{paths, parents}`; post-
586
+ // emit a flat array. Flatten either so reset()'s logging loop
587
+ // is shape-agnostic across the transition.
588
+ return Array.isArray(r.json)
589
+ ? r.json
590
+ : [...(r.json.paths ?? []), ...(r.json.parents ?? [])];
591
+ }
592
+ catch (e) {
593
+ logForDebugging(`[Sandbox Windows] acl restore: ${e.message}`, {
594
+ level: 'error',
595
+ });
596
+ return undefined;
597
+ }
598
+ }
599
+ /**
600
+ * Apply per-session additive `(OI)(CI)` ALLOW ACEs for the sandbox
601
+ * user on each path. The sandbox user has no inherent rights on
602
+ * real-user-owned files; this is what makes the working tree (and
603
+ * explicit `allowRead`/`allowWrite` paths) reachable from the
604
+ * child. Idempotent and refcounted via srt-win's `working_aces`
605
+ * table.
606
+ *
607
+ * @throws on exit ≠ 0. On throw the caller should call
608
+ * {@link revokeWindowsAcl} to release whatever WAS granted.
609
+ */
610
+ export function grantWindowsAcl(opts) {
611
+ const holder = opts.holderPid ?? process.pid;
612
+ const stdin = JSON.stringify({ read: opts.read, write: opts.write });
613
+ const r = runSrtWin([
614
+ 'acl',
615
+ 'grant',
616
+ '--holder-pid',
617
+ `${holder}`,
618
+ '--sandbox-user-sid',
619
+ opts.sandboxUserSid,
620
+ ], { timeoutMs: 60000, stdin, srtWin: opts.srtWin });
621
+ logForDebugging(`[Sandbox Windows] acl grant exit=${r.status}: ${r.stderr || r.stdout}`);
276
622
  if (r.status !== 0) {
277
- throw new Error(`srt-win wfp install failed (exit ${r.status}). ` +
278
- `This requires elevation — run as administrator. ` +
279
- `Output: ${r.stderr || r.stdout}`);
623
+ throw new Error(`srt-win acl grant exited ${r.status}: ${r.stderr || r.stdout}`);
624
+ }
625
+ }
626
+ /**
627
+ * Release this holder's grants and remove the sandbox-user ACE on
628
+ * any path whose refcount falls to zero. Best-effort (does not
629
+ * throw); logs anomalies.
630
+ */
631
+ export function revokeWindowsAcl(opts) {
632
+ const holder = opts.holderPid ?? process.pid;
633
+ try {
634
+ const r = runSrtWinJsonAllowFail([
635
+ 'acl',
636
+ 'revoke',
637
+ '--holder-pid',
638
+ `${holder}`,
639
+ '--sandbox-user-sid',
640
+ opts.sandboxUserSid,
641
+ '--json',
642
+ ], { timeoutMs: 60000, srtWin: opts.srtWin });
643
+ if (!r.ok) {
644
+ logForDebugging(`[Sandbox Windows] acl revoke exited non-zero: ${r.stderr}`, { level: 'error' });
645
+ }
646
+ return r.json;
647
+ }
648
+ catch (e) {
649
+ logForDebugging(`[Sandbox Windows] acl revoke: ${e.message}`, {
650
+ level: 'error',
651
+ });
652
+ return undefined;
280
653
  }
281
- logForDebugging(`[Sandbox Windows] wfp install: ${r.stderr || r.stdout}`);
282
654
  }
283
655
  // ────────────────────────────────────────────────────────────────────
284
656
  // Wrap
285
657
  // ────────────────────────────────────────────────────────────────────
658
+ /**
659
+ * `safe.directory` entries above this count collapse to a single
660
+ * `safe.directory=*`. Keeps `GIT_CONFIG_COUNT` (and the `--env`
661
+ * argv it rides on) bounded when `allowWrite` is wide.
662
+ */
663
+ const SAFE_DIRECTORY_WILDCARD_THRESHOLD = 8;
664
+ /**
665
+ * Build the `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_<n>` /
666
+ * `GIT_CONFIG_VALUE_<n>` env-var set for the sandboxed child.
667
+ *
668
+ * Emits:
669
+ * - `safe.directory=<dir>` for each entry in `safeDirs` (or one
670
+ * `safe.directory=*` when the list is long) — the working tree
671
+ * is owned by the real user, so git running as `srt-sandbox`
672
+ * refuses with "detected dubious ownership" without it.
673
+ * - `http.schannelUseSSLCAInfo=true` and
674
+ * `http.schannelCheckRevoke=false` when `schannelCa` — makes
675
+ * git's default (schannel) backend honor `GIT_SSL_CAINFO`
676
+ * without `-c http.sslBackend=openssl`. Revocation is disabled
677
+ * because CryptoAPI CRL/OCSP fetches ignore proxy env and would
678
+ * be WFP-fenced.
679
+ *
680
+ * Composes with an existing `GIT_CONFIG_COUNT` in `baseEnv` by
681
+ * continuing its numbering; the returned `GIT_CONFIG_COUNT` is the
682
+ * new total. Under the two-hop launch the broker's own environment
683
+ * never reaches the child, so `baseEnv` is the caller-supplied
684
+ * overlay ({@link WindowsSandboxParams.setEnvVars}), not
685
+ * `process.env`.
686
+ *
687
+ * Paths are emitted with forward slashes so the value survives
688
+ * msys2's env conversion untouched and native git accepts it.
689
+ */
690
+ export function buildGitConfigEnv(opts) {
691
+ // An explicit `GIT_CONFIG_COUNT=0` in baseEnv is an opt-out ("no
692
+ // env-level git config") — respect it rather than overwriting.
693
+ if (opts.baseEnv?.GIT_CONFIG_COUNT === '0')
694
+ return {};
695
+ const parsed = Number.parseInt(opts.baseEnv?.GIT_CONFIG_COUNT ?? '', 10);
696
+ const start = Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
697
+ let n = start;
698
+ const out = {};
699
+ const emit = (key, value) => {
700
+ out[`GIT_CONFIG_KEY_${n}`] = key;
701
+ out[`GIT_CONFIG_VALUE_${n}`] = value;
702
+ n++;
703
+ };
704
+ const dirs = [
705
+ ...new Set(opts.safeDirs
706
+ .filter((d) => !!d)
707
+ .map(d => {
708
+ const fwd = d.replace(/\\/g, '/');
709
+ const stripped = fwd.replace(/\/+$/, '');
710
+ // Don't strip the trailing slash off a drive root — `C:`
711
+ // is drive-relative-cwd, not the root; git wants `C:/`.
712
+ return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped;
713
+ })),
714
+ ];
715
+ if (dirs.length > SAFE_DIRECTORY_WILDCARD_THRESHOLD) {
716
+ emit('safe.directory', '*');
717
+ }
718
+ else {
719
+ // git matches safe.directory against the REPO TOP-LEVEL exactly,
720
+ // so a workspace root doesn't cover a nested repo. Emit both the
721
+ // exact path and the `<dir>/*` glob (git ≥2.46) so any repo
722
+ // at-or-under a granted dir is trusted.
723
+ for (const d of dirs) {
724
+ emit('safe.directory', d);
725
+ emit('safe.directory', `${d}/*`);
726
+ }
727
+ }
728
+ if (opts.schannelCa) {
729
+ emit('http.schannelUseSSLCAInfo', 'true');
730
+ emit('http.schannelCheckRevoke', 'false');
731
+ }
732
+ if (n === start)
733
+ return {};
734
+ out.GIT_CONFIG_COUNT = String(n);
735
+ return out;
736
+ }
286
737
  /**
287
738
  * Build the spawn descriptor for running `command` inside the Windows
288
739
  * sandbox: an `argv` array plus the `env` to spawn it with.
@@ -290,41 +741,111 @@ export function createWindowsWfp(ref) {
290
741
  * Caller MUST spawn the result with `{shell: false}` — that is the
291
742
  * security boundary that keeps untrusted bytes off the host's shell
292
743
  * (the inner `cmd.exe /c` runs INSIDE the sandbox; see
293
- * `vendor/srt-win/src/launch.rs` `build_cmdline` for the passthrough
744
+ * `vendor/srt-win-src/src/launch.rs` `build_cmdline` for the passthrough
294
745
  * rationale) — AND with the returned `env`.
295
746
  *
296
747
  * Proxy configuration is single-sourced by {@link generateProxyEnvVars}
297
748
  * (the same canonical builder used on macOS/Linux). `srt-win exec`
298
749
  * takes no `--http-proxy` / `--socks-proxy` flags and synthesizes no
299
- * proxy env; it forwards its own environment to the sandboxed child
300
- * verbatim. So the full proxy set is merged over the broker's
301
- * environment here and the child inherits it through the spawn.
750
+ * proxy env. The two-hop runner starts with the SANDBOX user's
751
+ * profile env (`USERPROFILE`/`TEMP` isolated) and overlays exactly
752
+ * what we pass as `--env` built here from the broker's `PATH` plus
753
+ * the generated proxy set.
302
754
  */
303
755
  export function wrapCommandWithSandboxWindows(p) {
304
- const exe = getSrtWinPath();
305
- const argv = [exe, 'exec', ...groupRefArgs(p.group)];
306
- argv.push('--');
307
- const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
308
- const shell = (p.binShell ?? 'cmd').toLowerCase();
309
- if (shell === 'pwsh' || shell.includes('powershell')) {
310
- const psExe = shell === 'pwsh'
311
- ? 'pwsh.exe'
312
- : path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
313
- argv.push(psExe, '-NoProfile', '-Command', p.command);
314
- }
315
- else {
316
- // cmd /d (no AutoRun) /s (strip first+last quote of post-/c by
317
- // position) /c (run-then-exit). The `command` string lands as a
318
- // single argv element; srt-win's build_cmdline wraps it in one
319
- // outer "…" pair for /s to consume. See launch.rs.
320
- argv.push(path.join(systemRoot, 'System32', 'cmd.exe'), '/d', '/s', '/c', p.command);
321
- }
322
- // Generated proxy vars override any inherited ones so the child
323
- // always routes through this sandbox's proxies.
324
- const generated = envListToObject(generateProxyEnvVars(p.httpProxyPort, p.socksProxyPort, undefined, p.proxyAuthToken));
756
+ const { exe, prependArgs } = p.srtWin ?? resolveSrtWin();
757
+ // Generated proxy + CA-trust env. Single-sourced here so the
758
+ // same object feeds (a) the spawn env merge below and (b) the
759
+ // explicit `--env` overlay for the runner.
760
+ //
761
+ // The CA trust-bundle path is emitted with forward slashes:
762
+ // msys2's POSIX-path conversion leaves `C:/…` alone and every
763
+ // tool we set the var for (curl, git, node, python, …) accepts
764
+ // forward slashes on Windows; backslashes would be mangled if
765
+ // the value passes through a bash command line. Schannel-level
766
+ // trust comes from the registry write `srt-win user trust-ca`
767
+ // did at install time; the env-var layer here covers the
768
+ // OpenSSL-backed tools.
769
+ const generated = envListToObject(generateProxyEnvVars(p.httpProxyPort, p.socksProxyPort, p.caCertPath?.replace(/\\/g, '/'), p.proxyAuthToken));
325
770
  // TMPDIR is a POSIX path meant for the macOS/Linux FS sandbox — it
326
771
  // serves no purpose on Windows and breaks msys2 tools (mktemp etc.).
327
772
  delete generated.TMPDIR;
773
+ // GIT_CONFIG_* set — safe.directory (dubious-ownership) + the
774
+ // schannel CA knobs. Composed against setEnvVars so a caller
775
+ // that already emits GIT_CONFIG_COUNT keeps its entries.
776
+ const gitCfg = buildGitConfigEnv({
777
+ safeDirs: [p.cwd ?? process.cwd(), ...(p.allowWrite ?? [])],
778
+ schannelCa: p.caCertPath !== undefined,
779
+ baseEnv: p.setEnvVars,
780
+ });
781
+ const argv = [exe, ...prependArgs, 'exec'];
782
+ for (const d of p.denyRead ?? [])
783
+ argv.push('--deny-read', d);
784
+ for (const d of p.denyWrite ?? [])
785
+ argv.push('--deny-write', d);
786
+ // The two-hop runner starts with the SANDBOX user's profile env
787
+ // (USERPROFILE/TEMP isolated) and overlays exactly what we pass as
788
+ // `--env`. The broker does NOT enumerate its own env — the overlay
789
+ // is built here from the broker's PATH, the mode:'mask' sentinel
790
+ // set, the generated proxy set, and the GIT_CONFIG_* set.
791
+ // Sentinels precede `generated` so a caller masking e.g.
792
+ // `HTTPS_PROXY` cannot break the sandbox's own proxy plumbing —
793
+ // same precedence as the macOS/Linux `env -u … VAR=…
794
+ // sandbox-exec` order. `gitCfg` is last so its GIT_CONFIG_COUNT
795
+ // (which composes against setEnvVars) wins.
796
+ const overlay = {
797
+ PATH: process.env.PATH,
798
+ PATHEXT: process.env.PATHEXT,
799
+ ...(p.setEnvVars ?? {}),
800
+ ...generated,
801
+ ...gitCfg,
802
+ };
803
+ for (const [k, v] of Object.entries(overlay)) {
804
+ if (v !== undefined)
805
+ argv.push('--env', `${k}=${v}`);
806
+ }
807
+ argv.push('--');
808
+ const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
809
+ const sh = p.binShell ?? { kind: 'cmd' };
810
+ switch (sh.kind) {
811
+ case 'bash':
812
+ // Git Bash: invoke the caller-supplied path directly with
813
+ // `-c <command>`. `command` is a fully-assembled bash command
814
+ // string with its own internal quoting; srt-win's `build_cmdline`
815
+ // takes the generic non-cmd branch and MSVCRT-quotes it as a
816
+ // SINGLE argv element, so bash receives it intact as argv[2].
817
+ argv.push(sh.path, '-c', p.command);
818
+ break;
819
+ case 'pwsh':
820
+ argv.push('pwsh.exe', '-NoProfile', '-Command', p.command);
821
+ break;
822
+ case 'powershell':
823
+ argv.push(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), '-NoProfile', '-Command', p.command);
824
+ break;
825
+ case 'cmd':
826
+ // cmd /d (no AutoRun) /s (strip first+last quote of post-/c by
827
+ // position) /c (run-then-exit). The `command` string lands as a
828
+ // single argv element; srt-win's build_cmdline wraps it in one
829
+ // outer "…" pair for /s to consume. See launch.rs.
830
+ argv.push(path.join(systemRoot, 'System32', 'cmd.exe'), '/d', '/s', '/c', p.command);
831
+ break;
832
+ }
833
+ // CreateProcessW's lpCommandLine is capped at 32 767 WCHARs.
834
+ // Node's `shell:false` spawn builds it by MSVCRT-quoting each
835
+ // argv element and joining with spaces; ~30 000 leaves headroom
836
+ // for the quote overhead the estimate doesn't model.
837
+ const cmdlineEstimate = argv.reduce((n, a) => n + a.length + 3, 0);
838
+ if (cmdlineEstimate > 30000) {
839
+ throw new Error(`Windows sandbox argv is ~${cmdlineEstimate} chars ` +
840
+ `(CreateProcessW limit is 32 767). Shorten the command, ` +
841
+ `or move broad globs to session-level filesystem.denyRead.`);
842
+ }
843
+ // The two-hop runner starts with a FRESH `srt-sandbox` profile
844
+ // env (`lpEnvironment = NULL` + `LOGON_WITH_PROFILE`), so the
845
+ // broker process's environment never reaches the child. The
846
+ // returned `env` is the spawn env for the broker (srt-win)
847
+ // process only; the child sees the `--env` overlay built into
848
+ // `argv` above (PATH/PATHEXT + mode:'mask' sentinels + proxy).
328
849
  const env = { ...process.env, ...generated };
329
850
  return { argv, env };
330
851
  }
@@ -348,80 +869,71 @@ function envListToObject(list) {
348
869
  // ────────────────────────────────────────────────────────────────────
349
870
  /**
350
871
  * Install instructions, surfaced verbatim in error messages.
351
- * Tailored to the observed group state: if the install already
352
- * ran (`created-not-on-token`), only the logout is missing.
353
872
  */
354
- export function windowsInstallInstructions(ref, sublayerGuid, groupState) {
355
- if (groupState === 'created-not-on-token') {
356
- return (`The discriminator group exists but is not yet in this session's ` +
357
- `token. LOG OUT and back in to pick up the new group membership ` +
358
- `(it enters TokenGroups at logon). Network is not disrupted ` +
359
- `meanwhile — WFP filter-0 PERMITs traffic while the group is absent ` +
360
- `from your token.`);
361
- }
362
- const g = ref.groupSid
363
- ? `--group-sid ${ref.groupSid}`
364
- : `--name ${ref.groupName ?? DEFAULT_WINDOWS_GROUP_NAME}`;
873
+ export function windowsInstallInstructions(sublayerGuid) {
365
874
  const sl = sublayerGuid ? ` --sublayer-guid ${sublayerGuid}` : '';
366
875
  return (`Windows sandbox needs a one-time install (one UAC prompt):\n` +
367
876
  ` npx sandbox-runtime windows-install\n` +
368
877
  ` — or call installWindowsSandbox(), or run ` +
369
- `\`srt-win.exe install ${g}${sl}\` directly —\n` +
370
- `then LOG OUT and back in (the group SID enters TokenGroups at logon).\n` +
371
- `Network is not disrupted before the logout: while the group is absent ` +
372
- `from your token, WFP filter-0 PERMITs all traffic.`);
878
+ `\`srt-win.exe install${sl}\` directly.\n` +
879
+ `No logout is needed: the WFP filter keys on the dedicated ` +
880
+ `\`srt-sandbox\` user's SID, so your network is unaffected.`);
373
881
  }
374
882
  /**
375
883
  * Check the Windows backend is ready to sandbox. Errors block
376
884
  * `initialize()`; warnings are informational.
377
885
  */
378
- export function checkWindowsDependencies(ref, sublayerGuid) {
886
+ export function checkWindowsDependencies(opts = {}) {
887
+ const { sublayerGuid } = opts;
379
888
  const errors = [];
380
889
  const warnings = [];
381
- // 1. Binary present.
382
- let exe;
890
+ // 1. Binary present (`resolveSrtWin` throws on a missing
891
+ // override, `getSrtWinPath` on a missing packaged binary). Resolve
892
+ // once and reuse for the status calls below.
893
+ let srtWin;
383
894
  try {
384
- exe = getSrtWinPath();
895
+ srtWin = opts.srtWin ?? resolveSrtWin();
385
896
  }
386
897
  catch (e) {
387
898
  return { errors: [e.message], warnings };
388
899
  }
389
- logForDebugging(`[Sandbox Windows] using srt-win at ${exe}`);
390
- // 2. Group ready (exists AND enabled in the caller's token).
391
- let gs;
900
+ logForDebugging(`[Sandbox Windows] using srt-win at ${srtWin.exe}`);
901
+ // 2. Sandbox user provisioned + credential readable.
902
+ let us;
392
903
  try {
393
- gs = getWindowsGroupStatus(ref);
904
+ us = getWindowsSandboxUserStatus({ srtWin });
394
905
  }
395
906
  catch (e) {
396
- errors.push(`srt-win group status failed: ${e.message}`);
907
+ errors.push(`srt-win user status failed: ${e.message}`);
397
908
  return { errors, warnings };
398
909
  }
399
- if (gs.state !== 'ready') {
400
- errors.push(`Discriminator group is ${gs.state}` +
401
- (gs.sid ? ` (sid=${gs.sid})` : '') +
402
- `. ` +
403
- windowsInstallInstructions(ref, sublayerGuid, gs.state));
910
+ if (!us.provisioned || !us.credPresent) {
911
+ errors.push(`Sandbox user is not provisioned (user=${us.provisioned}, ` +
912
+ `cred=${us.credPresent}). ` +
913
+ windowsInstallInstructions(sublayerGuid));
404
914
  }
405
- if (gs.warning)
406
- warnings.push(gs.warning);
407
- // 3. WFP filters installed under the sublayer.
915
+ // 3. WFP filters installed under the sublayer. BFE enumeration is
916
+ // admin-gated; `cannot-read` is informational only — the
917
+ // BEHAVIORAL check (`verifyWindowsWfpEgress`) runs at
918
+ // `initialize()` and is what actually fails closed.
408
919
  let ws;
409
920
  try {
410
- ws = getWindowsWfpStatus({ sublayerGuid });
921
+ ws = getWindowsWfpStatus({ sublayerGuid, srtWin });
411
922
  }
412
923
  catch (e) {
413
924
  errors.push(`srt-win wfp status failed: ${e.message}`);
414
925
  return { errors, warnings };
415
926
  }
416
- if (ws.state !== 'installed') {
417
- // If the group is also not-ready, the group-state error above
418
- // already gave the right instruction; don't repeat. Only
419
- // surface a separate WFP error when group IS ready (i.e.
420
- // someone uninstalled filters but kept the group).
421
- if (gs.state === 'ready') {
927
+ if (ws.state === 'cannot-read') {
928
+ logForDebugging(`[Sandbox Windows] wfp status cannot-read (non-elevated): ${ws.hint}`);
929
+ }
930
+ else if (ws.state !== 'installed') {
931
+ // 'absent'. If the user is also not-provisioned, the user-state
932
+ // error above already gave the right instruction; don't repeat.
933
+ if (us.provisioned && us.credPresent) {
422
934
  errors.push(`WFP filters not installed under sublayer ` +
423
935
  `${sublayerGuid ?? '(default)'}. ` +
424
- windowsInstallInstructions(ref, sublayerGuid, 'absent'));
936
+ windowsInstallInstructions(sublayerGuid));
425
937
  }
426
938
  }
427
939
  else if (ws.portRange) {