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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +43 -2
  2. package/dist/index.d.ts +3 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/sandbox/credential-mask-files.d.ts +72 -0
  7. package/dist/sandbox/credential-mask-files.d.ts.map +1 -0
  8. package/dist/sandbox/credential-mask-files.js +146 -0
  9. package/dist/sandbox/credential-mask-files.js.map +1 -0
  10. package/dist/sandbox/credential-sentinel.d.ts +72 -0
  11. package/dist/sandbox/credential-sentinel.d.ts.map +1 -0
  12. package/dist/sandbox/credential-sentinel.js +130 -0
  13. package/dist/sandbox/credential-sentinel.js.map +1 -0
  14. package/dist/sandbox/domain-pattern.d.ts +36 -0
  15. package/dist/sandbox/domain-pattern.d.ts.map +1 -0
  16. package/dist/sandbox/domain-pattern.js +60 -0
  17. package/dist/sandbox/domain-pattern.js.map +1 -0
  18. package/dist/sandbox/http-proxy.d.ts +32 -1
  19. package/dist/sandbox/http-proxy.d.ts.map +1 -1
  20. package/dist/sandbox/http-proxy.js +10 -2
  21. package/dist/sandbox/http-proxy.js.map +1 -1
  22. package/dist/sandbox/linux-sandbox-utils.d.ts +15 -0
  23. package/dist/sandbox/linux-sandbox-utils.d.ts.map +1 -1
  24. package/dist/sandbox/linux-sandbox-utils.js +106 -47
  25. package/dist/sandbox/linux-sandbox-utils.js.map +1 -1
  26. package/dist/sandbox/listen-in-range.d.ts +12 -0
  27. package/dist/sandbox/listen-in-range.d.ts.map +1 -0
  28. package/dist/sandbox/listen-in-range.js +43 -0
  29. package/dist/sandbox/listen-in-range.js.map +1 -0
  30. package/dist/sandbox/macos-sandbox-utils.d.ts +11 -0
  31. package/dist/sandbox/macos-sandbox-utils.d.ts.map +1 -1
  32. package/dist/sandbox/macos-sandbox-utils.js +69 -14
  33. package/dist/sandbox/macos-sandbox-utils.js.map +1 -1
  34. package/dist/sandbox/mitm-ca.d.ts +18 -2
  35. package/dist/sandbox/mitm-ca.d.ts.map +1 -1
  36. package/dist/sandbox/mitm-ca.js +49 -9
  37. package/dist/sandbox/mitm-ca.js.map +1 -1
  38. package/dist/sandbox/mitm-leaf.d.ts.map +1 -1
  39. package/dist/sandbox/mitm-leaf.js +24 -6
  40. package/dist/sandbox/mitm-leaf.js.map +1 -1
  41. package/dist/sandbox/mux-proxy.d.ts +59 -0
  42. package/dist/sandbox/mux-proxy.d.ts.map +1 -0
  43. package/dist/sandbox/mux-proxy.js +159 -0
  44. package/dist/sandbox/mux-proxy.js.map +1 -0
  45. package/dist/sandbox/request-filter.d.ts +11 -1
  46. package/dist/sandbox/request-filter.d.ts.map +1 -1
  47. package/dist/sandbox/request-filter.js.map +1 -1
  48. package/dist/sandbox/sandbox-config.d.ts +254 -25
  49. package/dist/sandbox/sandbox-config.d.ts.map +1 -1
  50. package/dist/sandbox/sandbox-config.js +200 -13
  51. package/dist/sandbox/sandbox-config.js.map +1 -1
  52. package/dist/sandbox/sandbox-manager.d.ts +4 -0
  53. package/dist/sandbox/sandbox-manager.d.ts.map +1 -1
  54. package/dist/sandbox/sandbox-manager.js +601 -194
  55. package/dist/sandbox/sandbox-manager.js.map +1 -1
  56. package/dist/sandbox/sandbox-schemas.d.ts +15 -0
  57. package/dist/sandbox/sandbox-schemas.d.ts.map +1 -1
  58. package/dist/sandbox/sandbox-utils.d.ts +42 -6
  59. package/dist/sandbox/sandbox-utils.d.ts.map +1 -1
  60. package/dist/sandbox/sandbox-utils.js +115 -27
  61. package/dist/sandbox/sandbox-utils.js.map +1 -1
  62. package/dist/sandbox/socks-proxy.d.ts +11 -5
  63. package/dist/sandbox/socks-proxy.d.ts.map +1 -1
  64. package/dist/sandbox/socks-proxy.js +13 -81
  65. package/dist/sandbox/socks-proxy.js.map +1 -1
  66. package/dist/sandbox/tls-terminate-proxy.d.ts +2 -2
  67. package/dist/sandbox/tls-terminate-proxy.d.ts.map +1 -1
  68. package/dist/sandbox/tls-terminate-proxy.js +31 -5
  69. package/dist/sandbox/tls-terminate-proxy.js.map +1 -1
  70. package/dist/sandbox/windows-sandbox-utils.d.ts +347 -11
  71. package/dist/sandbox/windows-sandbox-utils.d.ts.map +1 -1
  72. package/dist/sandbox/windows-sandbox-utils.js +437 -45
  73. package/dist/sandbox/windows-sandbox-utils.js.map +1 -1
  74. package/package.json +7 -4
  75. package/vendor/seccomp/build.ts +8 -30
  76. package/vendor/srt-win/build.ts +21 -0
  77. package/dist/vendor/seccomp/Dockerfile.build +0 -6
  78. package/dist/vendor/seccomp/arm64/apply-seccomp +0 -0
  79. package/dist/vendor/seccomp/build.ts +0 -91
  80. package/dist/vendor/seccomp/x64/apply-seccomp +0 -0
  81. package/dist/vendor/seccomp-src/apply-seccomp.c +0 -280
  82. package/dist/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  83. package/dist/vendor/srt-win/Cargo.lock +0 -361
  84. package/dist/vendor/srt-win/Cargo.toml +0 -46
  85. package/dist/vendor/srt-win/ci/cleanup.ps1 +0 -49
  86. package/dist/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  87. package/dist/vendor/srt-win/ci/smoke.ps1 +0 -307
  88. package/dist/vendor/srt-win/src/job.rs +0 -102
  89. package/dist/vendor/srt-win/src/launch.rs +0 -732
  90. package/dist/vendor/srt-win/src/lib.rs +0 -20
  91. package/dist/vendor/srt-win/src/main.rs +0 -669
  92. package/dist/vendor/srt-win/src/self_protect.rs +0 -169
  93. package/dist/vendor/srt-win/src/sid.rs +0 -296
  94. package/dist/vendor/srt-win/src/token.rs +0 -341
  95. package/dist/vendor/srt-win/src/util.rs +0 -42
  96. package/dist/vendor/srt-win/src/wfp.rs +0 -992
  97. package/dist/vendor/srt-win/src/winsta.rs +0 -209
  98. package/dist/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
  99. package/vendor/seccomp-src/apply-seccomp.c +0 -280
  100. package/vendor/seccomp-src/seccomp-unix-block.c +0 -148
  101. package/vendor/srt-win/Cargo.lock +0 -361
  102. package/vendor/srt-win/Cargo.toml +0 -46
  103. package/vendor/srt-win/ci/cleanup.ps1 +0 -49
  104. package/vendor/srt-win/ci/smoke-exec.ps1 +0 -446
  105. package/vendor/srt-win/ci/smoke.ps1 +0 -307
  106. package/vendor/srt-win/src/job.rs +0 -102
  107. package/vendor/srt-win/src/launch.rs +0 -732
  108. package/vendor/srt-win/src/lib.rs +0 -20
  109. package/vendor/srt-win/src/main.rs +0 -669
  110. package/vendor/srt-win/src/self_protect.rs +0 -169
  111. package/vendor/srt-win/src/sid.rs +0 -296
  112. package/vendor/srt-win/src/token.rs +0 -341
  113. package/vendor/srt-win/src/util.rs +0 -42
  114. package/vendor/srt-win/src/wfp.rs +0 -992
  115. package/vendor/srt-win/src/winsta.rs +0 -209
  116. package/vendor/srt-win/tests/sd_access_check_matrix.rs +0 -259
@@ -3,7 +3,10 @@ import * as path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { logForDebugging } from '../utils/debug.js';
6
- import { generateProxyEnvVars } from './sandbox-utils.js';
6
+ import { containsGlobCharsWin, expandGlobPattern, generateProxyEnvVars, normalizePathForSandbox, } from './sandbox-utils.js';
7
+ // Re-export so existing tests (glob-expand.test.ts) and any
8
+ // out-of-tree caller keep their import path.
9
+ export { containsGlobCharsWin, stripExtendedPathPrefix, } from './sandbox-utils.js';
7
10
  /**
8
11
  * Windows sandbox backend.
9
12
  *
@@ -19,7 +22,12 @@ import { generateProxyEnvVars } from './sandbox-utils.js';
19
22
  * token-membership check; WFP via providerData-tag enumeration under
20
23
  * the configured sublayer). There is no marker file.
21
24
  *
22
- * Filesystem restrictions are NOT enforced on Windows yet.
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}.
23
31
  */
24
32
  // ────────────────────────────────────────────────────────────────────
25
33
  // Types
@@ -28,6 +36,50 @@ export const DEFAULT_WINDOWS_GROUP_NAME = 'sandbox-runtime-net';
28
36
  export const DEFAULT_WINDOWS_PROXY_PORT_RANGE = [
29
37
  60080, 60089,
30
38
  ];
39
+ /**
40
+ * Adapter from the cross-platform `binShell?: string` surface
41
+ * ({@link SandboxManager.wrapWithSandboxArgv}) to the Windows
42
+ * discriminated union. Throws on any value outside the recognised
43
+ * set — there is no silent fallback to cmd.exe.
44
+ *
45
+ * Uses `path.win32` explicitly so the function (and its unit test)
46
+ * is platform-independent.
47
+ */
48
+ export function parseWindowsBinShell(raw) {
49
+ if (raw === undefined)
50
+ return { kind: 'cmd' };
51
+ // bash/sh: path semantics — match on basename, keep the caller's
52
+ // absolute path verbatim.
53
+ const base = path.win32.basename(raw).toLowerCase();
54
+ if (base === 'bash' ||
55
+ base === 'bash.exe' ||
56
+ base === 'sh' ||
57
+ base === 'sh.exe') {
58
+ if (!path.win32.isAbsolute(raw)) {
59
+ throw new Error(`binShell bash path must be absolute (got ${JSON.stringify(raw)}); ` +
60
+ `pass the resolved Git Bash install path`);
61
+ }
62
+ return { kind: 'bash', path: raw };
63
+ }
64
+ // cmd/powershell/pwsh: token semantics — match on the FULL string,
65
+ // not basename, so an absolute path to pwsh.exe (whose path we'd
66
+ // otherwise discard) falls through to the explicit throw rather
67
+ // than silently degrading to a PATH lookup.
68
+ switch (raw.toLowerCase()) {
69
+ case 'pwsh':
70
+ case 'pwsh.exe':
71
+ return { kind: 'pwsh' };
72
+ case 'powershell':
73
+ case 'powershell.exe':
74
+ return { kind: 'powershell' };
75
+ case 'cmd':
76
+ case 'cmd.exe':
77
+ return { kind: 'cmd' };
78
+ default:
79
+ throw new Error(`unrecognised binShell ${JSON.stringify(raw)}: expected ` +
80
+ `'cmd' | 'powershell' | 'pwsh' or an absolute path to bash.exe/sh.exe`);
81
+ }
82
+ }
31
83
  // ────────────────────────────────────────────────────────────────────
32
84
  // Binary resolution
33
85
  // ────────────────────────────────────────────────────────────────────
@@ -36,12 +88,20 @@ function repoRoot() {
36
88
  const here = path.dirname(fileURLToPath(import.meta.url));
37
89
  return path.resolve(here, '..', '..');
38
90
  }
91
+ const nodeArchToDir = { x64: 'x64', arm64: 'arm64' };
39
92
  /**
40
93
  * Locate `srt-win.exe`. Resolution order:
41
94
  * 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).
95
+ * 2. `<root>/vendor/srt-win/{arch}/srt-win.exe` (prebuilt published npm
96
+ * package, or after `npm run build:srt-win` locally).
97
+ * 3. `<root>/vendor/srt-win-src/target/release/srt-win.exe` (local
98
+ * `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
+ *
102
+ * `<root>` is {@link repoRoot} — `__dirname/../..`, which resolves to the
103
+ * repo root from `src/sandbox/` and `dist/sandbox/` alike, and to the
104
+ * package root when installed under `node_modules`.
45
105
  *
46
106
  * Resolution via the optional `@anthropic-ai/sandbox-runtime-win32-*`
47
107
  * platform packages is added separately.
@@ -54,16 +114,20 @@ export function getSrtWinPath() {
54
114
  return envPath;
55
115
  }
56
116
  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
- ];
117
+ const arch = nodeArchToDir[process.arch];
118
+ const candidates = [];
119
+ if (arch) {
120
+ candidates.push(path.join(root, 'vendor', 'srt-win', arch, 'srt-win.exe'));
121
+ }
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'));
61
125
  for (const c of candidates) {
62
126
  if (fs.existsSync(c))
63
127
  return c;
64
128
  }
65
129
  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\`. ` +
130
+ `\`cargo build --release --manifest-path vendor/srt-win-src/Cargo.toml\`. ` +
67
131
  `Looked in: ${[envPath, ...candidates].filter(Boolean).join(', ')}`);
68
132
  }
69
133
  // ────────────────────────────────────────────────────────────────────
@@ -74,9 +138,13 @@ function groupRefArgs(ref) {
74
138
  return ['--group-sid', ref.groupSid];
75
139
  return ['--name', ref.groupName ?? DEFAULT_WINDOWS_GROUP_NAME];
76
140
  }
77
- function runSrtWin(args) {
141
+ function runSrtWin(args, stdin, timeoutMs = 15000) {
78
142
  const exe = getSrtWinPath();
79
- const r = spawnSync(exe, args, { encoding: 'utf8', timeout: 15000 });
143
+ const r = spawnSync(exe, args, {
144
+ encoding: 'utf8',
145
+ timeout: timeoutMs,
146
+ ...(stdin !== undefined && { input: stdin }),
147
+ });
80
148
  if (r.error) {
81
149
  throw new Error(`srt-win ${args[0]}: spawn failed: ${r.error.message}`);
82
150
  }
@@ -86,20 +154,29 @@ function runSrtWin(args) {
86
154
  stderr: (r.stderr ?? '').trim(),
87
155
  };
88
156
  }
89
- function runSrtWinJson(args) {
90
- const r = runSrtWin(args);
91
- if (r.status !== 0) {
92
- throw new Error(`srt-win ${args.join(' ')} exited ${r.status}: ${r.stderr || r.stdout}`);
93
- }
94
- // Status subcommands print exactly one line of JSON to stdout. stderr
95
- // may carry `srt-win:` diagnostics — ignore it for parsing.
157
+ 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;
96
164
  try {
97
- return JSON.parse(r.stdout);
165
+ json = JSON.parse(r.stdout);
98
166
  }
99
167
  catch (e) {
100
- throw new Error(`srt-win ${args.join(' ')}: unparseable JSON output ` +
101
- `${JSON.stringify(r.stdout)}: ${e.message}`);
168
+ parseErr = e.message;
169
+ }
170
+ if (opts?.allowNonZero && json !== undefined) {
171
+ return { ok: r.status === 0, json, stderr: r.stderr };
102
172
  }
173
+ if (r.status !== 0) {
174
+ throw new Error(`srt-win ${args.join(' ')} exited ${r.status}: ${r.stderr || r.stdout}`);
175
+ }
176
+ if (json !== undefined)
177
+ return json;
178
+ throw new Error(`srt-win ${args.join(' ')}: unparseable JSON output ` +
179
+ `${JSON.stringify(r.stdout)}: ${parseErr}`);
103
180
  }
104
181
  // ────────────────────────────────────────────────────────────────────
105
182
  // Status / install API
@@ -129,8 +206,83 @@ export function getWindowsWfpStatus(opts = {}) {
129
206
  state: raw.state,
130
207
  filters: raw.filters,
131
208
  ...(raw.port_range && { portRange: raw.port_range }),
209
+ userFilters: raw.user_filters ?? 0,
210
+ ...(raw.user_sid && { userSid: raw.user_sid }),
132
211
  };
133
212
  }
213
+ /**
214
+ * Query the sandbox user account's provisioning state. Each field
215
+ * is independently observed so a half-provisioned install (e.g.
216
+ * user exists but credential file missing) is distinguishable.
217
+ * Does not require elevation.
218
+ */
219
+ export function getWindowsSandboxUserStatus() {
220
+ const raw = runSrtWinJson(['user', 'status']);
221
+ return {
222
+ provisioned: raw.user.exists,
223
+ ...(raw.user.sid && { sid: raw.user.sid }),
224
+ groupExists: raw.user.group_exists,
225
+ ...(raw.user.group_sid && { groupSid: raw.user.group_sid }),
226
+ inBuiltinUsers: raw.user.in_builtin_users,
227
+ inSandboxGroup: raw.user.in_sandbox_group,
228
+ hiddenFromLogon: raw.user.hidden_from_logon,
229
+ credPresent: raw.cred_present,
230
+ ...(typeof raw.marker_version === 'number' && {
231
+ markerVersion: raw.marker_version,
232
+ }),
233
+ ...(raw.ca_cert_thumb && { caCertThumb: raw.ca_cert_thumb }),
234
+ ...(raw.ca_cert_pem && { caCertPem: raw.ca_cert_pem }),
235
+ };
236
+ }
237
+ /**
238
+ * Read back the persistent MITM CA the sandbox was installed with
239
+ * (via `srt-win user trust-ca` / {@link windowsTrustCa}).
240
+ * Returns `null` when no CA was installed. The PEM is what `srt-win
241
+ * user status` reconstructs from the DER stored in `state.db`.
242
+ *
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.
248
+ *
249
+ * @param status pass an already-fetched
250
+ * {@link getWindowsSandboxUserStatus} result to avoid a second
251
+ * `srt-win user status` spawn.
252
+ */
253
+ export function getWindowsSandboxCaCert(status) {
254
+ const u = status ?? getWindowsSandboxUserStatus();
255
+ if (!u.caCertThumb || !u.caCertPem)
256
+ return null;
257
+ return { pem: u.caCertPem, thumb: u.caCertThumb };
258
+ }
259
+ /**
260
+ * Install (or replace) the MITM CA in the **sandbox user's**
261
+ * `CurrentUser\Root` and record it in `state.db` (so
262
+ * {@link getWindowsSandboxCaCert} surfaces its thumbprint + PEM).
263
+ * Thin wrapper around `srt-win user trust-ca <path>`. Does NOT
264
+ * require elevation. Persistent until {@link uninstallWindowsSandbox}
265
+ * deletes the sandbox user's profile.
266
+ *
267
+ * The CA has a separate lifecycle from {@link installWindowsSandbox}
268
+ * — install provisions the account/filters and never touches the CA;
269
+ * call this AFTER install when `tlsTerminate` will be used with
270
+ * `windows.asSandboxUser`.
271
+ *
272
+ * @throws when the sandbox user is not provisioned, the file is not a
273
+ * parseable X.509 certificate, or the registry write into the
274
+ * sandbox user's hive fails.
275
+ */
276
+ export function windowsTrustCa(caCertPath) {
277
+ // 60s: first call may create the sandbox user's profile
278
+ // (LOGON_WITH_PROFILE) via the one-shot CreateProcessWithLogonW.
279
+ const r = runSrtWin(['user', 'trust-ca', caCertPath], undefined, 60000);
280
+ logForDebugging(`[Sandbox Windows] user trust-ca exit=${r.status}: ${r.stderr || r.stdout}`);
281
+ if (r.status !== 0) {
282
+ throw new Error(`srt-win user trust-ca '${caCertPath}' failed (exit ` +
283
+ `${r.status}): ${r.stderr || r.stdout}`);
284
+ }
285
+ }
134
286
  /**
135
287
  * One-shot install: creates the discriminator group, adds the
136
288
  * current user (or `userSid`), and installs the machine-wide WFP
@@ -164,7 +316,7 @@ export function installWindowsSandbox(opts = {}) {
164
316
  }
165
317
  if (opts.force)
166
318
  args.push('--force');
167
- const r = runSrtWin(args);
319
+ const r = runSrtWin(args, undefined, 60000);
168
320
  logForDebugging(`[Sandbox Windows] install exit=${r.status}: ${r.stderr || r.stdout}`);
169
321
  // srt-win install exit-code contract:
170
322
  // 0 ok
@@ -172,6 +324,7 @@ export function installWindowsSandbox(opts = {}) {
172
324
  // 11 group create failed
173
325
  // 12 WFP install failed
174
326
  // 13 already installed with different config (use --force)
327
+ // 14 sandbox-user provisioning failed
175
328
  // 1 other error (stderr has detail)
176
329
  const out = r.stderr || r.stdout;
177
330
  switch (r.status) {
@@ -181,12 +334,15 @@ export function installWindowsSandbox(opts = {}) {
181
334
  return {
182
335
  group: getWindowsGroupStatus(opts),
183
336
  wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
337
+ user: getWindowsSandboxUserStatus(),
184
338
  cancelled: true,
185
339
  };
186
340
  case 11:
187
341
  throw new Error(`srt-win install: group create failed: ${out}`);
188
342
  case 12:
189
343
  throw new Error(`srt-win install: WFP filter install failed: ${out}`);
344
+ case 14:
345
+ throw new Error(`srt-win install: sandbox user provisioning failed: ${out}`);
190
346
  case 13:
191
347
  throw new Error(`srt-win install: filters already exist under this sublayer with ` +
192
348
  `different configuration (group SID or port range). ` +
@@ -198,6 +354,7 @@ export function installWindowsSandbox(opts = {}) {
198
354
  return {
199
355
  group: getWindowsGroupStatus(opts),
200
356
  wfp: getWindowsWfpStatus({ sublayerGuid: opts.sublayerGuid }),
357
+ user: getWindowsSandboxUserStatus(),
201
358
  };
202
359
  }
203
360
  /**
@@ -209,12 +366,19 @@ export function installWindowsSandbox(opts = {}) {
209
366
  * re-do the logout dance on the next install. Call
210
367
  * {@link deleteWindowsGroup} explicitly if you want full teardown.
211
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.
373
+ *
212
374
  * @returns `{cancelled: true}` if the user dismissed UAC.
213
375
  */
214
376
  export function uninstallWindowsSandbox(opts = {}) {
215
377
  const args = ['uninstall'];
216
378
  if (opts.sublayerGuid)
217
379
  args.push('--sublayer-guid', opts.sublayerGuid);
380
+ if (opts.keepUser)
381
+ args.push('--keep-user');
218
382
  const r = runSrtWin(args);
219
383
  logForDebugging(`[Sandbox Windows] uninstall exit=${r.status}: ${r.stderr || r.stdout}`);
220
384
  if (r.status === 10)
@@ -280,6 +444,136 @@ export function createWindowsWfp(ref) {
280
444
  }
281
445
  logForDebugging(`[Sandbox Windows] wfp install: ${r.stderr || r.stdout}`);
282
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) {
464
+ const out = new Set();
465
+ for (const raw of patterns) {
466
+ const norm = normalizePathForSandbox(raw);
467
+ const candidates = containsGlobCharsWin(norm)
468
+ ? expandGlobPattern(norm, { caseInsensitive: true })
469
+ : [norm];
470
+ for (const c of candidates) {
471
+ const st = fs.statSync(c, { throwIfNoEntry: false });
472
+ if (!st)
473
+ 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
+ out.add(c);
480
+ }
481
+ }
482
+ return [...out];
483
+ }
484
+ /**
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.
489
+ *
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}.
494
+ *
495
+ * @throws on exit ≠ 0 — including exit 2 (one or more inputs
496
+ * skipped). srt-win stamps the resolvable inputs before exiting
497
+ * 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).
500
+ */
501
+ export function stampWindowsAcl(opts) {
502
+ const holder = opts.holderPid ?? process.pid;
503
+ const stdin = JSON.stringify({
504
+ denyRead: opts.denyRead,
505
+ denyWrite: opts.denyWrite,
506
+ });
507
+ const r = runSrtWin(['acl', 'stamp', ...groupRefArgs(opts.group), '--holder-pid', `${holder}`], stdin, 60000);
508
+ logForDebugging(`[Sandbox Windows] acl stamp exit=${r.status}: ${r.stderr || r.stdout}`);
509
+ 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
+ throw new Error(`srt-win acl stamp exited ${r.status} ` +
513
+ (r.status === 2 ? '(partial — some inputs skipped)' : '(failed)') +
514
+ `: ${r.stderr || r.stdout}`);
515
+ }
516
+ }
517
+ /**
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.
527
+ */
528
+ export function restoreWindowsAcl(opts) {
529
+ 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
+ // 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.
545
+ try {
546
+ const r = runSrtWinJson(args, {
547
+ timeoutMs: 60000,
548
+ allowNonZero: true,
549
+ });
550
+ if (!r.ok) {
551
+ logForDebugging(`[Sandbox Windows] acl restore exited non-zero (per-path ` +
552
+ `outcomes preserved): ${r.stderr}`, { level: 'error' });
553
+ }
554
+ return r.json;
555
+ }
556
+ catch (e) {
557
+ logForDebugging(`[Sandbox Windows] acl restore: ${e.message}`, {
558
+ level: 'error',
559
+ });
560
+ return undefined;
561
+ }
562
+ }
563
+ /**
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.
567
+ */
568
+ export const WINDOWS_ACL_PATH_OK = new Set([
569
+ 'restored',
570
+ ]);
571
+ /**
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.
575
+ */
576
+ export const WINDOWS_ACL_PARENT_OK = new Set(['restored', 'alreadyOriginal', 'stillHeld']);
283
577
  // ────────────────────────────────────────────────────────────────────
284
578
  // Wrap
285
579
  // ────────────────────────────────────────────────────────────────────
@@ -290,7 +584,7 @@ export function createWindowsWfp(ref) {
290
584
  * Caller MUST spawn the result with `{shell: false}` — that is the
291
585
  * security boundary that keeps untrusted bytes off the host's shell
292
586
  * (the inner `cmd.exe /c` runs INSIDE the sandbox; see
293
- * `vendor/srt-win/src/launch.rs` `build_cmdline` for the passthrough
587
+ * `vendor/srt-win-src/src/launch.rs` `build_cmdline` for the passthrough
294
588
  * rationale) — AND with the returned `env`.
295
589
  *
296
590
  * Proxy configuration is single-sourced by {@link generateProxyEnvVars}
@@ -302,30 +596,128 @@ export function createWindowsWfp(ref) {
302
596
  */
303
597
  export function wrapCommandWithSandboxWindows(p) {
304
598
  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));
599
+ // Generated proxy + CA-trust env. Single-sourced here so the
600
+ // same object feeds (a) the spawn env merge below and (b) the
601
+ // explicit `--env` overlay for the two-hop launch.
602
+ //
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));
325
618
  // TMPDIR is a POSIX path meant for the macOS/Linux FS sandbox — it
326
619
  // serves no purpose on Windows and breaks msys2 tools (mktemp etc.).
327
620
  delete generated.TMPDIR;
328
- const env = { ...process.env, ...generated };
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
+ }
652
+ }
653
+ argv.push('--');
654
+ const systemRoot = process.env.SystemRoot ?? 'C:\\Windows';
655
+ const sh = p.binShell ?? { kind: 'cmd' };
656
+ switch (sh.kind) {
657
+ case 'bash':
658
+ // Git Bash: invoke the caller-supplied path directly with
659
+ // `-c <command>`. `command` is a fully-assembled bash command
660
+ // string with its own internal quoting; srt-win's `build_cmdline`
661
+ // takes the generic non-cmd branch and MSVCRT-quotes it as a
662
+ // 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
+ argv.push(sh.path, '-c', p.command);
667
+ break;
668
+ case 'pwsh':
669
+ argv.push('pwsh.exe', '-NoProfile', '-Command', p.command);
670
+ break;
671
+ case 'powershell':
672
+ argv.push(path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), '-NoProfile', '-Command', p.command);
673
+ break;
674
+ case 'cmd':
675
+ // cmd /d (no AutoRun) /s (strip first+last quote of post-/c by
676
+ // position) /c (run-then-exit). The `command` string lands as a
677
+ // single argv element; srt-win's build_cmdline wraps it in one
678
+ // outer "…" pair for /s to consume. See launch.rs.
679
+ argv.push(path.join(systemRoot, 'System32', 'cmd.exe'), '/d', '/s', '/c', p.command);
680
+ break;
681
+ }
682
+ // CreateProcessW's lpCommandLine is capped at 32 767 WCHARs.
683
+ // 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.
692
+ const cmdlineEstimate = argv.reduce((n, a) => n + a.length + 3, 0);
693
+ if (cmdlineEstimate > 30000) {
694
+ const nDeny = (p.denyRead?.length ?? 0) + (p.denyWrite?.length ?? 0);
695
+ 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 };
329
721
  return { argv, env };
330
722
  }
331
723
  /**