@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
@@ -1,9 +1,11 @@
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 { containsGlobCharsWin, expandGlobPattern, generateProxyEnvVars, normalizePathForSandbox, } from './sandbox-utils.js';
8
+ import { generateProxyEnvVars, normalizePathForSandbox, containsGlobCharsWin, expandGlobPattern, } from './sandbox-utils.js';
7
9
  // Re-export so existing tests (glob-expand.test.ts) and any
8
10
  // out-of-tree caller keep their import path.
9
11
  export { containsGlobCharsWin, stripExtendedPathPrefix, } from './sandbox-utils.js';
@@ -11,28 +13,31 @@ export { containsGlobCharsWin, stripExtendedPathPrefix, } from './sandbox-utils.
11
13
  * Windows sandbox backend.
12
14
  *
13
15
  * Network isolation is enforced by `srt-win.exe` — a Rust helper that
14
- * manages a local discriminator group, a machine-wide WFP filter set
15
- * keyed on that group's SID, and an `exec` subcommand that spawns the
16
- * target under a restricted token (group flipped deny-only) inside a
17
- * hardened job. The sandboxed child reaches the host only via the JS
18
- * 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.
19
30
  *
20
31
  * This module is a thin wrapper around the `srt-win` CLI; all status
21
- * comes from live enumeration (group via `LookupAccountNameW` +
22
- * token-membership check; WFP via providerData-tag enumeration under
23
- * the configured sublayer). There is no marker file.
32
+ * comes from live enumeration. There is no marker file.
24
33
  *
25
- * Filesystem deny (`denyRead`/`denyWrite`) is enforced via
26
- * `srt-win acl stamp` at session start: a broker-only DACL is
27
- * applied to each listed file plus a `Modify`-minus-`FILE_DELETE_CHILD`
28
- * allow-list on its immediate parent directory, with restore state
29
- * sealed by an inert hash-ACE marker so the on-disk SD is
30
- * self-authenticating. See {@link stampWindowsAcl}.
34
+ * Filesystem rules (`denyRead`/`denyWrite`/`allowRead`/`allowWrite`)
35
+ * are enforced via additive explicit ACEs for `<sb-SID>` see
36
+ * {@link grantWindowsAcl} / {@link stampWindowsAcl}.
31
37
  */
32
38
  // ────────────────────────────────────────────────────────────────────
33
39
  // Types
34
40
  // ────────────────────────────────────────────────────────────────────
35
- export const DEFAULT_WINDOWS_GROUP_NAME = 'sandbox-runtime-net';
36
41
  export const DEFAULT_WINDOWS_PROXY_PORT_RANGE = [
37
42
  60080, 60089,
38
43
  ];
@@ -90,134 +95,248 @@ function repoRoot() {
90
95
  }
91
96
  const nodeArchToDir = { x64: 'x64', arm64: 'arm64' };
92
97
  /**
93
- * Locate `srt-win.exe`. Resolution order:
94
- * 1. `SRT_WIN_PATH` env var (CI sets this to the freshly-built binary).
95
- * 2. `<root>/vendor/srt-win/{arch}/srt-win.exe` (prebuilt — published npm
98
+ * Locate the packaged `srt-win.exe`. Resolution order:
99
+ * 1. `<root>/vendor/srt-win/{arch}/srt-win.exe` (prebuilt published npm
96
100
  * package, or after `npm run build:srt-win` locally).
97
- * 3. `<root>/vendor/srt-win-src/target/release/srt-win.exe` (local
101
+ * 2. `<root>/vendor/srt-win-src/target/release/srt-win.exe` (local
98
102
  * `cargo build --release` fallback for development).
99
- * 4. `<root>/vendor/srt-win/target/release/srt-win.exe` (transitional:
100
- * stale local build from before the srt-win-src rename).
101
103
  *
102
104
  * `<root>` is {@link repoRoot} — `__dirname/../..`, which resolves to the
103
105
  * repo root from `src/sandbox/` and `dist/sandbox/` alike, and to the
104
106
  * package root when installed under `node_modules`.
105
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}.
112
+ *
106
113
  * Resolution via the optional `@anthropic-ai/sandbox-runtime-win32-*`
107
114
  * platform packages is added separately.
108
115
  *
109
116
  * @throws if none exist.
110
117
  */
111
118
  export function getSrtWinPath() {
112
- const envPath = process.env.SRT_WIN_PATH;
113
- if (envPath && fs.existsSync(envPath)) {
114
- return envPath;
115
- }
116
119
  const root = repoRoot();
117
120
  const arch = nodeArchToDir[process.arch];
118
121
  const candidates = [];
119
122
  if (arch) {
120
123
  candidates.push(path.join(root, 'vendor', 'srt-win', arch, 'srt-win.exe'));
121
124
  }
122
- candidates.push(path.join(root, 'vendor', 'srt-win-src', 'target', 'release', 'srt-win.exe'),
123
- // transitional: stale local build from before the srt-win-src rename
124
- path.join(root, 'vendor', 'srt-win', 'target', 'release', 'srt-win.exe'));
125
+ candidates.push(path.join(root, 'vendor', 'srt-win-src', 'target', 'release', 'srt-win.exe'));
125
126
  for (const c of candidates) {
126
127
  if (fs.existsSync(c))
127
128
  return c;
128
129
  }
129
- throw new Error(`srt-win.exe not found. Set SRT_WIN_PATH or build with ` +
130
+ throw new Error(`srt-win.exe not found. Set windows.srtWin.path or build with ` +
130
131
  `\`cargo build --release --manifest-path vendor/srt-win-src/Cargo.toml\`. ` +
131
- `Looked in: ${[envPath, ...candidates].filter(Boolean).join(', ')}`);
132
+ `Looked in: ${candidates.join(', ')}`);
132
133
  }
133
- // ────────────────────────────────────────────────────────────────────
134
- // Internal: spawn helpers
135
- // ────────────────────────────────────────────────────────────────────
136
- function groupRefArgs(ref) {
137
- if (ref.groupSid)
138
- return ['--group-sid', ref.groupSid];
139
- 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: [] };
140
161
  }
141
- function runSrtWin(args, stdin, timeoutMs = 15000) {
142
- const exe = getSrtWinPath();
143
- const r = spawnSync(exe, args, {
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], {
144
169
  encoding: 'utf8',
145
- timeout: timeoutMs,
146
- ...(stdin !== undefined && { input: stdin }),
170
+ timeout: opts.timeoutMs ?? 15000,
171
+ ...(opts.stdin !== undefined ? { input: opts.stdin } : {}),
147
172
  });
148
173
  if (r.error) {
149
174
  throw new Error(`srt-win ${args[0]}: spawn failed: ${r.error.message}`);
150
175
  }
151
176
  return {
152
177
  status: r.status,
178
+ signal: r.signal,
153
179
  stdout: (r.stdout ?? '').trim(),
154
180
  stderr: (r.stderr ?? '').trim(),
155
181
  };
156
182
  }
157
183
  function runSrtWinJson(args, opts) {
158
- const r = runSrtWin(args, undefined, opts?.timeoutMs);
159
- // Parse stdout BEFORE the exit-code check so `allowNonZero`
160
- // can hand back the JSON on exit≠0. JSON.parse never returns
161
- // `undefined`, so `json !== undefined` ⇔ parse succeeded.
162
- let json;
163
- let parseErr;
184
+ const r = runSrtWin(args, opts);
185
+ if (r.status !== 0) {
186
+ throw new Error(`srt-win ${args.join(' ')} exited ${r.status}: ${r.stderr || r.stdout}`);
187
+ }
164
188
  try {
165
- json = JSON.parse(r.stdout);
189
+ return JSON.parse(r.stdout);
166
190
  }
167
191
  catch (e) {
168
- parseErr = e.message;
192
+ throw new Error(`srt-win ${args.join(' ')}: unparseable JSON output ` +
193
+ `${JSON.stringify(r.stdout)}: ${e.message}`);
169
194
  }
170
- if (opts?.allowNonZero && json !== undefined) {
171
- return { ok: r.status === 0, json, stderr: r.stderr };
195
+ }
196
+ /**
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`).
201
+ */
202
+ function runSrtWinJsonAllowFail(args, opts) {
203
+ const r = runSrtWin(args, opts);
204
+ let json;
205
+ try {
206
+ json = JSON.parse(r.stdout);
172
207
  }
173
- if (r.status !== 0) {
174
- throw new Error(`srt-win ${args.join(' ')} exited ${r.status}: ${r.stderr || r.stdout}`);
208
+ catch (e) {
209
+ throw new Error(`srt-win ${args.join(' ')}: unparseable JSON output ` +
210
+ `${JSON.stringify(r.stdout)}: ${e.message}`);
175
211
  }
176
- if (json !== undefined)
177
- return json;
178
- throw new Error(`srt-win ${args.join(' ')}: unparseable JSON output ` +
179
- `${JSON.stringify(r.stdout)}: ${parseErr}`);
212
+ return { ok: r.status === 0, json, stderr: r.stderr };
180
213
  }
181
214
  // ────────────────────────────────────────────────────────────────────
182
215
  // Status / install API
183
216
  // ────────────────────────────────────────────────────────────────────
184
217
  /**
185
- * Query the discriminator group's state in SAM and in the current
186
- * process's `TokenGroups`. `ready` means the group exists AND is
187
- * enabled in the caller's token (i.e. the logout/login dance has
188
- * happened). `created-not-on-token` means the install step ran but
189
- * a fresh logon is needed before {@link initialize} can succeed.
190
- */
191
- export function getWindowsGroupStatus(ref) {
192
- return runSrtWinJson(['group', 'status', ...groupRefArgs(ref)]);
193
- }
194
- /**
195
- * Query the WFP filter set under the given sublayer. `installed` means
196
- * srt-win-tagged `permit-group` AND `block` filters are both present
197
- * under that sublayer. Detection is **tag-based** (providerData JSON);
198
- * 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}.
199
227
  */
200
228
  export function getWindowsWfpStatus(opts = {}) {
201
229
  const args = ['wfp', 'status'];
202
230
  if (opts.sublayerGuid)
203
231
  args.push('--sublayer-guid', opts.sublayerGuid);
204
- const raw = runSrtWinJson(args);
232
+ const raw = runSrtWinJson(args, { srtWin: opts.srtWin });
205
233
  return {
206
234
  state: raw.state,
207
235
  filters: raw.filters,
208
236
  ...(raw.port_range && { portRange: raw.port_range }),
209
- userFilters: raw.user_filters ?? 0,
210
237
  ...(raw.user_sid && { userSid: raw.user_sid }),
238
+ ...(raw.hint && { hint: raw.hint }),
211
239
  };
212
240
  }
241
+ /**
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
+ }
213
332
  /**
214
333
  * Query the sandbox user account's provisioning state. Each field
215
334
  * is independently observed so a half-provisioned install (e.g.
216
335
  * user exists but credential file missing) is distinguishable.
217
336
  * Does not require elevation.
218
337
  */
219
- export function getWindowsSandboxUserStatus() {
220
- const raw = runSrtWinJson(['user', 'status']);
338
+ export function getWindowsSandboxUserStatus(opts = {}) {
339
+ const raw = runSrtWinJson(['user', 'status'], { srtWin: opts.srtWin });
221
340
  return {
222
341
  provisioned: raw.user.exists,
223
342
  ...(raw.user.sid && { sid: raw.user.sid }),
@@ -230,6 +349,7 @@ export function getWindowsSandboxUserStatus() {
230
349
  ...(typeof raw.marker_version === 'number' && {
231
350
  markerVersion: raw.marker_version,
232
351
  }),
352
+ realUserSid: raw.real_user_sid,
233
353
  ...(raw.ca_cert_thumb && { caCertThumb: raw.ca_cert_thumb }),
234
354
  ...(raw.ca_cert_pem && { caCertPem: raw.ca_cert_pem }),
235
355
  };
@@ -240,18 +360,18 @@ export function getWindowsSandboxUserStatus() {
240
360
  * Returns `null` when no CA was installed. The PEM is what `srt-win
241
361
  * user status` reconstructs from the DER stored in `state.db`.
242
362
  *
243
- * On Windows with `windows.asSandboxUser`, `tlsTerminate` requires
244
- * this CA to be present in the sandbox user's `CurrentUser\Root`
245
- * (schannel-level trust is an install-time concern, not
246
- * per-session); the host calls this from `initialize()` to fail
247
- * early with an actionable message when it isn't.
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.
248
368
  *
249
369
  * @param status pass an already-fetched
250
370
  * {@link getWindowsSandboxUserStatus} result to avoid a second
251
371
  * `srt-win user status` spawn.
252
372
  */
253
- export function getWindowsSandboxCaCert(status) {
254
- const u = status ?? getWindowsSandboxUserStatus();
373
+ export function getWindowsSandboxCaCert(status, opts = {}) {
374
+ const u = status ?? getWindowsSandboxUserStatus(opts);
255
375
  if (!u.caCertThumb || !u.caCertPem)
256
376
  return null;
257
377
  return { pem: u.caCertPem, thumb: u.caCertThumb };
@@ -266,17 +386,19 @@ export function getWindowsSandboxCaCert(status) {
266
386
  *
267
387
  * The CA has a separate lifecycle from {@link installWindowsSandbox}
268
388
  * — install provisions the account/filters and never touches the CA;
269
- * call this AFTER install when `tlsTerminate` will be used with
270
- * `windows.asSandboxUser`.
389
+ * call this AFTER install when `tlsTerminate` will be used.
271
390
  *
272
391
  * @throws when the sandbox user is not provisioned, the file is not a
273
392
  * parseable X.509 certificate, or the registry write into the
274
393
  * sandbox user's hive fails.
275
394
  */
276
- export function windowsTrustCa(caCertPath) {
395
+ export function windowsTrustCa(caCertPath, opts = {}) {
277
396
  // 60s: first call may create the sandbox user's profile
278
397
  // (LOGON_WITH_PROFILE) via the one-shot CreateProcessWithLogonW.
279
- const r = runSrtWin(['user', 'trust-ca', caCertPath], undefined, 60000);
398
+ const r = runSrtWin(['user', 'trust-ca', caCertPath], {
399
+ timeoutMs: 60000,
400
+ srtWin: opts.srtWin,
401
+ });
280
402
  logForDebugging(`[Sandbox Windows] user trust-ca exit=${r.status}: ${r.stderr || r.stdout}`);
281
403
  if (r.status !== 0) {
282
404
  throw new Error(`srt-win user trust-ca '${caCertPath}' failed (exit ` +
@@ -284,92 +406,72 @@ export function windowsTrustCa(caCertPath) {
284
406
  }
285
407
  }
286
408
  /**
287
- * One-shot install: creates the discriminator group, adds the
288
- * current user (or `userSid`), and installs the machine-wide WFP
289
- * filter set — all in a single self-elevating process (one UAC
290
- * prompt). Idempotent.
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.
291
413
  *
292
- * Network for the calling user is **not disrupted** before the
293
- * required logout: while the group is absent from the token, WFP
294
- * filter-0 (PERMIT non-members) matches and traffic flows normally.
295
- * After log-out/log-in, the group is enabled in the token and
296
- * filter-1 (PERMIT group-enabled) takes over for the broker; only
297
- * `srt-win exec` children (group flipped deny-only) fall through to
298
- * 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.
299
418
  *
300
- * Returns the post-call group + WFP state. If the user cancels the
301
- * UAC prompt this returns `{cancelled: true, …}` rather than
302
- * 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.
303
422
  *
304
- * @throws on group/WFP creation failure, or if filters already
305
- * exist under `sublayerGuid` with different configuration and
306
- * `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.
307
426
  */
308
427
  export function installWindowsSandbox(opts = {}) {
309
- const args = ['install', ...groupRefArgs(opts)];
310
- if (opts.userSid)
311
- args.push('--user-sid', opts.userSid);
428
+ const srtWin = opts.srtWin ?? resolveSrtWin();
429
+ const args = ['install'];
312
430
  if (opts.sublayerGuid)
313
431
  args.push('--sublayer-guid', opts.sublayerGuid);
314
432
  if (opts.proxyPortRange) {
315
433
  args.push('--proxy-port-range', `${opts.proxyPortRange[0]}-${opts.proxyPortRange[1]}`);
316
434
  }
435
+ if (opts.sandboxUser)
436
+ args.push('--sandbox-user', opts.sandboxUser);
317
437
  if (opts.force)
318
438
  args.push('--force');
319
- const r = runSrtWin(args, undefined, 60000);
439
+ const r = runSrtWin(args, { timeoutMs: 60000, srtWin });
320
440
  logForDebugging(`[Sandbox Windows] install exit=${r.status}: ${r.stderr || r.stdout}`);
321
441
  // srt-win install exit-code contract:
322
442
  // 0 ok
323
443
  // 10 user cancelled UAC elevation
324
- // 11 group create failed
325
444
  // 12 WFP install failed
326
445
  // 13 already installed with different config (use --force)
327
446
  // 14 sandbox-user provisioning failed
328
447
  // 1 other error (stderr has detail)
329
448
  const out = r.stderr || r.stdout;
449
+ const readBack = () => ({
450
+ wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid, srtWin }),
451
+ user: getWindowsSandboxUserStatus({ srtWin }),
452
+ });
330
453
  switch (r.status) {
331
454
  case 0:
332
- break;
455
+ return readBack();
333
456
  case 10:
334
- return {
335
- group: getWindowsGroupStatus(opts),
336
- wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
337
- user: getWindowsSandboxUserStatus(),
338
- cancelled: true,
339
- };
340
- case 11:
341
- throw new Error(`srt-win install: group create failed: ${out}`);
457
+ return { ...readBack(), cancelled: true };
342
458
  case 12:
343
459
  throw new Error(`srt-win install: WFP filter install failed: ${out}`);
344
460
  case 14:
345
461
  throw new Error(`srt-win install: sandbox user provisioning failed: ${out}`);
346
462
  case 13:
347
463
  throw new Error(`srt-win install: filters already exist under this sublayer with ` +
348
- `different configuration (group SID or port range). ` +
349
- `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. ` +
350
466
  `Output: ${out}`);
351
467
  default:
352
468
  throw new Error(`srt-win install failed (exit ${r.status}): ${out}`);
353
469
  }
354
- return {
355
- group: getWindowsGroupStatus(opts),
356
- wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
357
- user: getWindowsSandboxUserStatus(),
358
- };
359
470
  }
360
471
  /**
361
- * Remove the WFP filter set under `sublayerGuid` (one UAC prompt).
362
- * Idempotent.
363
- *
364
- * **Does NOT delete the discriminator group** — group membership is
365
- * persistent user state and removing it would force every user to
366
- * re-do the logout dance on the next install. Call
367
- * {@link deleteWindowsGroup} explicitly if you want full teardown.
368
- *
369
- * **Does** remove the `srt-sandbox` account, its credential file,
370
- * and the setup marker, unless `keepUser` is set — the credential
371
- * is useless without the account and vice versa, so they're
372
- * treated as one unit.
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.
373
475
  *
374
476
  * @returns `{cancelled: true}` if the user dismissed UAC.
375
477
  */
@@ -379,7 +481,7 @@ export function uninstallWindowsSandbox(opts = {}) {
379
481
  args.push('--sublayer-guid', opts.sublayerGuid);
380
482
  if (opts.keepUser)
381
483
  args.push('--keep-user');
382
- const r = runSrtWin(args);
484
+ const r = runSrtWin(args, { srtWin: opts.srtWin });
383
485
  logForDebugging(`[Sandbox Windows] uninstall exit=${r.status}: ${r.stderr || r.stdout}`);
384
486
  if (r.status === 10)
385
487
  return { cancelled: true };
@@ -389,78 +491,17 @@ export function uninstallWindowsSandbox(opts = {}) {
389
491
  return {};
390
492
  }
391
493
  /**
392
- * Delete the discriminator group. Separate from
393
- * {@link uninstallWindowsSandbox} so that uninstall→reinstall
394
- * doesn't force a fresh logout for every member. **Requires
395
- * elevation.** Idempotent (no-op if the group doesn't exist).
396
- */
397
- export function deleteWindowsGroup(ref) {
398
- const r = runSrtWin(['group', 'delete', ...groupRefArgs(ref)]);
399
- if (r.status !== 0) {
400
- throw new Error(`srt-win group delete failed (exit ${r.status}). ` +
401
- `Requires elevation. Output: ${r.stderr || r.stdout}`);
402
- }
403
- logForDebugging(`[Sandbox Windows] group delete: ${r.stderr || r.stdout}`);
404
- }
405
- /**
406
- * Granular primitive: create the discriminator group and add the
407
- * current user (or `userSid`). Most callers should use
408
- * {@link installWindowsSandbox} instead; this exists for
409
- * enterprise/CI flows that manage group and WFP separately.
410
- * **Requires elevation.** Idempotent.
411
- */
412
- export function createWindowsGroup(ref) {
413
- const args = ['group', 'create', ...groupRefArgs(ref)];
414
- if (ref.userSid)
415
- args.push('--user-sid', ref.userSid);
416
- const r = runSrtWin(args);
417
- if (r.status !== 0) {
418
- throw new Error(`srt-win group create failed (exit ${r.status}). ` +
419
- `This requires elevation — run as administrator. ` +
420
- `Output: ${r.stderr || r.stdout}`);
421
- }
422
- logForDebugging(`[Sandbox Windows] group create: ${r.stderr || r.stdout}`);
423
- }
424
- /**
425
- * Granular primitive: install the machine-wide WFP filter set
426
- * under `sublayerGuid` keyed on the group SID. Most callers should
427
- * use {@link installWindowsSandbox} instead; this exists for
428
- * enterprise/CI flows that manage group and WFP separately.
429
- * **Requires elevation.** Idempotent — re-running replaces any
430
- * existing srt-win-tagged filters under that sublayer.
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.
431
503
  */
432
- export function createWindowsWfp(ref) {
433
- const args = ['wfp', 'install', ...groupRefArgs(ref)];
434
- if (ref.sublayerGuid)
435
- args.push('--sublayer-guid', ref.sublayerGuid);
436
- if (ref.proxyPortRange) {
437
- args.push('--proxy-port-range', `${ref.proxyPortRange[0]}-${ref.proxyPortRange[1]}`);
438
- }
439
- const r = runSrtWin(args);
440
- if (r.status !== 0) {
441
- throw new Error(`srt-win wfp install failed (exit ${r.status}). ` +
442
- `This requires elevation — run as administrator. ` +
443
- `Output: ${r.stderr || r.stdout}`);
444
- }
445
- logForDebugging(`[Sandbox Windows] wfp install: ${r.stderr || r.stdout}`);
446
- }
447
- /**
448
- * Expand the `denyRead`/`denyWrite` input set to a flat list of
449
- * existing FILE paths for `srt-win acl stamp`.
450
- *
451
- * Every input goes through {@link normalizePathForSandbox} (the
452
- * single Windows-aware chokepoint: `\\?\`/UNC-strip, drive-letter
453
- * case-fold, ~-expand, realpath). Globs (`*`/`?` only — `[`/`]`
454
- * are legal Win32 filename chars) expand via the shared walker
455
- * with case-insensitive matching (point-in-time: a file appearing
456
- * after this returns is NOT covered). Each candidate is checked
457
- * with one `statSync({throwIfNoEntry:false})`: missing → drop
458
- * (the protection model covers files present at session start);
459
- * directory → reject (the file stamp applies a per-file DACL plus
460
- * a per-parent-directory allow-list; stamping a directory itself
461
- * would touch every child); file → keep.
462
- */
463
- export function expandWindowsFsDenyPaths(patterns) {
504
+ export function expandWindowsFsPaths(patterns) {
464
505
  const out = new Set();
465
506
  for (const raw of patterns) {
466
507
  const norm = normalizePathForSandbox(raw);
@@ -471,32 +512,26 @@ export function expandWindowsFsDenyPaths(patterns) {
471
512
  const st = fs.statSync(c, { throwIfNoEntry: false });
472
513
  if (!st)
473
514
  continue;
474
- if (st.isDirectory()) {
475
- throw new Error(`Windows fs deny requires explicit file paths; ` +
476
- `${JSON.stringify(raw)} resolved to directory ` +
477
- `${JSON.stringify(c)}. Directory targets are not supported.`);
478
- }
479
515
  out.add(c);
480
516
  }
481
517
  }
482
518
  return [...out];
483
519
  }
484
520
  /**
485
- * Apply the file-deny stamp set for one host session. Idempotent
486
- * via `srt-win`'s disk-first `ensure_stamped` chokepoint calling
487
- * this again with overlapping paths re-verifies the on-disk DACL
488
- * against the hash-ACE marker rather than trusting state-DB rows.
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.
489
526
  *
490
- * Inputs are passed verbatim to `srt-win` (which canonicalizes,
491
- * rejects directories and globs, and stamps each file plus its
492
- * immediate parent directory). Callers that accept globs should
493
- * pre-expand via {@link expandWindowsFsDenyPaths}.
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}.
494
530
  *
495
531
  * @throws on exit ≠ 0 — including exit 2 (one or more inputs
496
532
  * skipped). srt-win stamps the resolvable inputs before exiting
497
533
  * 2, so on throw the caller should call {@link restoreWindowsAcl}
498
- * to release whatever WAS stamped (fail-closed at session start
499
- * means tearing down a partial setup).
534
+ * to release whatever WAS stamped.
500
535
  */
501
536
  export function stampWindowsAcl(opts) {
502
537
  const holder = opts.holderPid ?? process.pid;
@@ -504,54 +539,55 @@ export function stampWindowsAcl(opts) {
504
539
  denyRead: opts.denyRead,
505
540
  denyWrite: opts.denyWrite,
506
541
  });
507
- const r = runSrtWin(['acl', 'stamp', ...groupRefArgs(opts.group), '--holder-pid', `${holder}`], stdin, 60000);
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 });
508
550
  logForDebugging(`[Sandbox Windows] acl stamp exit=${r.status}: ${r.stderr || r.stdout}`);
509
551
  if (r.status !== 0) {
510
- // exit 2 = partial (some inputs skipped); exit 1 = at least
511
- // one path could not be stamped. Either is a setup failure.
512
552
  throw new Error(`srt-win acl stamp exited ${r.status} ` +
513
553
  (r.status === 2 ? '(partial — some inputs skipped)' : '(failed)') +
514
554
  `: ${r.stderr || r.stdout}`);
515
555
  }
516
556
  }
517
557
  /**
518
- * Release this holder's file-deny stamps and return per-path /
519
- * per-parent outcomes. Best-effort: a non-`restored` entry means
520
- * the file's stamp was LEFT in place (fail-closed) — see
521
- * {@link WindowsAclPathOutcome} for the cases. Does not throw on
522
- * anomalies; the caller decides whether to surface them.
523
- *
524
- * Returns `undefined` when `srt-win acl restore` itself failed
525
- * (no JSON to parse) — the caller should log and move on rather
526
- * than block teardown.
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).
527
563
  */
528
564
  export function restoreWindowsAcl(opts) {
529
565
  const holder = opts.holderPid ?? process.pid;
530
- const args = [
531
- 'acl',
532
- 'restore',
533
- ...groupRefArgs(opts.group),
534
- '--holder-pid',
535
- `${holder}`,
536
- '--json',
537
- ];
538
566
  // Don't let a teardown helper throw — the caller's reset() must
539
- // complete. runSrtWinJson parses stdout before checking the
540
- // exit code, so a non-zero exit with the per-path JSON intact
541
- // (`acl restore` prints outcomes THEN errors when any path
542
- // stayed stamped) still surfaces every entry to reset()'s loop
543
- // instead of collapsing to `undefined`. Only spawn-fail /
544
- // unparseable output throws → log and return undefined.
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.
545
571
  try {
546
- const r = runSrtWinJson(args, {
547
- timeoutMs: 60000,
548
- allowNonZero: true,
549
- });
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 });
550
581
  if (!r.ok) {
551
582
  logForDebugging(`[Sandbox Windows] acl restore exited non-zero (per-path ` +
552
583
  `outcomes preserved): ${r.stderr}`, { level: 'error' });
553
584
  }
554
- return r.json;
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 ?? [])];
555
591
  }
556
592
  catch (e) {
557
593
  logForDebugging(`[Sandbox Windows] acl restore: ${e.message}`, {
@@ -561,22 +597,143 @@ export function restoreWindowsAcl(opts) {
561
597
  }
562
598
  }
563
599
  /**
564
- * Per-path outcomes that mean the file's DACL was returned to its
565
- * pre-stamp state (or was already there). Anything else left the
566
- * stamp in place and should be surfaced to the user.
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.
567
609
  */
568
- export const WINDOWS_ACL_PATH_OK = new Set([
569
- 'restored',
570
- ]);
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}`);
622
+ if (r.status !== 0) {
623
+ throw new Error(`srt-win acl grant exited ${r.status}: ${r.stderr || r.stdout}`);
624
+ }
625
+ }
571
626
  /**
572
- * Per-parent-directory outcomes that are expected during normal
573
- * teardown. `stillHeld` is normal when another active session
574
- * still references a file under this directory.
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.
575
630
  */
576
- export const WINDOWS_ACL_PARENT_OK = new Set(['restored', 'alreadyOriginal', 'stillHeld']);
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;
653
+ }
654
+ }
577
655
  // ────────────────────────────────────────────────────────────────────
578
656
  // Wrap
579
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
+ }
580
737
  /**
581
738
  * Build the spawn descriptor for running `command` inside the Windows
582
739
  * sandbox: an `argv` array plus the `env` to spawn it with.
@@ -590,65 +747,62 @@ export const WINDOWS_ACL_PARENT_OK = new Set(['restored', 'alreadyOriginal', 'st
590
747
  * Proxy configuration is single-sourced by {@link generateProxyEnvVars}
591
748
  * (the same canonical builder used on macOS/Linux). `srt-win exec`
592
749
  * takes no `--http-proxy` / `--socks-proxy` flags and synthesizes no
593
- * proxy env; it forwards its own environment to the sandboxed child
594
- * verbatim. So the full proxy set is merged over the broker's
595
- * 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.
596
754
  */
597
755
  export function wrapCommandWithSandboxWindows(p) {
598
- const exe = getSrtWinPath();
756
+ const { exe, prependArgs } = p.srtWin ?? resolveSrtWin();
599
757
  // Generated proxy + CA-trust env. Single-sourced here so the
600
758
  // same object feeds (a) the spawn env merge below and (b) the
601
- // explicit `--env` overlay for the two-hop launch.
759
+ // explicit `--env` overlay for the runner.
602
760
  //
603
- // Under `asSandboxUser` the CA-bundle path is OMITTED: it points
604
- // into the broker's `%TEMP%\srt-sandbox-…`, which the
605
- // `srt-sandbox` user cannot read OpenSSL-backed clients (msys2
606
- // curl, `git -c http.sslBackend=openssl`, node, python) would
607
- // fail to open it (curl exit 77 etc.). Schannel-level trust comes
608
- // from the registry write `srt-win user trust-ca` did at install
609
- // time; the env-var bundle layer for the two-hop path lands with
610
- // the working-tree/profile-grant work. See WindowsSandboxParams.
611
- if (p.asSandboxUser && p.caCertPath !== undefined) {
612
- logForDebugging(`[Sandbox Windows] caCertPath '${p.caCertPath}' not forwarded ` +
613
- `under asSandboxUser (broker %TEMP% is unreadable by ` +
614
- `srt-sandbox); schannel trust via 'srt-win user trust-ca' ` +
615
- `is the only CA-trust path for the two-hop launch`);
616
- }
617
- const generated = envListToObject(generateProxyEnvVars(p.httpProxyPort, p.socksProxyPort, p.asSandboxUser ? undefined : p.caCertPath, p.proxyAuthToken));
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));
618
770
  // TMPDIR is a POSIX path meant for the macOS/Linux FS sandbox — it
619
771
  // serves no purpose on Windows and breaks msys2 tools (mktemp etc.).
620
772
  delete generated.TMPDIR;
621
- const argv = [exe, 'exec', ...groupRefArgs(p.group)];
622
- // Format-validated at the config boundary
623
- // (`WindowsConfigSchema.wfpSublayerGuid: z.string().uuid()`),
624
- // and again by clap's GUID parser at the binary boundary; the
625
- // outer spawn is `shell:false`, so the value is an argv element,
626
- // never shell-interpolated.
627
- if (p.sublayerGuid)
628
- argv.push('--sublayer-guid', p.sublayerGuid);
629
- if (p.holderPid !== undefined) {
630
- argv.push('--holder-pid', `${p.holderPid}`);
631
- }
632
- for (const f of p.denyRead ?? [])
633
- argv.push('--deny-read', f);
634
- for (const f of p.denyWrite ?? [])
635
- argv.push('--deny-write', f);
636
- if (p.asSandboxUser) {
637
- argv.push('--as-sandbox-user');
638
- // The two-hop runner starts with the SANDBOX user's profile env
639
- // (USERPROFILE/TEMP isolated) and overlays exactly what we pass
640
- // as `--env`. The broker does NOT enumerate its own env the
641
- // overlay is built here from the same single source as the
642
- // same-user path's spawn env.
643
- const overlay = {
644
- PATH: process.env.PATH,
645
- PATHEXT: process.env.PATHEXT,
646
- ...generated,
647
- };
648
- for (const [k, v] of Object.entries(overlay)) {
649
- if (v !== undefined)
650
- argv.push('--env', `${k}=${v}`);
651
- }
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}`);
652
806
  }
653
807
  argv.push('--');
654
808
  const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
@@ -660,9 +814,6 @@ export function wrapCommandWithSandboxWindows(p) {
660
814
  // string with its own internal quoting; srt-win's `build_cmdline`
661
815
  // takes the generic non-cmd branch and MSVCRT-quotes it as a
662
816
  // SINGLE argv element, so bash receives it intact as argv[2].
663
- // TODO: MSYS2 derives POSIX /tmp from Windows TEMP/TMP itself;
664
- // revisit whether any extra TEMP/TMP normalisation is needed for
665
- // the bash inner shell under the restricted token.
666
817
  argv.push(sh.path, '-c', p.command);
667
818
  break;
668
819
  case 'pwsh':
@@ -681,43 +832,21 @@ export function wrapCommandWithSandboxWindows(p) {
681
832
  }
682
833
  // CreateProcessW's lpCommandLine is capped at 32 767 WCHARs.
683
834
  // Node's `shell:false` spawn builds it by MSVCRT-quoting each
684
- // argv element and joining with spaces; the worst-case quote
685
- // overhead per element is +2 (wrapping `"`) plus backslash
686
- // doubling before embedded quotes (rare in file paths). A
687
- // path-count proxy can't see this — 60 long paths overflow
688
- // while 200 short ones fit — so estimate the actual command
689
- // line and refuse with the same guidance the old count check
690
- // gave. ~30 000 leaves headroom for the quote overhead the
691
- // estimate doesn't model.
835
+ // argv element and joining with spaces; ~30 000 leaves headroom
836
+ // for the quote overhead the estimate doesn't model.
692
837
  const cmdlineEstimate = argv.reduce((n, a) => n + a.length + 3, 0);
693
838
  if (cmdlineEstimate > 30000) {
694
- const nDeny = (p.denyRead?.length ?? 0) + (p.denyWrite?.length ?? 0);
695
839
  throw new Error(`Windows sandbox argv is ~${cmdlineEstimate} chars ` +
696
- `(CreateProcessW limit is 32 767). ${nDeny} per-exec ` +
697
- `file-deny path(s) ride on this argv — move broad globs to ` +
698
- `the session-level filesystem.denyRead/denyWrite (passed to ` +
699
- `SandboxManager.initialize(), stdin-passed to \`acl stamp\`) ` +
700
- `instead, or shorten the command.`);
701
- }
702
- // Drop/overwrite denied credential env vars from the inherited
703
- // environment FIRST. The proxy assignments below must come LAST
704
- // so SRT's own proxy plumbing vars survive even if a caller lists
705
- // one of them as a denied credential — same precedence as the
706
- // macOS/Linux `env -u … VAR=… sandbox-exec` order.
707
- //
708
- // Windows env is case-insensitive but Node preserves the OS
709
- // casing on enumeration, so a `delete baseEnv['SECRET']` would
710
- // miss a `Secret` key. Match by uppercased name instead.
711
- const baseEnv = { ...process.env };
712
- const unsetUpper = new Set((p.unsetEnvVars ?? []).map(k => k.toUpperCase()));
713
- for (const k of Object.keys(baseEnv)) {
714
- if (unsetUpper.has(k.toUpperCase()))
715
- delete baseEnv[k];
716
- }
717
- Object.assign(baseEnv, p.setEnvVars ?? {});
718
- // Generated proxy vars override any inherited (or just-masked)
719
- // ones so the child always routes through this sandbox's proxies.
720
- const env = { ...baseEnv, ...generated };
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).
849
+ const env = { ...process.env, ...generated };
721
850
  return { argv, env };
722
851
  }
723
852
  /**
@@ -740,80 +869,71 @@ function envListToObject(list) {
740
869
  // ────────────────────────────────────────────────────────────────────
741
870
  /**
742
871
  * Install instructions, surfaced verbatim in error messages.
743
- * Tailored to the observed group state: if the install already
744
- * ran (`created-not-on-token`), only the logout is missing.
745
872
  */
746
- export function windowsInstallInstructions(ref, sublayerGuid, groupState) {
747
- if (groupState === 'created-not-on-token') {
748
- return (`The discriminator group exists but is not yet in this session's ` +
749
- `token. LOG OUT and back in to pick up the new group membership ` +
750
- `(it enters TokenGroups at logon). Network is not disrupted ` +
751
- `meanwhile — WFP filter-0 PERMITs traffic while the group is absent ` +
752
- `from your token.`);
753
- }
754
- const g = ref.groupSid
755
- ? `--group-sid ${ref.groupSid}`
756
- : `--name ${ref.groupName ?? DEFAULT_WINDOWS_GROUP_NAME}`;
873
+ export function windowsInstallInstructions(sublayerGuid) {
757
874
  const sl = sublayerGuid ? ` --sublayer-guid ${sublayerGuid}` : '';
758
875
  return (`Windows sandbox needs a one-time install (one UAC prompt):\n` +
759
876
  ` npx sandbox-runtime windows-install\n` +
760
877
  ` — or call installWindowsSandbox(), or run ` +
761
- `\`srt-win.exe install ${g}${sl}\` directly —\n` +
762
- `then LOG OUT and back in (the group SID enters TokenGroups at logon).\n` +
763
- `Network is not disrupted before the logout: while the group is absent ` +
764
- `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.`);
765
881
  }
766
882
  /**
767
883
  * Check the Windows backend is ready to sandbox. Errors block
768
884
  * `initialize()`; warnings are informational.
769
885
  */
770
- export function checkWindowsDependencies(ref, sublayerGuid) {
886
+ export function checkWindowsDependencies(opts = {}) {
887
+ const { sublayerGuid } = opts;
771
888
  const errors = [];
772
889
  const warnings = [];
773
- // 1. Binary present.
774
- 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;
775
894
  try {
776
- exe = getSrtWinPath();
895
+ srtWin = opts.srtWin ?? resolveSrtWin();
777
896
  }
778
897
  catch (e) {
779
898
  return { errors: [e.message], warnings };
780
899
  }
781
- logForDebugging(`[Sandbox Windows] using srt-win at ${exe}`);
782
- // 2. Group ready (exists AND enabled in the caller's token).
783
- let gs;
900
+ logForDebugging(`[Sandbox Windows] using srt-win at ${srtWin.exe}`);
901
+ // 2. Sandbox user provisioned + credential readable.
902
+ let us;
784
903
  try {
785
- gs = getWindowsGroupStatus(ref);
904
+ us = getWindowsSandboxUserStatus({ srtWin });
786
905
  }
787
906
  catch (e) {
788
- errors.push(`srt-win group status failed: ${e.message}`);
907
+ errors.push(`srt-win user status failed: ${e.message}`);
789
908
  return { errors, warnings };
790
909
  }
791
- if (gs.state !== 'ready') {
792
- errors.push(`Discriminator group is ${gs.state}` +
793
- (gs.sid ? ` (sid=${gs.sid})` : '') +
794
- `. ` +
795
- 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));
796
914
  }
797
- if (gs.warning)
798
- warnings.push(gs.warning);
799
- // 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.
800
919
  let ws;
801
920
  try {
802
- ws = getWindowsWfpStatus({ sublayerGuid });
921
+ ws = getWindowsWfpStatus({ sublayerGuid, srtWin });
803
922
  }
804
923
  catch (e) {
805
924
  errors.push(`srt-win wfp status failed: ${e.message}`);
806
925
  return { errors, warnings };
807
926
  }
808
- if (ws.state !== 'installed') {
809
- // If the group is also not-ready, the group-state error above
810
- // already gave the right instruction; don't repeat. Only
811
- // surface a separate WFP error when group IS ready (i.e.
812
- // someone uninstalled filters but kept the group).
813
- 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) {
814
934
  errors.push(`WFP filters not installed under sublayer ` +
815
935
  `${sublayerGuid ?? '(default)'}. ` +
816
- windowsInstallInstructions(ref, sublayerGuid, 'absent'));
936
+ windowsInstallInstructions(sublayerGuid));
817
937
  }
818
938
  }
819
939
  else if (ws.portRange) {