@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.
- package/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +91 -0
- package/README.zh.md +93 -0
- package/lib/index.js +2 -0
- package/lib/invariant.js +24 -0
- package/lib/runner.js +150 -0
- package/lib/types/acl.d.ts +83 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/ffi.d.ts +252 -0
- package/lib/types/grant.d.ts +63 -0
- package/lib/types/index.d.ts +157 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/runner.d.ts +51 -0
- package/lib/types/spawn.d.ts +103 -0
- package/lib/types/token.d.ts +89 -0
- package/lib/types/win32-abi.d.ts +178 -0
- package/lib/types/workspace-sid.d.ts +31 -0
- package/lib/types-Br60qOzH.js +1545 -0
- package/package.json +53 -0
|
@@ -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 };
|