@deepseek-ai/dsh-sandbox-windows-acl 0.0.1-rc.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.
@@ -0,0 +1,1545 @@
1
+ import { existsSync, mkdirSync, statSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import koffi from "koffi";
5
+ //#region lib/types/errors.js
6
+ /**
7
+ * Fail-closed Win32 error type. Every backend API failure raises this with the
8
+ * API name and the exact Win32 code; the original POC silently ignored every
9
+ * failed call and would run children UNRESTRICTED (fail-open) — that is the
10
+ * failure mode this class exists to prevent.
11
+ * @module @deepseek-ai/dsh-sandbox-windows-acl/errors
12
+ */
13
+ var Win32Error = class extends Error {
14
+ /** The failing Win32 API name, e.g. `CreateRestrictedToken`. */
15
+ api;
16
+ /** The Win32 error code (`GetLastError` for BOOL APIs, the HRESULT-style return for ACL APIs). */
17
+ win32Code;
18
+ constructor(api, win32Code, detail) {
19
+ super(`${api} failed (Win32 ${win32Code})${detail === void 0 ? "" : `: ${detail}`}`);
20
+ this.name = "Win32Error";
21
+ this.api = api;
22
+ this.win32Code = win32Code;
23
+ }
24
+ };
25
+ /**
26
+ * SE_GROUP_LOGON_ID: marks a token group SID as the logon SID (compared with
27
+ * `>>> 0` — the flag's high bit makes it negative as a signed 32-bit number).
28
+ */
29
+ const SE_GROUP_LOGON_ID = 3221225472;
30
+ /**
31
+ * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and
32
+ * FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant
33
+ * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are
34
+ * deliberately excluded: they would let the confined child take ownership or
35
+ * rewrite DACLs.
36
+ */
37
+ const GRANT_MASK = 1114454;
38
+ /**
39
+ * FILE_ALL_ACCESS (winnt.h line ~2789: STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE
40
+ * | 0x1FF): full file-object access. The mask of the ACE merged into the
41
+ * restricted token's DEFAULT DACL — the token holder must keep full access to
42
+ * every NEW object it creates (pipes included), and the ACE must name a
43
+ * restricting SID so the write pass-2 check passes at creation.
44
+ */
45
+ const FILE_ALL_ACCESS = 2032127;
46
+ /** PROCESS_QUERY_INFORMATION: read exit status and times of a process handle. */
47
+ const PROCESS_QUERY_INFORMATION = 1024;
48
+ /** JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: the child dies when the runner's last job handle closes — the orphan-child backstop. */
49
+ const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 8192;
50
+ //#endregion
51
+ //#region lib/types/ffi.js
52
+ /**
53
+ * Lazy koffi bindings for the Win32 ACL-sandbox backend. Koffi loads lazily so
54
+ * non-Windows processes never open Win32 libraries. Every function signature
55
+ * below was verified against the MinGW Windows headers on this machine
56
+ * (winnt.h / accctrl.h / aclapi.h / securitybaseapi.h / sddl.h /
57
+ * processthreadsapi.h / fileapi.h / namedpipeapi.h / synchapi.h / winbase.h);
58
+ * struct layouts are asserted at load time against verify/abi-probe.cpp.
59
+ * @module @deepseek-ai/dsh-sandbox-windows-acl/ffi
60
+ */
61
+ /**
62
+ * True for NULL pointers, however koffi returns them (null or 0n).
63
+ * @param value - a pointer as koffi may hand it back (pointer, null, or 0n).
64
+ * @returns a type guard narrowing to the NULL shapes.
65
+ */
66
+ function isNullPtr(value) {
67
+ return value === null || value === void 0 || value === 0n;
68
+ }
69
+ /**
70
+ * True for CreateFileW's INVALID_HANDLE_VALUE failure marker (-1, which
71
+ * koffi hands back as the unsigned 64-bit all-ones pointer).
72
+ * @param handle - the handle CreateFileW returned.
73
+ * @returns whether the handle signals failure.
74
+ */
75
+ function isInvalidHandle(handle) {
76
+ if (isNullPtr(handle)) return true;
77
+ return handle === 18446744073709551615n || handle === -1n;
78
+ }
79
+ const PVOID = koffi.pointer("void");
80
+ const PPVOID = koffi.pointer(PVOID);
81
+ /** koffi STARTUPINFOW layout; its size is asserted against abi.STARTUPINFOW_SIZE at load. */
82
+ const STARTUPINFOW = koffi.struct("STARTUPINFOW", {
83
+ cb: "uint32",
84
+ lpReserved: "str16",
85
+ lpDesktop: "str16",
86
+ lpTitle: "str16",
87
+ dwX: "uint32",
88
+ dwY: "uint32",
89
+ dwXSize: "uint32",
90
+ dwYSize: "uint32",
91
+ dwXCountChars: "uint32",
92
+ dwYCountChars: "uint32",
93
+ dwFillAttribute: "uint32",
94
+ dwFlags: "uint32",
95
+ wShowWindow: "uint16",
96
+ cbReserved2: "uint16",
97
+ lpReserved2: koffi.pointer("uint8"),
98
+ hStdInput: PVOID,
99
+ hStdOutput: PVOID,
100
+ hStdError: PVOID
101
+ });
102
+ /** koffi PROCESS_INFORMATION layout; its size is asserted against abi.PROCESS_INFORMATION_SIZE at load. */
103
+ const PROCESS_INFORMATION = koffi.struct("PROCESS_INFORMATION", {
104
+ hProcess: PVOID,
105
+ hThread: PVOID,
106
+ dwProcessId: "uint32",
107
+ dwThreadId: "uint32"
108
+ });
109
+ /* v8 ignore start -- layout-mismatch guards fire only on ABI breakage; verify/abi-probe.cpp pins both sizes. */
110
+ if (STARTUPINFOW.size !== 104) throw new Error(`STARTUPINFOW layout mismatch: koffi computed ${STARTUPINFOW.size}, header probe says 104`);
111
+ if (PROCESS_INFORMATION.size !== 24) throw new Error(`PROCESS_INFORMATION layout mismatch: koffi computed ${PROCESS_INFORMATION.size}, header probe says 24`);
112
+ /* v8 ignore stop */
113
+ /**
114
+ * Allocate one pointer-sized slot (for `T **` out-parameters).
115
+ * @returns the allocated slot pointer.
116
+ */
117
+ function allocPtrSlot() {
118
+ return koffi.alloc(PVOID, 1);
119
+ }
120
+ /**
121
+ * Allocate one uint32 slot.
122
+ * @returns the allocated slot pointer.
123
+ */
124
+ function allocUint32() {
125
+ return koffi.alloc("uint32", 1);
126
+ }
127
+ /**
128
+ * Write a uint32 value into a slot pointer.
129
+ * @param slot - the slot allocated by {@link allocUint32}.
130
+ * @param value - the uint32 to encode.
131
+ */
132
+ function encodeUint32(slot, value) {
133
+ koffi.encode(slot, "uint32", value);
134
+ }
135
+ /**
136
+ * Decode the pointer stored in a pointer-sized slot (NULL becomes null).
137
+ * @param slot - the pointer-sized slot holding the out-parameter value.
138
+ * @returns the decoded pointer, or null for NULL.
139
+ */
140
+ function decodePtr(slot) {
141
+ const value = koffi.decode(slot, PVOID);
142
+ if (isNullPtr(value)) return null;
143
+ return value;
144
+ }
145
+ /**
146
+ * Decode a uint32 at a slot pointer.
147
+ * @param slot - the uint32 slot holding the out-parameter value.
148
+ * @returns the decoded uint32.
149
+ */
150
+ function decodeUint32(slot) {
151
+ return koffi.decode(slot, "uint32");
152
+ }
153
+ /**
154
+ * Cast a koffi pointer to its numeric address (bigint, used for raw struct packing).
155
+ * @param ptr - the koffi pointer.
156
+ * @returns the pointer's numeric address.
157
+ */
158
+ function ptrAddress(ptr) {
159
+ return koffi.address(ptr);
160
+ }
161
+ /**
162
+ * Allocate a raw byte block (used for SID copies and variable-length arrays).
163
+ * @param length - the block size in bytes.
164
+ * @returns the allocated block pointer.
165
+ */
166
+ function allocBytes(length) {
167
+ return koffi.alloc("uint8", length);
168
+ }
169
+ /**
170
+ * Allocate one zeroed OVERLAPPED (32 bytes on x64: Internal@0, InternalHigh@8,
171
+ * Offset@16, OffsetHigh@20, hEvent@24). LockFileEx/UnlockFileEx receive this
172
+ * instead of a NULL lpOverlapped: koffi 3.1.1 crashes on NULL there, and a
173
+ * zeroed OVERLAPPED on a synchronous file handle is the documented equivalent
174
+ * (the byte range locks from offset 0, hEvent stays NULL).
175
+ * @returns the zeroed block pointer.
176
+ */
177
+ function allocOverlapped() {
178
+ return allocBytes(32);
179
+ }
180
+ /**
181
+ * Decode a pointer VALUE stored in memory at `buffer[offset]` (e.g. TOKEN_GROUPS entries).
182
+ * @param buffer - the buffer holding the pointer value.
183
+ * @param offset - byte offset of the pointer inside the buffer.
184
+ * @returns the decoded pointer, or null for NULL.
185
+ */
186
+ function decodePtrAt(buffer, offset) {
187
+ const value = koffi.decode(buffer, offset, PVOID);
188
+ if (isNullPtr(value)) return null;
189
+ return value;
190
+ }
191
+ /**
192
+ * Decode a uint8 at a native pointer plus byte offset — the ACL walk's
193
+ * field-read primitive (koffi.decode with an offset, no memcpy, no pointer
194
+ * arithmetic).
195
+ * @param ptr - the native pointer to read from.
196
+ * @param offset - byte offset from the pointer.
197
+ * @returns the decoded uint8.
198
+ */
199
+ function decodeUint8At(ptr, offset) {
200
+ return koffi.decode(ptr, offset, "uint8");
201
+ }
202
+ /**
203
+ * Decode a uint16 at a native pointer plus byte offset (see {@link decodeUint8At}).
204
+ * @param ptr - the native pointer to read from.
205
+ * @param offset - byte offset from the pointer.
206
+ * @returns the decoded uint16.
207
+ */
208
+ function decodeUint16At(ptr, offset) {
209
+ return koffi.decode(ptr, offset, "uint16");
210
+ }
211
+ /**
212
+ * Decode a uint32 at a native pointer plus byte offset (see {@link decodeUint8At}).
213
+ * @param ptr - the native pointer to read from.
214
+ * @param offset - byte offset from the pointer.
215
+ * @returns the decoded uint32.
216
+ */
217
+ function decodeUint32At(ptr, offset) {
218
+ return koffi.decode(ptr, offset, "uint32");
219
+ }
220
+ /**
221
+ * Compare two SIDs field-by-field via BOUNDED offset reads (revision, count,
222
+ * identifier authority, subauthorities up to the count) — never a fixed-size
223
+ * struct decode, which would read past a short SID allocation (a SID with
224
+ * fewer than 8 subauthorities is smaller than `SID_STRUCT`). An implausible
225
+ * subauthority count reads as unequal.
226
+ * @param left - pointer to one SID (offset 0).
227
+ * @param leftOffset - byte offset of the SID structure within `left`.
228
+ * @param right - pointer to the other SID.
229
+ * @param rightOffset - byte offset of the SID structure within `right`.
230
+ * @returns whether the SIDs are identical.
231
+ */
232
+ function sameSidAt(left, leftOffset, right, rightOffset) {
233
+ if (decodeUint8At(left, leftOffset) !== decodeUint8At(right, rightOffset)) return false;
234
+ const leftCount = decodeUint8At(left, leftOffset + 1);
235
+ if (leftCount !== decodeUint8At(right, rightOffset + 1) || leftCount > 15) return false;
236
+ for (let index = 0; index < 6; index++) if (decodeUint8At(left, leftOffset + 2 + index) !== decodeUint8At(right, rightOffset + 2 + index)) return false;
237
+ for (let index = 0; index < leftCount; index++) if (decodeUint32At(left, leftOffset + 8 + index * 4) !== decodeUint32At(right, rightOffset + 8 + index * 4)) return false;
238
+ return true;
239
+ }
240
+ /**
241
+ * Allocate a zeroed STARTUPINFOW.
242
+ * @returns the allocated struct pointer.
243
+ */
244
+ function allocStartupInfo() {
245
+ return koffi.alloc(STARTUPINFOW, 1);
246
+ }
247
+ /**
248
+ * Write the stdio-relevant fields into a zeroed STARTUPINFOW (others stay default-initialized).
249
+ * @param startupInfo - the allocated STARTUPINFOW to encode into.
250
+ * @param fields - the field subset to write.
251
+ */
252
+ function encodeStartupInfo(startupInfo, fields) {
253
+ koffi.encode(startupInfo, STARTUPINFOW, fields);
254
+ }
255
+ /**
256
+ * Allocate a zeroed PROCESS_INFORMATION.
257
+ * @returns the allocated struct pointer.
258
+ */
259
+ function allocProcessInfo() {
260
+ return koffi.alloc(PROCESS_INFORMATION, 1);
261
+ }
262
+ /**
263
+ * Decode a PROCESS_INFORMATION after CreateProcessAsUserW.
264
+ * @param processInfo - the PROCESS_INFORMATION filled by the spawn call.
265
+ * @returns the decoded handle/id fields.
266
+ */
267
+ function decodeProcessInfo(processInfo) {
268
+ return koffi.decode(processInfo, PROCESS_INFORMATION);
269
+ }
270
+ let cached;
271
+ function bindings() {
272
+ if (cached !== void 0) return cached;
273
+ const kernel32 = koffi.load("kernel32.dll");
274
+ const advapi32 = koffi.load("advapi32.dll");
275
+ const bind = (lib, name, result, args) => lib.func("__stdcall", name, result, args);
276
+ cached = {
277
+ openProcess: bind(kernel32, "OpenProcess", PVOID, [
278
+ "uint32",
279
+ "int",
280
+ "uint32"
281
+ ]),
282
+ openProcessToken: bind(advapi32, "OpenProcessToken", "int", [
283
+ PVOID,
284
+ "uint32",
285
+ PPVOID
286
+ ]),
287
+ closeHandle: bind(kernel32, "CloseHandle", "int", [PVOID]),
288
+ getLastError: bind(kernel32, "GetLastError", "uint32", []),
289
+ formatMessageW: bind(kernel32, "FormatMessageW", "uint32", [
290
+ "uint32",
291
+ PVOID,
292
+ "uint32",
293
+ "uint32",
294
+ PVOID,
295
+ "uint32",
296
+ PVOID
297
+ ]),
298
+ localAlloc: bind(kernel32, "LocalAlloc", PVOID, ["uint32", "size_t"]),
299
+ localFree: bind(kernel32, "LocalFree", PVOID, [PVOID]),
300
+ convertStringSidToSidW: bind(advapi32, "ConvertStringSidToSidW", "int", ["str16", PPVOID]),
301
+ createWellKnownSid: bind(advapi32, "CreateWellKnownSid", "int", [
302
+ "int",
303
+ PVOID,
304
+ PVOID,
305
+ koffi.pointer("uint32")
306
+ ]),
307
+ isValidSid: bind(advapi32, "IsValidSid", "int", [PVOID]),
308
+ getLengthSid: bind(advapi32, "GetLengthSid", "uint32", [PVOID]),
309
+ copySid: bind(advapi32, "CopySid", "int", [
310
+ "uint32",
311
+ PVOID,
312
+ PVOID
313
+ ]),
314
+ getTokenInformation: bind(advapi32, "GetTokenInformation", "int", [
315
+ PVOID,
316
+ "int",
317
+ PVOID,
318
+ "uint32",
319
+ koffi.pointer("uint32")
320
+ ]),
321
+ setTokenInformation: bind(advapi32, "SetTokenInformation", "int", [
322
+ PVOID,
323
+ "int",
324
+ PVOID,
325
+ "uint32"
326
+ ]),
327
+ createRestrictedToken: bind(advapi32, "CreateRestrictedToken", "int", [
328
+ PVOID,
329
+ "uint32",
330
+ "uint32",
331
+ PVOID,
332
+ "uint32",
333
+ PVOID,
334
+ "uint32",
335
+ PVOID,
336
+ PPVOID
337
+ ]),
338
+ setEntriesInAclW: bind(advapi32, "SetEntriesInAclW", "uint32", [
339
+ "uint32",
340
+ PVOID,
341
+ PVOID,
342
+ PPVOID
343
+ ]),
344
+ setNamedSecurityInfoW: bind(advapi32, "SetNamedSecurityInfoW", "uint32", [
345
+ "str16",
346
+ "int",
347
+ "uint32",
348
+ PVOID,
349
+ PVOID,
350
+ PVOID,
351
+ PVOID
352
+ ]),
353
+ getNamedSecurityInfoW: bind(advapi32, "GetNamedSecurityInfoW", "uint32", [
354
+ "str16",
355
+ "int",
356
+ "uint32",
357
+ PPVOID,
358
+ PPVOID,
359
+ PPVOID,
360
+ PPVOID,
361
+ PPVOID
362
+ ]),
363
+ getTempPathW: bind(kernel32, "GetTempPathW", "uint32", ["uint32", PVOID]),
364
+ createFileW: bind(kernel32, "CreateFileW", PVOID, [
365
+ "str16",
366
+ "uint32",
367
+ "uint32",
368
+ PVOID,
369
+ "uint32",
370
+ "uint32",
371
+ PVOID
372
+ ]),
373
+ lockFileEx: bind(kernel32, "LockFileEx", "int", [
374
+ PVOID,
375
+ "uint32",
376
+ "uint32",
377
+ "uint32",
378
+ "uint32",
379
+ PVOID
380
+ ]),
381
+ unlockFileEx: bind(kernel32, "UnlockFileEx", "int", [
382
+ PVOID,
383
+ "uint32",
384
+ "uint32",
385
+ "uint32",
386
+ PVOID
387
+ ]),
388
+ createPipe: bind(kernel32, "CreatePipe", "int", [
389
+ PPVOID,
390
+ PPVOID,
391
+ PVOID,
392
+ "uint32"
393
+ ]),
394
+ setHandleInformation: bind(kernel32, "SetHandleInformation", "int", [
395
+ PVOID,
396
+ "uint32",
397
+ "uint32"
398
+ ]),
399
+ createProcessAsUserW: bind(advapi32, "CreateProcessAsUserW", "int", [
400
+ PVOID,
401
+ "str16",
402
+ "str16",
403
+ PVOID,
404
+ PVOID,
405
+ "int",
406
+ "uint32",
407
+ PVOID,
408
+ "str16",
409
+ koffi.pointer(STARTUPINFOW),
410
+ koffi.pointer(PROCESS_INFORMATION)
411
+ ]),
412
+ setEnvironmentVariableW: bind(kernel32, "SetEnvironmentVariableW", "int", ["str16", "str16"]),
413
+ readFile: bind(kernel32, "ReadFile", "int", [
414
+ PVOID,
415
+ PVOID,
416
+ "uint32",
417
+ koffi.pointer("uint32"),
418
+ PVOID
419
+ ]),
420
+ peekNamedPipe: bind(kernel32, "PeekNamedPipe", "int", [
421
+ PVOID,
422
+ PVOID,
423
+ "uint32",
424
+ koffi.pointer("uint32"),
425
+ koffi.pointer("uint32"),
426
+ koffi.pointer("uint32")
427
+ ]),
428
+ waitForSingleObject: bind(kernel32, "WaitForSingleObject", "uint32", [PVOID, "uint32"]),
429
+ getExitCodeProcess: bind(kernel32, "GetExitCodeProcess", "int", [PVOID, koffi.pointer("uint32")]),
430
+ resumeThread: bind(kernel32, "ResumeThread", "uint32", [PVOID]),
431
+ createJobObjectW: bind(kernel32, "CreateJobObjectW", PVOID, [PVOID, "str16"]),
432
+ setInformationJobObject: bind(kernel32, "SetInformationJobObject", "int", [
433
+ PVOID,
434
+ "int",
435
+ PVOID,
436
+ "uint32"
437
+ ]),
438
+ assignProcessToJobObject: bind(kernel32, "AssignProcessToJobObject", "int", [PVOID, PVOID]),
439
+ terminateProcess: bind(kernel32, "TerminateProcess", "int", [PVOID, "uint32"]),
440
+ setConsoleCtrlHandler: bind(kernel32, "SetConsoleCtrlHandler", "int", [PVOID, "int"]),
441
+ getStdHandle: bind(kernel32, "GetStdHandle", PVOID, ["int"])
442
+ };
443
+ return cached;
444
+ }
445
+ /**
446
+ * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed).
447
+ * @returns the cached binding table.
448
+ */
449
+ function win32() {
450
+ return Promise.resolve(bindings());
451
+ }
452
+ /**
453
+ * Resolve the lazy Win32 bindings SYNCHRONOUSLY — the sandbox seam's
454
+ * server-side per-session grant materializes ACEs inside the synchronous
455
+ * `confine()` call, which cannot await. Same cached table as {@link win32}
456
+ * (the underlying koffi loads are synchronous; the async wrapper exists for
457
+ * the runner's await-shaped call sites).
458
+ * @returns the cached binding table.
459
+ */
460
+ function win32Sync() {
461
+ return bindings();
462
+ }
463
+ /**
464
+ * Turn a Win32 error code into readable text via FormatMessageW.
465
+ * @param api - the binding table.
466
+ * @param win32Code - the error code to format.
467
+ * @returns the formatted message text, or '' when formatting fails.
468
+ */
469
+ function errorText(api, win32Code) {
470
+ const buffer = Buffer.alloc(1024);
471
+ const length = api.formatMessageW(4608, null, win32Code, 0, buffer, buffer.length / 2, null);
472
+ if (length === 0) return "";
473
+ return buffer.subarray(0, length * 2).toString("utf16le").trim();
474
+ }
475
+ /**
476
+ * Read the process temp directory via GetTempPathW (fileapi.h line ~188).
477
+ * Defensive against an overlong system temp path: GetTempPathW reports the
478
+ * REQUIRED length (including NUL) without writing the buffer when it is too
479
+ * small, so a reported length beyond the buffer's capacity means the buffer
480
+ * was never filled and must not be decoded.
481
+ * @param api - the binding table.
482
+ * @returns the NUL-terminated temp path decoded as a string.
483
+ */
484
+ function getTempPath(api) {
485
+ const buffer = Buffer.alloc(261 * 2);
486
+ const length = api.getTempPathW(buffer.length / 2, buffer);
487
+ if (length === 0) throwLastError(api, "GetTempPathW");
488
+ if (length > buffer.length / 2) throw new Win32Error("GetTempPathW", 122, `required ${length} chars exceed the ${buffer.length / 2}-char buffer; nothing was written`);
489
+ return buffer.subarray(0, length * 2).toString("utf16le");
490
+ }
491
+ /**
492
+ * Throw a Win32Error for a BOOL-style API failure. MUST be called immediately
493
+ * after the failed call so GetLastError is not clobbered by other Win32 calls.
494
+ * @param api - the binding table.
495
+ * @param name - the failed API's name for the error message.
496
+ * @param detail - optional detail overriding the formatted system message.
497
+ * @returns never — always throws.
498
+ */
499
+ function throwLastError(api, name, detail) {
500
+ const win32Code = api.getLastError();
501
+ throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code));
502
+ }
503
+ /**
504
+ * Throw a Win32Error for an HRESULT-style API return value (the value IS the error code).
505
+ * @param api - the binding table.
506
+ * @param name - the failed API's name for the error message.
507
+ * @param win32Code - the API's returned error code.
508
+ * @param detail - optional detail overriding the formatted system message.
509
+ * @returns never — always throws.
510
+ */
511
+ function throwWin32(api, name, win32Code, detail) {
512
+ throw new Win32Error(name, win32Code, detail ?? errorText(api, win32Code));
513
+ }
514
+ //#endregion
515
+ //#region lib/types/acl.js
516
+ /**
517
+ * ACL editing helpers: grant/revoke the orphan write SID on a directory via
518
+ * SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with
519
+ * the failure handling the POC lacks). Every API call is checked and every
520
+ * failure is reported with the API name, the exact Win32 code, the formatted
521
+ * system text, and the affected path.
522
+ *
523
+ * Concurrency: grants are read-merge-write against the directory's CURRENT
524
+ * DACL, and the whole get-merge-set sequence runs under a per-path exclusive
525
+ * LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances
526
+ * cannot clobber each other's ACEs.
527
+ * @module @deepseek-ai/dsh-sandbox-windows-acl/acl
528
+ */
529
+ /**
530
+ * Pack one EXPLICIT_ACCESS_W (48 bytes, layout verified by abi-probe.cpp):
531
+ * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16,
532
+ * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }.
533
+ * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which
534
+ * removes every ACE for the trustee.
535
+ * @param sidPtr - the trustee SID the entry names.
536
+ * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS).
537
+ * @param permissions - the access mask to grant (0 for REVOKE_ACCESS).
538
+ * @returns the packed entry buffer.
539
+ */
540
+ function buildExplicitAccess(sidPtr, mode, permissions) {
541
+ const entry = Buffer.alloc(48);
542
+ entry.writeUInt32LE(permissions, 0);
543
+ entry.writeUInt32LE(mode, 4);
544
+ entry.writeUInt32LE(3, 8);
545
+ entry.writeUInt32LE(0, 24);
546
+ entry.writeUInt32LE(0, 28);
547
+ entry.writeUInt32LE(0, 32);
548
+ entry.writeBigUInt64LE(ptrAddress(sidPtr), 40);
549
+ return entry;
550
+ }
551
+ /**
552
+ * One lock file per protected path: `<GetTempPathW()>\dsh-acl-locks\<first 16
553
+ * hex of sha256(lowercased path)>.lock`. The lock root derives from
554
+ * GetTempPathW (never from runner argv or DSH_HOME), and the lowercasing
555
+ * maps Windows's case-insensitive path spellings onto one lock.
556
+ * @param api - the binding table.
557
+ * @param path - the protected directory (absolute).
558
+ * @returns the lock file path for that directory.
559
+ */
560
+ function lockFilePath(api, path) {
561
+ const digest = createHash("sha256").update(path.toLowerCase()).digest("hex").slice(0, 16);
562
+ return join(getTempPath(api), "dsh-acl-locks", `${digest}.lock`);
563
+ }
564
+ /**
565
+ * Run `action` holding the per-path exclusive lock: CreateFileW
566
+ * (OPEN_ALWAYS, shared read/write but NOT delete — a deletable lock file
567
+ * could be removed and recreated under the holder, letting two processes
568
+ * hold "the same" lock), then a one-byte LockFileEx
569
+ * (LOCKFILE_EXCLUSIVE_LOCK, zeroed OVERLAPPED = lock from offset 0 on the
570
+ * synchronous handle — see allocOverlapped for why not NULL), then
571
+ * UnlockFileEx + CloseHandle. Fail-closed: open/lock/unlock/close failures
572
+ * throw like every other Win32 call in this package; an `action` failure
573
+ * still unlocks (best-effort) and rethrows the original error.
574
+ * @param api - the binding table.
575
+ * @param path - the protected directory (absolute).
576
+ * @param action - the get-merge-set sequence to serialize.
577
+ * @returns the action's result.
578
+ */
579
+ function withPathLock(api, path, action) {
580
+ const lockPath = lockFilePath(api, path);
581
+ mkdirSync(dirname(lockPath), { recursive: true });
582
+ const handle = api.createFileW(lockPath, -1073741824, 3, null, 4, 0, null);
583
+ if (isInvalidHandle(handle)) throwLastError(api, "CreateFileW", lockPath);
584
+ const overlapped = allocOverlapped();
585
+ if (api.lockFileEx(handle, 2, 0, 1, 0, overlapped) === 0) {
586
+ const win32Code = api.getLastError();
587
+ api.closeHandle(handle);
588
+ throwWin32(api, "LockFileEx", win32Code, lockPath);
589
+ }
590
+ let result;
591
+ try {
592
+ result = action();
593
+ } catch (error) {
594
+ api.unlockFileEx(handle, 0, 1, 0, overlapped);
595
+ api.closeHandle(handle);
596
+ throw error;
597
+ }
598
+ if (api.unlockFileEx(handle, 0, 1, 0, overlapped) === 0) {
599
+ const win32Code = api.getLastError();
600
+ api.closeHandle(handle);
601
+ throwWin32(api, "UnlockFileEx", win32Code, lockPath);
602
+ }
603
+ if (api.closeHandle(handle) === 0) throwLastError(api, "CloseHandle", `lock file ${lockPath}`);
604
+ return result;
605
+ }
606
+ /**
607
+ * Read the directory's current explicit DACL via GetNamedSecurityInfoW.
608
+ * Allocation contract (the POC's RevokeAccess, minus its missing checks): the
609
+ * returned ACL pointer sits INSIDE the security descriptor allocation — only
610
+ * the descriptor may be LocalFree'd, and it must not be freed before
611
+ * SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself
612
+ * corrupts the heap (verified the hard way).
613
+ * @param api - the binding table.
614
+ * @param path - the directory whose DACL is read.
615
+ * @returns the current explicit DACL (null when the directory carries none) and its owning descriptor.
616
+ */
617
+ function readCurrentDacl(api, path) {
618
+ const ownerSlot = allocPtrSlot();
619
+ const groupSlot = allocPtrSlot();
620
+ const daclSlot = allocPtrSlot();
621
+ const saclSlot = allocPtrSlot();
622
+ const descriptorSlot = allocPtrSlot();
623
+ const readResult = api.getNamedSecurityInfoW(path, 1, 4, ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot);
624
+ if (readResult !== 0) throwWin32(api, "GetNamedSecurityInfoW", readResult, path);
625
+ return {
626
+ oldAcl: decodePtr(daclSlot),
627
+ descriptor: decodePtr(descriptorSlot)
628
+ };
629
+ }
630
+ /**
631
+ * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl`
632
+ * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch),
633
+ * free the descriptor before applying the merged ACL, apply it, then free the
634
+ * merged ACL — checking every call and reporting with the caller's label.
635
+ * @param api - the binding table.
636
+ * @param path - the directory the DACL edit applies to.
637
+ * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke).
638
+ * @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}).
639
+ * @param descriptor - the descriptor allocation owning `oldAcl`.
640
+ * @param label - the caller's name for error details.
641
+ */
642
+ function mergeAndApply(api, path, entry, oldAcl, descriptor, label) {
643
+ const newAclSlot = allocPtrSlot();
644
+ const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot);
645
+ if (mergeResult !== 0) {
646
+ if (descriptor !== null) api.localFree(descriptor);
647
+ throwWin32(api, "SetEntriesInAclW", mergeResult, `${label}(${path})`);
648
+ }
649
+ const newAcl = decodePtr(newAclSlot);
650
+ if (newAcl === null) {
651
+ if (descriptor !== null) api.localFree(descriptor);
652
+ throwWin32(api, "SetEntriesInAclW", api.getLastError(), `${label}(${path}): null new ACL`);
653
+ }
654
+ const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null;
655
+ const applyResult = api.setNamedSecurityInfoW(path, 1, 4, null, null, newAcl, null);
656
+ const freedNew = api.localFree(newAcl);
657
+ if (applyResult !== 0) throwWin32(api, "SetNamedSecurityInfoW", applyResult, `${label}(${path})`);
658
+ if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, "LocalFree", `${label}(${path}) descriptor`);
659
+ if (!isNullPtr(freedNew)) throwLastError(api, "LocalFree", `${label}(${path}) new ACL`);
660
+ }
661
+ /**
662
+ * True when the explicit DACL already carries the EXACT write grant this
663
+ * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the
664
+ * orphan SID). Every field is read through koffi.decode at pointer offsets —
665
+ * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the
666
+ * ACE after the 4-byte mask — there is no pointer to read; reading one
667
+ * yields garbage addresses and crashed EqualSid, verified by gdb), so it is
668
+ * compared field-by-field against the orphan SID through bounded offset
669
+ * reads ({@link sameSidAt}). A malformed header reads as "no exact grant"
670
+ * so the caller falls back to the merge-apply path, which owns the robust
671
+ * failure handling.
672
+ * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}).
673
+ * @param sidPtr - the orphan write SID to match.
674
+ * @returns whether the exact grant ACE is already present.
675
+ */
676
+ function hasExactGrant(oldAcl, sidPtr) {
677
+ const aclSize = decodeUint16At(oldAcl, 2);
678
+ const aceCount = decodeUint16At(oldAcl, 4);
679
+ if (aclSize < 8 || aclSize > 1048576) return false;
680
+ let offset = 8;
681
+ for (let index = 0; index < aceCount; index++) {
682
+ const aceSize = decodeUint16At(oldAcl, offset + 2);
683
+ if (aceSize < 8 || offset + aceSize > aclSize) return false;
684
+ if (decodeUint8At(oldAcl, offset) === 0 && decodeUint8At(oldAcl, offset + 1) === 3 && decodeUint32At(oldAcl, offset + 4) === 1114454 && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true;
685
+ offset += aceSize;
686
+ }
687
+ return false;
688
+ }
689
+ /**
690
+ * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID
691
+ * on `path`, inheriting to subcontainers and objects. Idempotent: when the
692
+ * directory's current explicit DACL already carries the exact ACE (the
693
+ * per-session grant surviving from a previous server lifetime), the
694
+ * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate
695
+ * the identical ACE across the whole tree (eager inheritance; minutes on
696
+ * large workspaces). Otherwise read-merge-write: the new ACE merges into the
697
+ * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so
698
+ * pre-existing explicit ACEs survive. Runs under the per-path lock. The
699
+ * directory must be owned by the caller (owner implicit WRITE_DAC) — same
700
+ * precondition as the POC.
701
+ * @param api - the binding table.
702
+ * @param path - the directory whose DACL gains the grant (the workspace or temp root).
703
+ * @param sidPtr - the orphan write SID the ACE names.
704
+ */
705
+ function grantWrite(api, path, sidPtr) {
706
+ withPathLock(api, path, () => {
707
+ const { oldAcl, descriptor } = readCurrentDacl(api, path);
708
+ if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) {
709
+ if (descriptor !== null) {
710
+ if (!isNullPtr(api.localFree(descriptor))) throwLastError(api, "LocalFree", `grantWrite(${path}) descriptor`);
711
+ }
712
+ return;
713
+ }
714
+ mergeAndApply(api, path, buildExplicitAccess(sidPtr, 1, GRANT_MASK), oldAcl, descriptor, "grantWrite");
715
+ });
716
+ }
717
+ /**
718
+ * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS
719
+ * merge — other entries are preserved). Returns whether an ACE removal was
720
+ * attempted (false when the directory carries no DACL at all).
721
+ *
722
+ * Runs under the per-path lock (the whole get-merge-set sequence); the
723
+ * descriptor/ACL allocation contract lives on {@link readCurrentDacl}.
724
+ * @param api - the binding table.
725
+ * @param path - the directory whose DACL loses the orphan-SID ACEs.
726
+ * @param sidPtr - the orphan write SID whose ACEs are removed.
727
+ * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all).
728
+ */
729
+ function revokeWrite(api, path, sidPtr) {
730
+ return withPathLock(api, path, () => {
731
+ const { oldAcl, descriptor } = readCurrentDacl(api, path);
732
+ if (oldAcl === null) {
733
+ if (descriptor !== null) {
734
+ if (!isNullPtr(api.localFree(descriptor))) throwLastError(api, "LocalFree", `revokeWrite(${path}) descriptor`);
735
+ }
736
+ return false;
737
+ }
738
+ mergeAndApply(api, path, buildExplicitAccess(sidPtr, 4, 0), oldAcl, descriptor, "revokeWrite");
739
+ return true;
740
+ });
741
+ }
742
+ //#endregion
743
+ //#region lib/types/spawn.js
744
+ /**
745
+ * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with
746
+ * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then
747
+ * asynchronous pipe draining and exit waiting. Console isolation
748
+ * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this
749
+ * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED
750
+ * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is
751
+ * pipe-based and unaffected; the child shares the host console.
752
+ * @module @deepseek-ai/dsh-sandbox-windows-acl/spawn
753
+ */
754
+ /**
755
+ * Quote one argument per the CommandLineToArgvW parsing rules: backslashes
756
+ * are doubled only before a quote character — including the closing quote
757
+ * this function appends, so a trailing backslash run is doubled as well
758
+ * (otherwise an odd run would escape the closing quote into a literal
759
+ * character and corrupt the rest of the command line). Mirrors the CRT
760
+ * ArgvQuote behavior Microsoft documents for command-line arguments.
761
+ * @param argument - one argv entry to quote.
762
+ * @returns the quoted entry (bare when quoting is unnecessary).
763
+ */
764
+ function quoteArg(argument) {
765
+ if (argument === "") return "\"\"";
766
+ if (!/[\s"]/u.test(argument)) return argument;
767
+ let quoted = "\"";
768
+ for (let index = 0; index < argument.length; index++) {
769
+ let backslashes = 0;
770
+ while (index < argument.length && argument.charAt(index) === "\\") {
771
+ backslashes++;
772
+ index++;
773
+ }
774
+ if (index === argument.length) quoted += "\\".repeat(backslashes * 2);
775
+ else if (argument.charAt(index) === "\"") quoted += "\\".repeat(backslashes * 2 + 1) + "\"";
776
+ else quoted += "\\".repeat(backslashes) + argument.charAt(index);
777
+ }
778
+ return quoted + "\"";
779
+ }
780
+ /**
781
+ * Build the single command line CreateProcess parses from program + argv.
782
+ * @param program - the executable (argv[0]).
783
+ * @param args - the remaining argv entries.
784
+ * @returns the joined, quoted command line.
785
+ */
786
+ function buildCommandLine(program, args) {
787
+ return [program, ...args].map(quoteArg).join(" ");
788
+ }
789
+ function createPipe(api) {
790
+ const readSlot = allocPtrSlot();
791
+ const writeSlot = allocPtrSlot();
792
+ if (api.createPipe(readSlot, writeSlot, null, 0) === 0) throwLastError(api, "CreatePipe");
793
+ const read = decodePtr(readSlot);
794
+ const write = decodePtr(writeSlot);
795
+ if (read === null || write === null) throwLastError(api, "CreatePipe", "null pipe handle");
796
+ return {
797
+ read,
798
+ write
799
+ };
800
+ }
801
+ function setInheritable(api, handle, label) {
802
+ if (api.setHandleInformation(handle, 1, 1) === 0) throwLastError(api, "SetHandleInformation", label);
803
+ }
804
+ /**
805
+ * Create a process under the restricted token with piped stdio. The child's
806
+ * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends
807
+ * are returned for draining. The child inherits the caller's environment block
808
+ * (lpEnvironment NULL); the caller rewrites entries through
809
+ * SetEnvironmentVariableW before spawning (the runner's per-session temp
810
+ * contract) — passing an explicit block through koffi trips
811
+ * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically).
812
+ * @param api - the binding table.
813
+ * @param token - the restricted token the child runs under.
814
+ * @param options - command, args, and working directory.
815
+ * @returns the spawned child's handles.
816
+ */
817
+ function spawnSandboxed(api, token, options) {
818
+ const stdIn = createPipe(api);
819
+ const stdOut = createPipe(api);
820
+ const stdErr = createPipe(api);
821
+ setInheritable(api, stdIn.read, "stdin read end");
822
+ setInheritable(api, stdOut.write, "stdout write end");
823
+ setInheritable(api, stdErr.write, "stderr write end");
824
+ const startupInfo = allocStartupInfo();
825
+ encodeStartupInfo(startupInfo, {
826
+ cb: 104,
827
+ dwFlags: 256,
828
+ hStdInput: stdIn.read,
829
+ hStdOutput: stdOut.write,
830
+ hStdError: stdErr.write
831
+ });
832
+ const processInfo = allocProcessInfo();
833
+ const commandLine = buildCommandLine(options.command, options.args);
834
+ if (api.createProcessAsUserW(token, null, commandLine, null, null, 1, 0, null, options.cwd, startupInfo, processInfo) === 0) {
835
+ const win32Code = api.getLastError();
836
+ api.closeHandle(stdIn.read);
837
+ api.closeHandle(stdIn.write);
838
+ api.closeHandle(stdOut.read);
839
+ api.closeHandle(stdOut.write);
840
+ api.closeHandle(stdErr.read);
841
+ api.closeHandle(stdErr.write);
842
+ throwWin32(api, "CreateProcessAsUserW", win32Code, `command: ${options.command}, cwd: ${options.cwd}`);
843
+ }
844
+ const info = decodeProcessInfo(processInfo);
845
+ const processHandle = info.hProcess;
846
+ const threadHandle = info.hThread;
847
+ if (processHandle === null || threadHandle === null) throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`);
848
+ api.closeHandle(stdIn.read);
849
+ api.closeHandle(stdOut.write);
850
+ api.closeHandle(stdErr.write);
851
+ api.closeHandle(stdIn.write);
852
+ api.closeHandle(threadHandle);
853
+ return {
854
+ pid: info.dwProcessId,
855
+ process: processHandle,
856
+ stdoutRead: stdOut.read,
857
+ stderrRead: stdErr.read
858
+ };
859
+ }
860
+ /**
861
+ * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling.
862
+ * @param api - the binding table.
863
+ * @param handle - the pipe read end to drain (closed when done).
864
+ * @returns the complete pipe contents.
865
+ */
866
+ async function drainPipe(api, handle) {
867
+ const chunks = [];
868
+ for (;;) {
869
+ const bytesReadSlot = allocUint32();
870
+ const totalAvailSlot = allocUint32();
871
+ const leftThisMessageSlot = allocUint32();
872
+ if (api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot) === 0) {
873
+ const win32Code = api.getLastError();
874
+ if (win32Code === 109 || win32Code === 232) break;
875
+ throwLastError(api, "PeekNamedPipe", `drain failure after ${chunks.length} chunk(s)`);
876
+ }
877
+ const available = decodeUint32(totalAvailSlot);
878
+ if (available > 0) {
879
+ const chunk = Buffer.alloc(available);
880
+ const readSlot = allocUint32();
881
+ if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) throwLastError(api, "ReadFile", `drain failure after ${chunks.length} chunk(s)`);
882
+ chunks.push(chunk.subarray(0, decodeUint32(readSlot)));
883
+ }
884
+ await new Promise((resolve) => setTimeout(resolve, 1));
885
+ }
886
+ api.closeHandle(handle);
887
+ return Buffer.concat(chunks);
888
+ }
889
+ /**
890
+ * Wait for process exit and return its exit code. Call only after both drains
891
+ * have resolved — the drains finish when the child closed its pipe ends, i.e.
892
+ * the child has already exited, so this wait returns immediately. Calling it
893
+ * earlier would block the event loop and starve the drains (the pipe-buffer
894
+ * deadlock the POC comments warn about).
895
+ * @param api - the binding table.
896
+ * @param process - the child process handle (closed when done).
897
+ * @returns the child's exit code.
898
+ */
899
+ function waitForExit(api, process) {
900
+ if (api.waitForSingleObject(process, 4294967295) === 4294967295) throwLastError(api, "WaitForSingleObject");
901
+ const exitCodeSlot = allocUint32();
902
+ if (api.getExitCodeProcess(process, exitCodeSlot) === 0) throwLastError(api, "GetExitCodeProcess");
903
+ api.closeHandle(process);
904
+ return decodeUint32(exitCodeSlot);
905
+ }
906
+ /**
907
+ * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at
908
+ * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout
909
+ * verified by abi-probe.cpp). When the caller dies with the job handle open,
910
+ * Windows terminates every process in the job — the orphan-child backstop.
911
+ * The caller keeps the returned handle open for the child's lifetime.
912
+ */
913
+ function createKillOnCloseJob(api) {
914
+ const job = api.createJobObjectW(null, null);
915
+ if (isNullPtr(job)) throwLastError(api, "CreateJobObjectW");
916
+ const information = Buffer.alloc(144);
917
+ information.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
918
+ if (api.setInformationJobObject(job, 9, information, information.length) === 0) {
919
+ const win32Code = api.getLastError();
920
+ api.closeHandle(job);
921
+ throwWin32(api, "SetInformationJobObject", win32Code);
922
+ }
923
+ return job;
924
+ }
925
+ /**
926
+ * Create a process under the restricted token whose stdio passes straight
927
+ * through to the caller's pipes. This is the runner shape: the harness spawns
928
+ * the runner with piped stdio, and the runner's confined child writes to
929
+ * those same pipes.
930
+ *
931
+ * Node clears the inheritability of its stdio handles at startup
932
+ * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit
933
+ * bit around the call (libuv instead duplicates the handles; re-enabling is
934
+ * equivalent here and cheaper) and pass them explicitly via
935
+ * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles
936
+ * ("The handle is invalid", verified the hard way). The child starts
937
+ * suspended so it can be assigned to a kill-on-close job before it runs.
938
+ * @param api - the binding table.
939
+ * @param token - the restricted token the child runs under.
940
+ * @param options - command, args, and working directory.
941
+ * @returns the spawned child's handles and job.
942
+ */
943
+ function spawnSandboxedInherited(api, token, options) {
944
+ const job = createKillOnCloseJob(api);
945
+ const stdIn = api.getStdHandle(-10);
946
+ const stdOut = api.getStdHandle(-11);
947
+ const stdErr = api.getStdHandle(-12);
948
+ if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) {
949
+ api.closeHandle(job);
950
+ throwLastError(api, "GetStdHandle", "null standard handle");
951
+ }
952
+ const makeInheritable = (handle, label) => {
953
+ if (api.setHandleInformation(handle, 1, 1) === 0) throwLastError(api, "SetHandleInformation", `${label} (enable inherit)`);
954
+ };
955
+ const restoreInherit = (handle) => {
956
+ api.setHandleInformation(handle, 1, 0);
957
+ };
958
+ makeInheritable(stdIn, "stdin");
959
+ makeInheritable(stdOut, "stdout");
960
+ makeInheritable(stdErr, "stderr");
961
+ const startupInfo = allocStartupInfo();
962
+ encodeStartupInfo(startupInfo, {
963
+ cb: 104,
964
+ dwFlags: 256,
965
+ hStdInput: stdIn,
966
+ hStdOutput: stdOut,
967
+ hStdError: stdErr
968
+ });
969
+ const processInfo = allocProcessInfo();
970
+ const commandLine = buildCommandLine(options.command, options.args);
971
+ const created = api.createProcessAsUserW(token, null, commandLine, null, null, 1, 4, null, options.cwd, startupInfo, processInfo);
972
+ restoreInherit(stdIn);
973
+ restoreInherit(stdOut);
974
+ restoreInherit(stdErr);
975
+ if (created === 0) {
976
+ const win32Code = api.getLastError();
977
+ api.closeHandle(job);
978
+ throwWin32(api, "CreateProcessAsUserW", win32Code, `command: ${options.command}, cwd: ${options.cwd}`);
979
+ }
980
+ const info = decodeProcessInfo(processInfo);
981
+ const processHandle = info.hProcess;
982
+ const threadHandle = info.hThread;
983
+ if (processHandle === null || threadHandle === null) {
984
+ api.closeHandle(job);
985
+ throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`);
986
+ }
987
+ if (api.assignProcessToJobObject(job, processHandle) === 0) {
988
+ const win32Code = api.getLastError();
989
+ api.terminateProcess(processHandle, 1);
990
+ api.closeHandle(threadHandle);
991
+ api.closeHandle(processHandle);
992
+ api.closeHandle(job);
993
+ throwWin32(api, "AssignProcessToJobObject", win32Code, `pid ${info.dwProcessId}`);
994
+ }
995
+ if (api.resumeThread(threadHandle) === 4294967295) {
996
+ const win32Code = api.getLastError();
997
+ api.closeHandle(threadHandle);
998
+ api.closeHandle(processHandle);
999
+ api.closeHandle(job);
1000
+ throwWin32(api, "ResumeThread", win32Code, `pid ${info.dwProcessId}`);
1001
+ }
1002
+ api.closeHandle(threadHandle);
1003
+ return {
1004
+ pid: info.dwProcessId,
1005
+ process: processHandle,
1006
+ job
1007
+ };
1008
+ }
1009
+ //#endregion
1010
+ //#region lib/types/token.js
1011
+ /**
1012
+ * Restricted-token construction: open the current process token, extract its
1013
+ * logon SID, build the well-known SIDs, and call CreateRestrictedToken with
1014
+ * the POC's restricting-SID allowlist. Every API call is checked; any failure
1015
+ * throws with the API name and the exact Win32 code — the original POC ignored
1016
+ * all of these and silently ran children with the FULL, unrestricted token.
1017
+ * @module @deepseek-ai/dsh-sandbox-windows-acl/token
1018
+ */
1019
+ /**
1020
+ * Open the current process's access token with the rights
1021
+ * CreateRestrictedToken requires (the POC's OpenProcessToken call; the token
1022
+ * handle is obtained through a real OpenProcess handle because the
1023
+ * GetCurrentProcess() pseudo-handle is not addressable through koffi).
1024
+ * @param api - the binding table.
1025
+ * @returns the opened token handle.
1026
+ */
1027
+ function openCurrentProcessToken(api) {
1028
+ const processHandle = api.openProcess(PROCESS_QUERY_INFORMATION, 0, process.pid);
1029
+ if (isNullPtr(processHandle)) throwLastError(api, "OpenProcess", `pid ${process.pid}`);
1030
+ const tokenSlot = allocPtrSlot();
1031
+ if (api.openProcessToken(processHandle, 139, tokenSlot) === 0) {
1032
+ const win32Code = api.getLastError();
1033
+ api.closeHandle(processHandle);
1034
+ throwWin32(api, "OpenProcessToken", win32Code, `pid ${process.pid}`);
1035
+ }
1036
+ if (api.closeHandle(processHandle) === 0) throwLastError(api, "CloseHandle", "OpenProcess process handle");
1037
+ const token = decodePtr(tokenSlot);
1038
+ if (token === null) throwWin32(api, "OpenProcessToken", api.getLastError(), "null token handle");
1039
+ return token;
1040
+ }
1041
+ /**
1042
+ * Find and copy the token's logon session SID (S-1-5-5-x-y, attribute
1043
+ * SE_GROUP_LOGON_ID). The restricted token needs it for WinSta0/desktop and
1044
+ * other per-logon objects; the POC extracts it the same way.
1045
+ * @param api - the binding table.
1046
+ * @param token - the token whose groups are scanned.
1047
+ * @returns a copied logon SID (thrown when the token carries none).
1048
+ */
1049
+ function findLogonSid(api, token) {
1050
+ const neededSlot = allocUint32();
1051
+ api.getTokenInformation(token, 2, null, 0, neededSlot);
1052
+ const needed = decodeUint32(neededSlot);
1053
+ if (needed === 0) throwLastError(api, "GetTokenInformation", "TokenGroups size query");
1054
+ if (needed < 8) throwWin32(api, "GetTokenInformation", api.getLastError(), `implausible TokenGroups size ${needed}`);
1055
+ const groups = Buffer.alloc(needed);
1056
+ if (api.getTokenInformation(token, 2, groups, groups.length, neededSlot) === 0) throwLastError(api, "GetTokenInformation", "TokenGroups");
1057
+ const groupCount = groups.readUInt32LE(0);
1058
+ for (let index = 0; index < groupCount; index++) {
1059
+ const sidPtr = decodePtrAt(groups, 8 + index * 16);
1060
+ const isLogonId = (groups.readUInt32LE(8 + index * 16 + 8) & SE_GROUP_LOGON_ID) >>> 0 === SE_GROUP_LOGON_ID >>> 0;
1061
+ if (sidPtr === null || !isLogonId) continue;
1062
+ const sidLength = api.getLengthSid(sidPtr);
1063
+ if (sidLength === 0) throwLastError(api, "GetLengthSid", `logon SID group ${index}`);
1064
+ const copy = allocBytes(sidLength);
1065
+ if (api.copySid(sidLength, copy, sidPtr) === 0) throwLastError(api, "CopySid", `logon SID group ${index}`);
1066
+ return copy;
1067
+ }
1068
+ throw new Error(`CreateRestrictedToken prerequisite failed: no logon SID found among ${groupCount} token groups`);
1069
+ }
1070
+ /**
1071
+ * Create one well-known SID (68-byte buffer) and assert its validity.
1072
+ * @param api - the binding table.
1073
+ * @param type - the WELL_KNOWN_SID_TYPE to create.
1074
+ * @returns the created SID pointer.
1075
+ */
1076
+ function makeWellKnownSid(api, type) {
1077
+ const sid = allocBytes(68);
1078
+ const sizeSlot = allocUint32();
1079
+ encodeUint32(sizeSlot, 68);
1080
+ if (api.createWellKnownSid(type, null, sid, sizeSlot) === 0) throwLastError(api, "CreateWellKnownSid", `type ${type}`);
1081
+ if (api.isValidSid(sid) === 0) throwLastError(api, "IsValidSid", `CreateWellKnownSid type ${type}`);
1082
+ return sid;
1083
+ }
1084
+ /**
1085
+ * Merge one full-access allow ACE for `sidPtr` into the token's DEFAULT DACL
1086
+ * — the DACL every NEW object the token holder creates (without an explicit
1087
+ * security descriptor) takes. The restricted token inherits the user's
1088
+ * default DACL verbatim, which names no restricting SID: a new anonymous pipe
1089
+ * (child stdio) therefore fails the write pass-2 check at creation
1090
+ * (ERROR_ACCESS_DENIED; Node surfaces it as spawn EPERM), breaking every
1091
+ * piped-stdio grandchild spawn. The merged ACE names a RESTRICTING SID (the
1092
+ * write SID under workspace-write, Everyone under read-only), so each new
1093
+ * object's own DACL passes pass-2 while object creation itself stays gated by
1094
+ * the parent container's DACL (files outside the granted trees remain
1095
+ * uncreatable). Fails closed: any Win32 failure throws before the spawn.
1096
+ * @param api - the binding table.
1097
+ * @param token - the restricted token to adjust (requires TOKEN_ADJUST_DEFAULT).
1098
+ * @param sidPtr - the restricting SID whose full-access ACE joins the default DACL.
1099
+ */
1100
+ function setTokenDefaultDaclGrant(api, token, sidPtr) {
1101
+ const neededSlot = allocUint32();
1102
+ api.getTokenInformation(token, 6, null, 0, neededSlot);
1103
+ const needed = decodeUint32(neededSlot);
1104
+ if (needed === 0) throwLastError(api, "GetTokenInformation", "TokenDefaultDacl size query");
1105
+ const buffer = Buffer.alloc(needed);
1106
+ if (api.getTokenInformation(token, 6, buffer, buffer.length, neededSlot) === 0) throwLastError(api, "GetTokenInformation", "TokenDefaultDacl");
1107
+ const currentDacl = decodePtrAt(buffer, 0);
1108
+ if (currentDacl === null) throw new Error("setTokenDefaultDaclGrant: the token carries no default DACL to extend");
1109
+ const newDaclSlot = allocPtrSlot();
1110
+ const result = api.setEntriesInAclW(1, buildExplicitAccess(sidPtr, 1, FILE_ALL_ACCESS), currentDacl, newDaclSlot);
1111
+ if (result !== 0) throwWin32(api, "SetEntriesInAclW", result, "default DACL merge");
1112
+ const newDacl = decodePtr(newDaclSlot);
1113
+ if (newDacl === null) throwWin32(api, "SetEntriesInAclW", result, "null merged default DACL");
1114
+ const info = Buffer.alloc(8);
1115
+ info.writeBigUInt64LE(newDacl, 0);
1116
+ if (api.setTokenInformation(token, 6, info, info.length) === 0) {
1117
+ const win32Code = api.getLastError();
1118
+ api.localFree(newDacl);
1119
+ throwWin32(api, "SetTokenInformation", win32Code, "TokenDefaultDacl");
1120
+ }
1121
+ api.localFree(newDacl);
1122
+ }
1123
+ /** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */
1124
+ function buildRestrictingSids(sids) {
1125
+ const buffer = Buffer.alloc(16 * sids.length);
1126
+ sids.forEach((sid, index) => {
1127
+ buffer.writeBigUInt64LE(ptrAddress(sid), 16 * index);
1128
+ });
1129
+ return buffer;
1130
+ }
1131
+ /**
1132
+ * Create the write-restricted token with the mode-selected restricting list
1133
+ * (verified on Win11 26200, see the POC-worktree restrict-variant harness):
1134
+ * - read-only: [logon SID, EVERYONE]
1135
+ * - workspace-write: [logon SID, EVERYONE, orphan]
1136
+ *
1137
+ * The logon SID + EVERYONE keep-alive group is shared by both modes: early
1138
+ * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee —
1139
+ * pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY
1140
+ * workspace-write — read-only carries no write SID, so a standing grant ACE
1141
+ * from an earlier workspace-write period (a `/permission` mode downgrade, or
1142
+ * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED
1143
+ * pass-2 check grants only what the restricting list carries, keeping
1144
+ * read-only strictly zero-grant even with stale ACEs standing, while the
1145
+ * unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no
1146
+ * re-propagation). Authenticated Users is absent from BOTH lists: the WMI
1147
+ * namespace security check fails (0x80041003), so CIM is unavailable in
1148
+ * every confined mode, and the C:\-root tree-creation escape (standing
1149
+ * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in
1150
+ * README. INTERACTIVE/LOCAL are absent from BOTH lists too — the host's
1151
+ * Public tree grants write to INTERACTIVE, so removing it closes that
1152
+ * escape. S-1-2-1 (console logon) is intentionally absent: see win32-abi.ts
1153
+ * for the verified failure modes. FAILS CLOSED: any failure throws — never
1154
+ * spawn unrestricted.
1155
+ * @param api - the binding table.
1156
+ * @param currentToken - the process token to restrict.
1157
+ * @param logonSid - the copied logon session SID.
1158
+ * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only).
1159
+ * @param known - the well-known SIDs entering the restricting list.
1160
+ * @param mode - selects the restricting list (workspace-write adds the write SID).
1161
+ * @returns the restricted token handle.
1162
+ */
1163
+ function createRestrictedToken(api, currentToken, logonSid, writeSid, known, mode) {
1164
+ const restrictingSids = buildRestrictingSids(mode === "read-only" ? [logonSid, known.world] : writeSid === void 0 ? (() => {
1165
+ throw new Error("createRestrictedToken: workspace-write restricting list requires the write SID");
1166
+ })() : [
1167
+ logonSid,
1168
+ known.world,
1169
+ writeSid
1170
+ ]);
1171
+ const tokenSlot = allocPtrSlot();
1172
+ if (api.createRestrictedToken(currentToken, 13, 0, null, 0, null, restrictingSids.length / 16, restrictingSids, tokenSlot) === 0) throwLastError(api, "CreateRestrictedToken", `restricting SIDs: ${restrictingSids.length / 16}`);
1173
+ const token = decodePtr(tokenSlot);
1174
+ if (token === null) throwWin32(api, "CreateRestrictedToken", api.getLastError(), "null token handle");
1175
+ return token;
1176
+ }
1177
+ //#endregion
1178
+ //#region lib/types/grant.js
1179
+ /**
1180
+ * Server-side per-session write grant: the ACE materialization half of the
1181
+ * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE
1182
+ * {@link AclWriteGrant} per session for the server process's lifetime —
1183
+ * created lazily at the session's first confined execution, reused (never
1184
+ * re-applied) for every later call, revoked on provider dispose. The durable
1185
+ * half (the session's SID and paths surviving a restart) lives in the
1186
+ * session log, owned by the seam; this module owns only the native half: the
1187
+ * parsed SID pointer and the standing ACEs.
1188
+ *
1189
+ * Fail-closed: `add` throws on any grant failure and the caller disposes the
1190
+ * instance (revoking every path granted so far); `dispose` revokes every
1191
+ * standing grant and reports every cleanup failure.
1192
+ * @module @deepseek-ai/dsh-sandbox-windows-acl/grant
1193
+ */
1194
+ /**
1195
+ * One write SID's server-lifetime grant materialization: the parsed SID
1196
+ * pointer plus every directory whose DACL currently carries its ACE.
1197
+ * Workspace paths are added STANDING (their ACEs are the cross-session reuse
1198
+ * cache and outlive the grant — dispose() skips revoking them, or the next
1199
+ * provision would re-propagate the whole tree); temp paths are revocable
1200
+ * (dispose() revokes them — an inheritable ACE must not outlive its
1201
+ * session's temp directory). Create with {@link AclWriteGrant.create};
1202
+ * dispose revokes the revocable paths and frees the SID.
1203
+ */
1204
+ var AclWriteGrant = class AclWriteGrant {
1205
+ /** The write SID in SDDL string form. */
1206
+ writeSid;
1207
+ api;
1208
+ sidPtr;
1209
+ revocablePaths = [];
1210
+ standingPaths = [];
1211
+ constructor(api, sidPtr, writeSid) {
1212
+ this.api = api;
1213
+ this.sidPtr = sidPtr;
1214
+ this.writeSid = writeSid;
1215
+ }
1216
+ /**
1217
+ * Parse the SID string and open the binding table (lazily, once per
1218
+ * server). Fail-closed: any failure throws — nothing is granted yet.
1219
+ * @param writeSid - the orphan write SID string (`S-1-4-x-y`).
1220
+ * @param api - optional already-resolved bindings (tests).
1221
+ * @returns the ready grant (no ACEs yet).
1222
+ */
1223
+ static create(writeSid, api) {
1224
+ const bindings = api ?? win32Sync();
1225
+ const sidSlot = allocPtrSlot();
1226
+ if (bindings.convertStringSidToSidW(writeSid, sidSlot) === 0) throwLastError(bindings, "ConvertStringSidToSidW", writeSid);
1227
+ const sidPtr = decodePtr(sidSlot);
1228
+ if (sidPtr === null) throwLastError(bindings, "ConvertStringSidToSidW", `null SID for ${writeSid}`);
1229
+ return new AclWriteGrant(bindings, sidPtr, writeSid);
1230
+ }
1231
+ /**
1232
+ * Grant the write ACE on one directory (idempotent: an already-standing
1233
+ * exact ACE skips the eager full-tree re-propagation — see
1234
+ * {@link grantWrite}) and record the path for {@link dispose} unless it is
1235
+ * standing. The path is recorded BEFORE the grant: a post-apply throw (a
1236
+ * LocalFree failure after SetNamedSecurityInfoW succeeded) must still
1237
+ * revoke it, and revoking an ungranted path is a no-op merge. Callers
1238
+ * treat a throw as a failed materialization and dispose the instance to
1239
+ * revoke the paths granted so far.
1240
+ * @param path - the directory whose DACL gains the grant.
1241
+ * @param standing - the ACE outlives this grant (the workspace reuse
1242
+ * cache; dispose() skips revoking it). Default false (revoked on
1243
+ * dispose — the temp-directory lifecycle).
1244
+ */
1245
+ add(path, standing = false) {
1246
+ (standing ? this.standingPaths : this.revocablePaths).push(path);
1247
+ grantWrite(this.api, path, this.sidPtr);
1248
+ }
1249
+ /** Every directory currently carrying the grant, in grant order. */
1250
+ get paths() {
1251
+ return [...this.standingPaths, ...this.revocablePaths];
1252
+ }
1253
+ /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */
1254
+ dispose() {
1255
+ const failures = [];
1256
+ for (const path of this.revocablePaths) try {
1257
+ revokeWrite(this.api, path, this.sidPtr);
1258
+ } catch (error) {
1259
+ failures.push(error);
1260
+ }
1261
+ try {
1262
+ if (!isNullPtr(this.api.localFree(this.sidPtr))) throwLastError(this.api, "LocalFree", "write SID");
1263
+ } catch (error) {
1264
+ failures.push(error);
1265
+ }
1266
+ if (failures.length > 0) throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`);
1267
+ }
1268
+ };
1269
+ //#endregion
1270
+ //#region lib/types/workspace-sid.js
1271
+ /**
1272
+ * The per-workspace write identity: a deterministic `S-1-4-x-y` SID derived
1273
+ * from the canonical workspace path, whose ACEs form that workspace's write
1274
+ * allowlist. Every confined execution of the same workspace — across
1275
+ * sessions, server restarts, and calls — carries the SAME write SID, so the
1276
+ * workspace-root ACE materializes once per workspace per machine (the
1277
+ * grant's exact-ACE skip then makes every later provision O(1)) instead of
1278
+ * once per session. The SID's power is defined solely by the ACEs that name
1279
+ * it (which exist only on the workspace tree and the session's private temp
1280
+ * directory), and only tokens minted for that workspace carry it — the SID
1281
+ * string itself is not a secret (the previous per-session SID was likewise
1282
+ * logged in the plain).
1283
+ *
1284
+ * The input MUST be the canonical workspace path (`realpathSync.native` on
1285
+ * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it):
1286
+ * canonicalization converges case/alias spellings, so two spellings of one
1287
+ * workspace derive one SID; an as-spelled fallback path would mint a second
1288
+ * identity for the same directory (self-healing, at the cost of one extra
1289
+ * tree propagation). Renaming the workspace directory derives a new SID —
1290
+ * the old standing ACEs are inert residue, and the next session re-propagates
1291
+ * once.
1292
+ * @module @deepseek-ai/dsh-sandbox-windows-acl/workspace-sid
1293
+ */
1294
+ /**
1295
+ * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit,
1296
+ * matching the orphan shape the token and ACE layers already carry).
1297
+ * @param workspaceRoot - the canonical workspace path.
1298
+ * @returns the SDDL string form.
1299
+ */
1300
+ function workspaceWriteSid(workspaceRoot) {
1301
+ const digest = createHash("sha256").update(workspaceRoot, "utf8").digest();
1302
+ return `S-1-4-${digest.readUInt32LE(0) % (2 ** 30 - 1) + 1}-${digest.readUInt32LE(4) % (2 ** 30 - 1) + 1}`;
1303
+ }
1304
+ //#endregion
1305
+ //#region lib/types/index.js
1306
+ /**
1307
+ * Windows ACL write-restriction sandbox backend for the DeepSeek Harness
1308
+ * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/
1309
+ * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED
1310
+ * token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only
1311
+ * this sandbox adds to the target directories' DACLs — the intersection
1312
+ * check then allows writes exactly where that SID has a Write ACE, and
1313
+ * nowhere else the write SID is concerned (the token's write check ALSO
1314
+ * inherits the ambient write ACEs of the other restricting SIDs — the
1315
+ * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE,
1316
+ * and LOCAL are absent from both lists — see the seam's dual-list contract
1317
+ * in `packages/sandbox/sandbox-local` and the package README's Modes section
1318
+ * for the complete boundary). The write SID is the per-WORKSPACE identity
1319
+ * ({@link workspaceWriteSid}): deterministic from the canonical workspace
1320
+ * path, so the workspace-root ACE materializes once per workspace per
1321
+ * machine and every later provision hits the exact-ACE skip — the
1322
+ * grant-reuse story the per-session random SID paid a full tree propagation
1323
+ * per session for. Unlike the POC, every API failure throws with the API
1324
+ * name and exact Win32 code; a child is NEVER spawned unrestricted.
1325
+ *
1326
+ * Known boundaries (inherent to restricted tokens, not this port):
1327
+ * - writes are restricted; reads, network, and process visibility are NOT
1328
+ * (WRITE_RESTRICTED intersects only write accesses);
1329
+ * - console isolation is unavailable — children share the host console
1330
+ * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with
1331
+ * STATUS_DLL_INIT_FAILED under the restriction);
1332
+ * - the temp directory and every writable directory must be owned by the
1333
+ * caller (owner-implicit WRITE_DAC);
1334
+ * - grants are standing ACE mutations on real directories. WORKSPACE grants
1335
+ * are deliberately never revoked — the ACE is the cross-session reuse
1336
+ * cache (revoking would force the next session to re-propagate the whole
1337
+ * tree). TEMP grants are revocable: dispose() removes them so a standing
1338
+ * inheritable ACE never outlives its session's temp directory (an
1339
+ * inheritable ACE on the ambient temp root would otherwise widen the
1340
+ * SID's write reach to every future temp file). With `manageDacls: false`
1341
+ * the CALLER owns the DACLs (the sandbox seam's grant reuse):
1342
+ * init()/dispose() skip grant/revoke entirely and the caller must not
1343
+ * revoke under live children.
1344
+ * @module @deepseek-ai/dsh-sandbox-windows-acl
1345
+ */
1346
+ /**
1347
+ * One write-restricted sandbox instance: token + write-SID grants + spawn.
1348
+ * `init()` is fail-closed — any Win32 failure revokes the revocable (temp)
1349
+ * grants and throws; `dispose()` revokes the temp grants, leaves the
1350
+ * standing workspace ACEs in place (the cross-instance reuse cache), frees
1351
+ * every allocation, and reports every cleanup failure. With
1352
+ * `manageDacls: false` the caller owns the grants (the sandbox seam's grant
1353
+ * reuse): init() applies none and dispose() revokes none.
1354
+ */
1355
+ var AclSandbox = class {
1356
+ /** Absolute writable directories (constructor-validated). */
1357
+ writableDirs;
1358
+ /** The write SID string whose ACEs form the write allowlist (workspace-write only). */
1359
+ writeSid;
1360
+ /** The file-effect mode — the restricted token's restricting-SID list selection. */
1361
+ mode;
1362
+ tempDirOption;
1363
+ manageDacls;
1364
+ tempDirResolved;
1365
+ api;
1366
+ token;
1367
+ writeSidPtr;
1368
+ /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */
1369
+ sidAllocations = [];
1370
+ grantedPaths = [];
1371
+ constructor(options) {
1372
+ this.mode = options.mode;
1373
+ this.manageDacls = options.manageDacls ?? true;
1374
+ this.writableDirs = options.writableDirs.map((directory) => {
1375
+ const absolute = resolve(directory);
1376
+ if (!existsSync(absolute) || !statSync(absolute).isDirectory()) throw new Error(`AclSandbox writable dir does not exist or is not a directory: ${absolute}`);
1377
+ return absolute;
1378
+ });
1379
+ this.tempDirOption = options.tempDir;
1380
+ this.writeSid = options.writeSid;
1381
+ if (this.mode === "workspace-write" && this.writeSid === void 0) throw new Error("AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()");
1382
+ }
1383
+ /** Resolved temp directory (available after init; null when temp grants are disabled). */
1384
+ get tempDir() {
1385
+ return this.tempDirResolved;
1386
+ }
1387
+ /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */
1388
+ async init() {
1389
+ if (this.api !== void 0) throw new Error("AclSandbox is already initialized");
1390
+ const api = await win32();
1391
+ const currentToken = openCurrentProcessToken(api);
1392
+ try {
1393
+ let writeSidPtr;
1394
+ if (this.writeSid !== void 0) {
1395
+ const sidSlot = allocPtrSlot();
1396
+ if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) throwLastError(api, "ConvertStringSidToSidW", this.writeSid);
1397
+ const parsedSid = decodePtr(sidSlot);
1398
+ if (parsedSid === null) throw new Win32Error("ConvertStringSidToSidW", api.getLastError(), this.writeSid);
1399
+ this.writeSidPtr = parsedSid;
1400
+ writeSidPtr = parsedSid;
1401
+ }
1402
+ const tempDir = this.tempDirOption === null ? null : this.tempDirOption !== void 0 ? this.tempDirOption : getTempPath(api);
1403
+ if (tempDir !== null) {
1404
+ if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`);
1405
+ this.tempDirResolved = tempDir;
1406
+ }
1407
+ if (this.manageDacls) {
1408
+ if (writeSidPtr !== void 0) {
1409
+ for (const path of this.writableDirs) grantWrite(api, path, writeSidPtr);
1410
+ if (tempDir !== null) {
1411
+ this.grantedPaths.push(tempDir);
1412
+ grantWrite(api, tempDir, writeSidPtr);
1413
+ }
1414
+ }
1415
+ }
1416
+ const logonSid = findLogonSid(api, currentToken);
1417
+ this.sidAllocations.push(logonSid);
1418
+ const worldSid = makeWellKnownSid(api, 1);
1419
+ this.sidAllocations.push(worldSid);
1420
+ const restricted = createRestrictedToken(api, currentToken, logonSid, writeSidPtr, { world: worldSid }, this.mode);
1421
+ setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid);
1422
+ this.token = restricted;
1423
+ if (api.closeHandle(currentToken) === 0) throwLastError(api, "CloseHandle", "current process token");
1424
+ this.api = api;
1425
+ } catch (error) {
1426
+ api.closeHandle(currentToken);
1427
+ const cleanupFailures = [];
1428
+ const writeSidPtr = this.writeSidPtr;
1429
+ if (writeSidPtr !== void 0) for (const path of this.grantedPaths) try {
1430
+ revokeWrite(api, path, writeSidPtr);
1431
+ } catch (cleanupError) {
1432
+ cleanupFailures.push(cleanupError);
1433
+ }
1434
+ for (const sidPtr of this.sidAllocations.splice(0)) try {
1435
+ if (!isNullPtr(api.localFree(sidPtr))) throwLastError(api, "LocalFree", "init SID allocation");
1436
+ } catch (cleanupError) {
1437
+ cleanupFailures.push(cleanupError);
1438
+ }
1439
+ if (cleanupFailures.length > 0) throw new AggregateError([error, ...cleanupFailures], `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`);
1440
+ throw error;
1441
+ }
1442
+ }
1443
+ /**
1444
+ * Spawn a process under the restricted token. Fails closed: throws on every
1445
+ * Win32 failure; the child is never created unrestricted. With
1446
+ * `stdio: 'inherit'` the child shares the caller's stdio directly and is
1447
+ * placed in a kill-on-close job (dies with the caller). Call dispose() only
1448
+ * after all children have exited — revoking grants under a live child
1449
+ * removes its remaining write allowance.
1450
+ * @param options - the program, argv/cwd, and stdio shape.
1451
+ * @returns the running child.
1452
+ */
1453
+ spawn(options) {
1454
+ const api = this.api;
1455
+ const token = this.token;
1456
+ if (api === void 0 || token === void 0) throw new Error("AclSandbox is not initialized: call init() first");
1457
+ const args = options.args ?? [];
1458
+ const cwd = options.cwd ?? process.cwd();
1459
+ if (options.stdio === "inherit") {
1460
+ const native = spawnSandboxedInherited(api, token, {
1461
+ command: options.command,
1462
+ args,
1463
+ cwd
1464
+ });
1465
+ let exitCodePromise;
1466
+ return {
1467
+ pid: native.pid,
1468
+ wait: async () => {
1469
+ exitCodePromise ??= Promise.resolve(waitForExit(api, native.process));
1470
+ const exitCode = await exitCodePromise;
1471
+ if (api.closeHandle(native.job) === 0) throwLastError(api, "CloseHandle", "kill-on-close job");
1472
+ return {
1473
+ stdout: Buffer.alloc(0),
1474
+ stderr: Buffer.alloc(0),
1475
+ exitCode
1476
+ };
1477
+ }
1478
+ };
1479
+ }
1480
+ const native = spawnSandboxed(api, token, {
1481
+ command: options.command,
1482
+ args,
1483
+ cwd
1484
+ });
1485
+ const stdout = drainPipe(api, native.stdoutRead);
1486
+ const stderr = drainPipe(api, native.stderrRead);
1487
+ let exitCodePromise;
1488
+ return {
1489
+ pid: native.pid,
1490
+ wait: async () => {
1491
+ const stdoutBuffer = await stdout;
1492
+ const stderrBuffer = await stderr;
1493
+ exitCodePromise ??= Promise.resolve(waitForExit(api, native.process));
1494
+ return {
1495
+ stdout: stdoutBuffer,
1496
+ stderr: stderrBuffer,
1497
+ exitCode: await exitCodePromise
1498
+ };
1499
+ }
1500
+ };
1501
+ }
1502
+ /**
1503
+ * Revoke the revocable (temp) grants, free the SID, close the token; the
1504
+ * standing workspace ACEs stay (the reuse cache). Reports every cleanup
1505
+ * failure.
1506
+ */
1507
+ dispose() {
1508
+ const api = this.api;
1509
+ if (api === void 0) return;
1510
+ const failures = [];
1511
+ const writeSidPtr = this.writeSidPtr;
1512
+ if (writeSidPtr !== void 0) {
1513
+ if (this.manageDacls) for (const path of this.grantedPaths) try {
1514
+ revokeWrite(api, path, writeSidPtr);
1515
+ } catch (error) {
1516
+ failures.push(error);
1517
+ }
1518
+ try {
1519
+ if (!isNullPtr(api.localFree(writeSidPtr))) throwLastError(api, "LocalFree", "write SID");
1520
+ } catch (error) {
1521
+ failures.push(error);
1522
+ }
1523
+ }
1524
+ const token = this.token;
1525
+ /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always
1526
+ has its token; the guard mirrors the write-SID guard. */
1527
+ if (token !== void 0) try {
1528
+ if (api.closeHandle(token) === 0) throwLastError(api, "CloseHandle", "restricted token");
1529
+ } catch (error) {
1530
+ failures.push(error);
1531
+ }
1532
+ for (const sidPtr of this.sidAllocations.splice(0)) try {
1533
+ if (!isNullPtr(api.localFree(sidPtr))) throwLastError(api, "LocalFree", "init SID allocation");
1534
+ } catch (error) {
1535
+ failures.push(error);
1536
+ }
1537
+ this.api = void 0;
1538
+ this.token = void 0;
1539
+ this.writeSidPtr = void 0;
1540
+ this.grantedPaths = [];
1541
+ if (failures.length > 0) throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`);
1542
+ }
1543
+ };
1544
+ //#endregion
1545
+ export { win32 as a, quoteArg as i, workspaceWriteSid as n, Win32Error as o, AclWriteGrant as r, AclSandbox as t };