@dsh-electron/dsh-plugin-multi-root-workspace 0.1.5 → 0.1.7
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/CHANGELOG.en.md +56 -1
- package/CHANGELOG.md +56 -1
- package/README.en.md +157 -92
- package/README.md +158 -93
- package/cordis.patch.yml +61 -0
- package/lib/client.js +2819 -115
- package/lib/client.js.map +1 -1
- package/lib/command-CESuwlVt.js +1306 -0
- package/lib/command.js +2 -2
- package/lib/{compat-82Y27Iom.js → compat-DF_yL1bB.js} +180 -4
- package/lib/compat.js +1 -1
- package/lib/contract-DZ4zczDT.js +238 -0
- package/lib/{fs-D_Sw3o5z.js → fs-9JIzGaf3.js} +55 -19
- package/lib/fs.js +2 -1
- package/lib/index.js +10 -8
- package/lib/{instructions-Cn1Jj5Xi.js → instructions-D19jRxN0.js} +109 -28
- package/lib/instructions.js +1 -1
- package/lib/lsp.js +43 -0
- package/lib/method-wrapper-CFY3p3j2.js +26 -0
- package/lib/process-fence.js +280 -0
- package/lib/providers.js +87 -0
- package/lib/{registry-DySMbjgW.js → registry-DCxC5Lay.js} +256 -35
- package/lib/registry.js +2 -2
- package/lib/{roots-DasWEBJk.js → roots-B-0l8mNG.js} +117 -20
- package/lib/sandbox-Kup4_ZIb.js +747 -0
- package/lib/sandbox.js +2 -1
- package/lib/scope.js +240 -25
- package/lib/session-directory-BLOstut1.js +9 -0
- package/lib/tool-context-CpgIFcCB.js +6 -0
- package/lib/types/client/RootSwitcher.d.ts +63 -0
- package/lib/types/client/WorkspaceFoldersAction.d.ts +41 -0
- package/lib/types/client/files-tree/FilesBody.module.css.d.ts +24 -0
- package/lib/types/client/files-tree/FilesTree.d.ts +47 -0
- package/lib/types/client/files-tree/adapter.d.ts +94 -0
- package/lib/types/client/files-tree/directory-node.d.ts +62 -0
- package/lib/types/client/files-tree/face.d.ts +107 -0
- package/lib/types/client/files-tree/locales.d.ts +10 -0
- package/lib/types/client/files-tree/primitives.d.ts +33 -0
- package/lib/types/client/files-tree/store.d.ts +81 -0
- package/lib/types/client/index.d.ts +13 -2
- package/lib/types/client/locales.d.ts +60 -0
- package/lib/types/client/open-in-app/OpenInAppAction.d.ts +27 -0
- package/lib/types/client/open-in-app/OpenTargetButton.d.ts +52 -0
- package/lib/types/client/open-in-app/OpenTargetButton.module.css.d.ts +29 -0
- package/lib/types/client/open-in-app/adapter.d.ts +46 -0
- package/lib/types/client/open-in-app/applications.d.ts +4 -0
- package/lib/types/client/open-in-app/controller.d.ts +53 -0
- package/lib/types/client/open-in-app/locales.d.ts +10 -0
- package/lib/types/client/open-in-app/open-failure-toast.d.ts +11 -0
- package/lib/types/client/open-in-app/open-path.d.ts +13 -0
- package/lib/types/client/panel-client.d.ts +2 -1
- package/lib/types/client/styles.d.ts +1 -1
- package/lib/types/command.d.ts +16 -3
- package/lib/types/compat/client-icons.d.ts +18 -0
- package/lib/types/compat/client-session.d.ts +2 -0
- package/lib/types/compat/dsh-version.d.ts +5 -3
- package/lib/types/compat/instruction-overlap.d.ts +7 -0
- package/lib/types/compat/open-in-app-routes.d.ts +38 -0
- package/lib/types/compat/process-entrypoints.d.ts +31 -0
- package/lib/types/compat/process-result.d.ts +14 -0
- package/lib/types/compat/pty-marker.d.ts +83 -0
- package/lib/types/compat/sandbox-confine.d.ts +2 -0
- package/lib/types/compat/session-directory.d.ts +4 -0
- package/lib/types/compat/storage-zod.d.ts +65 -0
- package/lib/types/compat/tool-context.d.ts +9 -0
- package/lib/types/compat/windows-deny-preflight.d.ts +21 -0
- package/lib/types/compat/windows-native.d.ts +68 -0
- package/lib/types/compat/windows-path-guard.d.ts +56 -0
- package/lib/types/compat/workspace-files.d.ts +7 -0
- package/lib/types/contract.d.ts +35 -4
- package/lib/types/dialects.d.ts +99 -3
- package/lib/types/fs.d.ts +32 -10
- package/lib/types/index.d.ts +5 -0
- package/lib/types/lsp.d.ts +4 -0
- package/lib/types/method-wrapper.d.ts +2 -0
- package/lib/types/process-fence.d.ts +54 -0
- package/lib/types/profile-isolation.d.ts +178 -0
- package/lib/types/providers.d.ts +70 -0
- package/lib/types/registry.d.ts +132 -31
- package/lib/types/roots.d.ts +100 -13
- package/lib/types/sandbox.d.ts +59 -12
- package/lib/types/scope.d.ts +203 -14
- package/lib/types/windows-broker.d.ts +17 -0
- package/lib/types/windows-deny.d.ts +1 -0
- package/lib/types/windows-profile.d.ts +10 -0
- package/lib/types/windows-runner-contract.d.ts +97 -0
- package/lib/types/windows-runner.d.ts +1 -0
- package/lib/types/workspace-files.d.ts +4 -0
- package/lib/types/workspace-routing.d.ts +11 -0
- package/lib/windows-runner-contract-CEfmZzu0.js +141 -0
- package/lib/windows-runner.js +1311 -0
- package/lib/workspace-files.js +203 -0
- package/lib/workspace-routing-YPxaQ4V3.js +26 -0
- package/package.json +116 -51
- package/scripts/windows-broker/install.ps1 +89 -0
- package/scripts/windows-broker/rights.cs +58 -0
- package/scripts/windows-broker/service.cs +323 -0
- package/scripts/windows-broker/verify-install.ps1 +80 -0
- package/lib/command-DyOWujPo.js +0 -690
- package/lib/sandbox-D8Fw95FM.js +0 -290
- package/lib/{registry-lease-win32-dy8TQMD9.js → registry-lease-win32-DTO6QpT9.js} +2 -2
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
import { a as parseWindowsProfile, i as isWindowsNetworkOrDevicePath, n as assertWindowsDenyCapacity, o as rootDenySid, r as canonicalPath, s as workspaceScopeSid } from "./windows-runner-contract-CEfmZzu0.js";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, win32 } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { createConnection } from "node:net";
|
|
8
|
+
//#region src/compat/windows-native.ts
|
|
9
|
+
/** Public Win32 process primitives, resolved from the upstream ACL installation.
|
|
10
|
+
* The standalone runner has no host module resolver. No upstream private file
|
|
11
|
+
* or load-time optional dependency is used here; unfamiliar exports fail closed.
|
|
12
|
+
*/
|
|
13
|
+
/** Decode the UTF-16 pointee, not a second string pointer stored at its first
|
|
14
|
+
* code units. `decode(ptr, 'str16')` would dereference those units as an address.
|
|
15
|
+
*/
|
|
16
|
+
function readWindowsWideString(koffi, pointer) {
|
|
17
|
+
return String(koffi.decode(pointer, "char16_t", -1));
|
|
18
|
+
}
|
|
19
|
+
async function loadWindowsNative(upstreamRunner) {
|
|
20
|
+
const aclEntry = createRequire(upstreamRunner).resolve("@deepseek-ai/dsh-sandbox-windows-acl");
|
|
21
|
+
const fromAcl = createRequire(aclEntry);
|
|
22
|
+
const processApi = await import(pathToFileURL(fromAcl.resolve("@deepseek-ai/dsh-win32-process")).href);
|
|
23
|
+
const koffi = (await import(pathToFileURL(fromAcl.resolve("koffi")).href)).default;
|
|
24
|
+
for (const name of [
|
|
25
|
+
"extendWin32ProcessBindings",
|
|
26
|
+
"spawnInheritedJobProcess",
|
|
27
|
+
"waitForProcessExit"
|
|
28
|
+
]) if (typeof processApi[name] !== "function") throw new Error(`Windows ambient restriction requires public Win32 export ${name}`);
|
|
29
|
+
for (const name of [
|
|
30
|
+
"decode",
|
|
31
|
+
"address",
|
|
32
|
+
"load",
|
|
33
|
+
"alloc",
|
|
34
|
+
"free",
|
|
35
|
+
"encode"
|
|
36
|
+
]) if (typeof koffi?.[name] !== "function") throw new Error(`Windows ambient restriction requires Koffi ${name}`);
|
|
37
|
+
return {
|
|
38
|
+
...processApi,
|
|
39
|
+
koffi
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Older public process helpers predate fd-7 CRT forwarding. Adapt the Win32
|
|
43
|
+
* STARTUPINFO structure at the public binding seam, preserving argv and Job
|
|
44
|
+
* ownership. New helpers already populate cbReserved2 and pass through.
|
|
45
|
+
*/
|
|
46
|
+
function spawnWindowsDeniedChild(native, api, options, activation) {
|
|
47
|
+
if (options.controlFileDescriptor !== void 0 && typeof api.getFileType !== "function") api = {
|
|
48
|
+
...api,
|
|
49
|
+
...native.extendWin32ProcessBindings(({ kernel32, bind }) => ({ getFileType: bind(kernel32, "GetFileType", "uint32", ["void*"]) }))
|
|
50
|
+
};
|
|
51
|
+
let launchApi = api;
|
|
52
|
+
if (activation !== void 0) launchApi = {
|
|
53
|
+
...api,
|
|
54
|
+
createProcessAsUserW: (...args) => {
|
|
55
|
+
if ((Number(args[6]) & 4) === 0) throw new Error("Windows deny activation requires a suspended child");
|
|
56
|
+
const result = api.createProcessAsUserW(activation.sourceToken, ...args.slice(1));
|
|
57
|
+
if (result === 0) return result;
|
|
58
|
+
const processInfo = Buffer.from(native.koffi.decode(args[10], "uint8", 24));
|
|
59
|
+
const process = native.koffi.decode(processInfo, "void*");
|
|
60
|
+
const thread = native.koffi.decode(processInfo.subarray(8), "void*");
|
|
61
|
+
try {
|
|
62
|
+
activation.assign(process, thread);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
api.terminateProcess(process, 127);
|
|
65
|
+
api.closeHandle(thread);
|
|
66
|
+
api.closeHandle(process);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
if (options.controlFileDescriptor === void 0) return native.spawnInheritedJobProcess(launchApi, options);
|
|
73
|
+
const handle = api.uvGetOsfhandle(7);
|
|
74
|
+
if (api.getFileType(handle) !== 3) throw new Error("subprocess control descriptor is not a Windows pipe");
|
|
75
|
+
if (api.setHandleInformation(handle, 1, 1) === 0) throw new Error(`SetHandleInformation(control) failed (Win32 ${api.getLastError()})`);
|
|
76
|
+
const adapted = {
|
|
77
|
+
...launchApi,
|
|
78
|
+
createProcessAsUserW: (...args) => {
|
|
79
|
+
const original = Buffer.from(native.koffi.decode(args[9], "uint8", 104));
|
|
80
|
+
if (original.readUInt32LE(0) !== 104) throw new Error("unrecognized Windows STARTUPINFO layout");
|
|
81
|
+
if (original.readUInt16LE(66) !== 0) return launchApi.createProcessAsUserW(...args);
|
|
82
|
+
const count = 8;
|
|
83
|
+
const data = Buffer.alloc(76);
|
|
84
|
+
data.writeUInt32LE(count);
|
|
85
|
+
for (let fd = 0; fd < count; fd++) data.writeBigUInt64LE(18446744073709551615n, 12 + fd * 8);
|
|
86
|
+
for (const [fd, value] of [
|
|
87
|
+
[0, original.readBigUInt64LE(80)],
|
|
88
|
+
[1, original.readBigUInt64LE(88)],
|
|
89
|
+
[2, original.readBigUInt64LE(96)],
|
|
90
|
+
[7, handle]
|
|
91
|
+
]) {
|
|
92
|
+
const kind = api.getFileType(value);
|
|
93
|
+
data[4 + fd] = 1 | (kind === 3 ? 8 : kind === 2 ? 64 : 0);
|
|
94
|
+
data.writeBigUInt64LE(BigInt(value), 12 + fd * 8);
|
|
95
|
+
}
|
|
96
|
+
const reserved = native.koffi.alloc("uint8", data.length);
|
|
97
|
+
try {
|
|
98
|
+
native.koffi.encode(reserved, "uint8", data, data.length);
|
|
99
|
+
original.writeUInt16LE(data.length, 66);
|
|
100
|
+
original.writeBigUInt64LE(native.koffi.address(reserved), 72);
|
|
101
|
+
return launchApi.createProcessAsUserW(...args.slice(0, 9), original, ...args.slice(10));
|
|
102
|
+
} finally {
|
|
103
|
+
native.koffi.free(reserved);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
return native.spawnInheritedJobProcess(adapted, options);
|
|
109
|
+
} finally {
|
|
110
|
+
api.setHandleInformation(handle, 1, 0);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Terminate a kill-on-close Job and wait until the kernel reports it drained.
|
|
115
|
+
*
|
|
116
|
+
* The public `waitForProcessExit` covers the direct child only: a descendant
|
|
117
|
+
* the child spawned before exiting stays in the Job and could otherwise keep
|
|
118
|
+
* running — with its restricted token — after the runner has released its
|
|
119
|
+
* ancestor pins. `TerminateJobObject(job, 127)` kills every member; the
|
|
120
|
+
* subsequent `QueryInformationJobObject` polling loop (class 1,
|
|
121
|
+
* JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, `ActiveProcesses` at byte 40) only
|
|
122
|
+
* returns once the kernel reports none left. There is deliberately no
|
|
123
|
+
* timeout: releasing pins while a live descendant remains is exactly the gap
|
|
124
|
+
* this wait exists to close. The caller still owns and closes the Job handle
|
|
125
|
+
* afterwards.
|
|
126
|
+
*
|
|
127
|
+
* Abnormal-error boundary: a TerminateJobObject or QueryInformationJobObject
|
|
128
|
+
* API failure throws into the caller's teardown — the Job handle is still
|
|
129
|
+
* closed there and its kill-on-close flag is the residual bound, same as the
|
|
130
|
+
* forced-termination case: if the runner process itself is killed, no
|
|
131
|
+
* in-process guarantee can survive, the Job flag takes the descendants down
|
|
132
|
+
* with it, and the pin handles die with the process. Protection is promised
|
|
133
|
+
* only through this orderly path.
|
|
134
|
+
*/
|
|
135
|
+
function terminateWindowsJobAndWait(native, api, job) {
|
|
136
|
+
const jobApi = {
|
|
137
|
+
...api,
|
|
138
|
+
...native.extendWin32ProcessBindings(({ kernel32, bind }) => ({
|
|
139
|
+
terminateJobObject: bind(kernel32, "TerminateJobObject", "int", ["void*", "uint32"]),
|
|
140
|
+
queryInformationJobObject: bind(kernel32, "QueryInformationJobObject", "int", [
|
|
141
|
+
"void*",
|
|
142
|
+
"int",
|
|
143
|
+
"void*",
|
|
144
|
+
"uint32",
|
|
145
|
+
"void*"
|
|
146
|
+
]),
|
|
147
|
+
sleep: bind(kernel32, "Sleep", "void", ["uint32"]),
|
|
148
|
+
getLastError: bind(kernel32, "GetLastError", "uint32", [])
|
|
149
|
+
}))
|
|
150
|
+
};
|
|
151
|
+
if (jobApi.terminateJobObject(job, 127) === 0) throw new Error(`TerminateJobObject failed (Win32 ${jobApi.getLastError()})`);
|
|
152
|
+
const info = Buffer.alloc(48);
|
|
153
|
+
for (;;) {
|
|
154
|
+
if (jobApi.queryInformationJobObject(job, 1, info, info.length, null) === 0) throw new Error(`QueryInformationJobObject failed (Win32 ${jobApi.getLastError()})`);
|
|
155
|
+
if (info.readUInt32LE(40) === 0) return;
|
|
156
|
+
jobApi.sleep(10);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/compat/windows-path-guard.ts
|
|
161
|
+
/** Windows deny-path pinning (branch-review Phase B).
|
|
162
|
+
*
|
|
163
|
+
* The ACL writer's deny ACEs follow the file OBJECT, not the registered
|
|
164
|
+
* spelling: a restricted process that renames a deny root's ancestor moves
|
|
165
|
+
* the protected tree to a NEW path the recorded deny set does not name — the
|
|
166
|
+
* kernel still refuses that token's writes through the moved ACEs, but the
|
|
167
|
+
* host fs fence, which matches the recorded spellings, no longer covers the
|
|
168
|
+
* moved objects (the shell → host new-path gap, R1). This guard pins every
|
|
169
|
+
* ancestor directory of every deny root with an open handle that shares read
|
|
170
|
+
* and write but NOT delete — so a rename or delete of the registered tree
|
|
171
|
+
* fails for the lifetime of the restricted child, without writing a single
|
|
172
|
+
* persistent ACL on `C:\`, user directories, or anywhere else.
|
|
173
|
+
*
|
|
174
|
+
* `windows-deny.ts` owns the lifetime contract: handles are held until the
|
|
175
|
+
* confined child AND its drained Job descendants have exited.
|
|
176
|
+
*
|
|
177
|
+
* Primitives are bound through the public `extendWin32ProcessBindings` seam —
|
|
178
|
+
* no upstream private file, no new native dependency. Every verification uses
|
|
179
|
+
* the same handle that pins the directory, so the checked object and the
|
|
180
|
+
* pinned object are always the same one.
|
|
181
|
+
*/
|
|
182
|
+
/** The access an ancestor pin needs to PARTICIPATE in the delete-sharing
|
|
183
|
+
* gate. Verified on Windows 11: a metadata-only open (FILE_READ_ATTRIBUTES
|
|
184
|
+
* alone, even with share mode 0) is transparent to the gate — it neither
|
|
185
|
+
* blocks a rename of the directory nor is blocked by a conflicting handle —
|
|
186
|
+
* while verified data-read access (GENERIC_READ here) does block it: the pin
|
|
187
|
+
* then holds renames while still sharing read and write.
|
|
188
|
+
* A consequence, not a bug: an ancestor the caller cannot READ cannot be
|
|
189
|
+
* pinned, and the pin fails closed instead of pretending. */
|
|
190
|
+
const PIN_ACCESS = 2147483648;
|
|
191
|
+
/** READ|WRITE sharing, deliberately WITHOUT FILE_SHARE_DELETE: that is the
|
|
192
|
+
* whole mechanism — a rename or delete of a pinned directory needs delete
|
|
193
|
+
* sharing on it, which nothing gets while a pin is held. */
|
|
194
|
+
const SHARE_READ_WRITE = 3;
|
|
195
|
+
const OPEN_EXISTING$1 = 3;
|
|
196
|
+
/** Directory handles need BACKUP_SEMANTICS; OPEN_REPARSE_POINT makes the pin
|
|
197
|
+
* land on the reparse point itself so the attribute check can refuse it. */
|
|
198
|
+
const PIN_FLAGS = 35651584;
|
|
199
|
+
const FILE_ATTRIBUTE_DIRECTORY$1 = 16;
|
|
200
|
+
const FILE_ATTRIBUTE_REPARSE_POINT = 1024;
|
|
201
|
+
const HANDLE_FLAG_INHERIT = 1;
|
|
202
|
+
/** `\\?\` extended-length prefix on a normalized DOS volume name; a `UNC`
|
|
203
|
+
* continuation means the handle resolved onto a network share. */
|
|
204
|
+
const DOS_PREFIX = "\\\\?\\";
|
|
205
|
+
/** Bind the file/handle primitives the guard needs through the public seam. */
|
|
206
|
+
function pathGuardBindings(native) {
|
|
207
|
+
return native.extendWin32ProcessBindings(({ kernel32, bind }) => ({
|
|
208
|
+
createFileW: bind(kernel32, "CreateFileW", "void*", [
|
|
209
|
+
"str16",
|
|
210
|
+
"uint32",
|
|
211
|
+
"uint32",
|
|
212
|
+
"void*",
|
|
213
|
+
"uint32",
|
|
214
|
+
"uint32",
|
|
215
|
+
"void*"
|
|
216
|
+
]),
|
|
217
|
+
getFileInformationByHandle: bind(kernel32, "GetFileInformationByHandle", "int", ["void*", "void*"]),
|
|
218
|
+
getFinalPathNameByHandleW: bind(kernel32, "GetFinalPathNameByHandleW", "uint32", [
|
|
219
|
+
"void*",
|
|
220
|
+
"void*",
|
|
221
|
+
"uint32",
|
|
222
|
+
"uint32"
|
|
223
|
+
]),
|
|
224
|
+
getHandleInformation: bind(kernel32, "GetHandleInformation", "int", ["void*", "void*"]),
|
|
225
|
+
closeHandle: bind(kernel32, "CloseHandle", "int", ["void*"]),
|
|
226
|
+
getLastError: bind(kernel32, "GetLastError", "uint32", [])
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
const isInvalidHandle = (handle) => handle === null || handle === void 0 || BigInt(handle) === -1n || BigInt(handle) === 0n || BigInt(handle) === 18446744073709551615n;
|
|
230
|
+
/** BY_HANDLE_FILE_INFORMATION is 52 bytes (FILETIME members are DWORD-pairs):
|
|
231
|
+
* attributes @0, volume serial @28, link count @40, file index high @44 /
|
|
232
|
+
* low @48. Read back on the same handle that pins the path, so the identity
|
|
233
|
+
* cannot drift between check and pin.
|
|
234
|
+
*/
|
|
235
|
+
function readWindowsPathIdentity(api, handle) {
|
|
236
|
+
const info = Buffer.alloc(52);
|
|
237
|
+
if (api.getFileInformationByHandle(handle, info) === 0) throw new Error(`GetFileInformationByHandle failed (Win32 ${api.getLastError()})`);
|
|
238
|
+
return {
|
|
239
|
+
attributes: info.readUInt32LE(0),
|
|
240
|
+
volumeSerial: info.readUInt32LE(28),
|
|
241
|
+
links: info.readUInt32LE(40),
|
|
242
|
+
fileIndex: BigInt(info.readUInt32LE(44)) << 32n | BigInt(info.readUInt32LE(48))
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The kernel's normalized DOS name for an open handle, compared against the
|
|
247
|
+
* spelling the caller expected. Anything else — a junction-resolved target, a
|
|
248
|
+
* UNC share, a device path — is a refusal, never an assumption.
|
|
249
|
+
*/
|
|
250
|
+
function readFinalPathName(api, handle, expected) {
|
|
251
|
+
let capacity = 260;
|
|
252
|
+
for (;;) {
|
|
253
|
+
const buffer = Buffer.alloc(capacity * 2);
|
|
254
|
+
const written = api.getFinalPathNameByHandleW(handle, buffer, capacity, 0);
|
|
255
|
+
if (written === 0) throw new Error(`GetFinalPathNameByHandleW(${expected}) failed (Win32 ${api.getLastError()})`);
|
|
256
|
+
if (written >= capacity) {
|
|
257
|
+
capacity = written + 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const name = buffer.subarray(0, written * 2).toString("utf16le");
|
|
261
|
+
const finalPath = name.startsWith(DOS_PREFIX) ? name.slice(4) : name;
|
|
262
|
+
if (finalPath.startsWith("UNC\\") || finalPath.startsWith("\\\\")) throw new Error(`deny path ancestor resolves to a non-local device: ${expected}`);
|
|
263
|
+
if (finalPath.toLowerCase() !== expected.toLowerCase()) throw new Error(`deny path ancestor resolves to "${finalPath}" instead of "${expected}"`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Every directory that must not move for `roots` to keep their registered
|
|
269
|
+
* meaning: the full chain from the drive root down to each deny root,
|
|
270
|
+
* deduplicated and ordered shallow-first so a shared prefix is pinned once
|
|
271
|
+
* and parents are held before their children.
|
|
272
|
+
*
|
|
273
|
+
* Roots must be canonical absolute local-drive spellings: relative input,
|
|
274
|
+
* UNC, and device paths are rejected lexically before any filesystem touch.
|
|
275
|
+
*/
|
|
276
|
+
function denyPathChain(roots) {
|
|
277
|
+
const seen = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const root of roots) {
|
|
279
|
+
const normalized = win32.normalize(root);
|
|
280
|
+
if (!/^[a-zA-Z]:[\\/]/.test(normalized) || isWindowsNetworkOrDevicePath(normalized)) throw new Error(`deny root is not a canonical local-drive path: ${root}`);
|
|
281
|
+
let cursor = normalized.length > 3 ? normalized.replace(/[\\/]+$/, "") : normalized;
|
|
282
|
+
for (;;) {
|
|
283
|
+
if (!seen.has(cursor.toLowerCase())) seen.set(cursor.toLowerCase(), cursor);
|
|
284
|
+
const parent = win32.dirname(cursor);
|
|
285
|
+
if (parent === cursor) break;
|
|
286
|
+
cursor = parent;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const depth = (path) => path.split("\\").length - 1;
|
|
290
|
+
return [...seen.values()].sort((a, b) => depth(a) - depth(b) || a.localeCompare(b));
|
|
291
|
+
}
|
|
292
|
+
/** Close every handle, deepest first; collect rather than lose failures. */
|
|
293
|
+
function closeAll(api, handles) {
|
|
294
|
+
const errors = [];
|
|
295
|
+
for (let index = handles.length - 1; index >= 0; index -= 1) try {
|
|
296
|
+
if (api.closeHandle(handles[index]) === 0) errors.push(/* @__PURE__ */ new Error(`CloseHandle failed (Win32 ${api.getLastError()})`));
|
|
297
|
+
} catch (error) {
|
|
298
|
+
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
299
|
+
}
|
|
300
|
+
return errors;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Open a pin handle on every ancestor of every deny root — including the deny
|
|
304
|
+
* root itself — and hold them all until {@link WindowsPathGuard.close}. Each
|
|
305
|
+
* handle is opened with GENERIC_READ, read+write sharing, and a null security
|
|
306
|
+
* descriptor so it is non-inheritable; the missing DELETE sharing is what
|
|
307
|
+
* blocks renames, while ordinary reads and writes (and creating sibling files
|
|
308
|
+
* inside a pinned directory) keep working.
|
|
309
|
+
*
|
|
310
|
+
* Every pin is verified on the same handle: the object must be a directory,
|
|
311
|
+
* must not be a reparse point, must not carry the inherit flag (so a confined
|
|
312
|
+
* child cannot simply `CloseHandle` the pin away), and its kernel-reported
|
|
313
|
+
* final DOS path must equal the expected canonical spelling.
|
|
314
|
+
* Any failure — open, verification, or a sharing conflict with a handle some
|
|
315
|
+
* other component already holds — closes every earlier pin in reverse order
|
|
316
|
+
* and refuses, keeping the launch fail-closed before spawn.
|
|
317
|
+
*/
|
|
318
|
+
function pinWindowsDenyPaths(native, roots) {
|
|
319
|
+
const api = pathGuardBindings(native);
|
|
320
|
+
const chain = denyPathChain(roots);
|
|
321
|
+
const handles = [];
|
|
322
|
+
try {
|
|
323
|
+
for (const path of chain) {
|
|
324
|
+
const handle = api.createFileW(path, PIN_ACCESS, SHARE_READ_WRITE, null, OPEN_EXISTING$1, PIN_FLAGS, null);
|
|
325
|
+
if (isInvalidHandle(handle)) throw new Error(`CreateFileW(${path}) failed (Win32 ${api.getLastError()})`);
|
|
326
|
+
handles.push(handle);
|
|
327
|
+
const identity = readWindowsPathIdentity(api, handle);
|
|
328
|
+
if ((identity.attributes & FILE_ATTRIBUTE_REPARSE_POINT) !== 0) throw new Error(`deny path ancestor is a reparse point: ${path}`);
|
|
329
|
+
if ((identity.attributes & FILE_ATTRIBUTE_DIRECTORY$1) === 0) throw new Error(`deny path ancestor is not a directory: ${path}`);
|
|
330
|
+
const flags = Buffer.alloc(4);
|
|
331
|
+
if (api.getHandleInformation(handle, flags) === 0) throw new Error(`GetHandleInformation(${path}) failed (Win32 ${api.getLastError()})`);
|
|
332
|
+
if ((flags.readUInt32LE() & HANDLE_FLAG_INHERIT) !== 0) throw new Error(`deny path handle is inheritable: ${path}`);
|
|
333
|
+
readFinalPathName(api, handle, path);
|
|
334
|
+
}
|
|
335
|
+
} catch (primary) {
|
|
336
|
+
const cleanup = closeAll(api, handles);
|
|
337
|
+
if (cleanup.length > 0) throw new AggregateError([primary instanceof Error ? primary : new Error(String(primary)), ...cleanup], primary instanceof Error ? primary.message : String(primary));
|
|
338
|
+
throw primary;
|
|
339
|
+
}
|
|
340
|
+
let closed = false;
|
|
341
|
+
return { close() {
|
|
342
|
+
if (closed) return;
|
|
343
|
+
closed = true;
|
|
344
|
+
const errors = closeAll(api, handles);
|
|
345
|
+
if (errors.length > 0) throw new AggregateError(errors, "WindowsPathGuard close failed");
|
|
346
|
+
} };
|
|
347
|
+
}
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region src/compat/windows-deny-preflight.ts
|
|
350
|
+
/** Windows deny-tree preflight (branch-review Phase C1, R4).
|
|
351
|
+
*
|
|
352
|
+
* A deny ACE written through `SetSecurityInfo` lands on the file OBJECT — for
|
|
353
|
+
* a hardlinked file every other link carries the same object, so a partial
|
|
354
|
+
* ACL walk would have already mutated paths outside the denied tree by the
|
|
355
|
+
* time a later hardlink is discovered. This module therefore scans EVERY deny
|
|
356
|
+
* root and descendant — read-only, no ACL edits anywhere — before the runner
|
|
357
|
+
* is allowed to request a token or touch a security descriptor. Any
|
|
358
|
+
* uninspectable node or any plain file with more than one link refuses the
|
|
359
|
+
* whole startup (exit 127 before side effects).
|
|
360
|
+
*
|
|
361
|
+
* The policy is conservative: `links > 1` fails even when every link appears
|
|
362
|
+
* to live inside the same scanned tree, because the snapshot cannot prove
|
|
363
|
+
* that topology (a link may be added or moved between scan and mutation).
|
|
364
|
+
* Reparse points are recorded but never descended — a junction's target is a
|
|
365
|
+
* different tree with its own policy and must not be scanned or mutated.
|
|
366
|
+
*
|
|
367
|
+
* `verifyHandle` re-checks identity on the same live handle about to be
|
|
368
|
+
* ACL-mutated: a path replaced between scan and mutation fails closed rather
|
|
369
|
+
* than writing a deny onto an object the preflight never inspected. This is
|
|
370
|
+
* not transactional — a malicious concurrent mutation can still race between
|
|
371
|
+
* the recheck and the write — but it closes the far cheaper
|
|
372
|
+
* replace-between-scan-and-write window.
|
|
373
|
+
*
|
|
374
|
+
* Primitives are bound through the public `extendWin32ProcessBindings` seam;
|
|
375
|
+
* only Node built-ins and local contract/native modules are imported.
|
|
376
|
+
*/
|
|
377
|
+
const FILE_READ_ATTRIBUTES = 128;
|
|
378
|
+
const INSPECTION_SHARE = 7;
|
|
379
|
+
const OPEN_EXISTING = 3;
|
|
380
|
+
const OPEN_FLAGS = 35651584;
|
|
381
|
+
const FILE_ATTRIBUTE_DIRECTORY = 16;
|
|
382
|
+
const KIND_BITS = 1040;
|
|
383
|
+
const FILE_TYPE_DISK = 1;
|
|
384
|
+
const INVALID_HANDLE = BigInt("0xFFFFFFFFFFFFFFFF");
|
|
385
|
+
const invalid = (handle) => handle === null || handle === void 0 || BigInt(handle) === INVALID_HANDLE || BigInt(handle) === -1n;
|
|
386
|
+
const kindOf = (root, protectedHome) => protectedHome !== void 0 && root.toLowerCase() === protectedHome.toLowerCase() ? "protected DSH home" : "registered root";
|
|
387
|
+
const hardlinkError = (root, path, links, protectedHome) => {
|
|
388
|
+
const kind = kindOf(root, protectedHome);
|
|
389
|
+
return /* @__PURE__ */ new Error(`hardlinked file inside the ${kind} ${root}: ${path} has links=${links}; a deny ACL written on one link mutates every link's shared object — ${kind === "protected DSH home" ? "replace the file with an independent copy — run `/workspace-folders isolate-profiles` to isolate the protected home, then reinstall the profile dependencies (do not skip this file)" : "replace the file with an independent copy (write its contents to a new file and rename that over it, or reinstall the dependency tree as copies)"}`);
|
|
390
|
+
};
|
|
391
|
+
/**
|
|
392
|
+
* Scan every root recursively. The returned snapshot keys every node by its
|
|
393
|
+
* lowercased walked spelling; a later same-key path (shared subtree) keeps
|
|
394
|
+
* the first entry and is not re-scanned.
|
|
395
|
+
*/
|
|
396
|
+
function preflightWindowsDenyRoots(native, roots, protectedHome) {
|
|
397
|
+
const api = native.extendWin32ProcessBindings(({ kernel32, bind }) => ({
|
|
398
|
+
createFileW: bind(kernel32, "CreateFileW", "void*", [
|
|
399
|
+
"str16",
|
|
400
|
+
"uint32",
|
|
401
|
+
"uint32",
|
|
402
|
+
"void*",
|
|
403
|
+
"uint32",
|
|
404
|
+
"uint32",
|
|
405
|
+
"void*"
|
|
406
|
+
]),
|
|
407
|
+
getFileInformationByHandle: bind(kernel32, "GetFileInformationByHandle", "int", ["void*", "void*"]),
|
|
408
|
+
getFileType: bind(kernel32, "GetFileType", "uint32", ["void*"]),
|
|
409
|
+
closeHandle: bind(kernel32, "CloseHandle", "int", ["void*"]),
|
|
410
|
+
getLastError: bind(kernel32, "GetLastError", "uint32", [])
|
|
411
|
+
}));
|
|
412
|
+
const entries = /* @__PURE__ */ new Map();
|
|
413
|
+
/**
|
|
414
|
+
* Open, identify, record, then close — the handle never outlives the node.
|
|
415
|
+
* Every failure names the root and the path; a close failure is reported on
|
|
416
|
+
* its own when it is the only error and aggregated (never masking) when the
|
|
417
|
+
* inspection itself already failed.
|
|
418
|
+
*/
|
|
419
|
+
const inspect = (root, path) => {
|
|
420
|
+
const handle = api.createFileW(path, FILE_READ_ATTRIBUTES, INSPECTION_SHARE, 0, OPEN_EXISTING, OPEN_FLAGS, 0);
|
|
421
|
+
if (invalid(handle)) throw new Error(`deny preflight cannot inspect ${path} under root ${root}: CreateFileW Win32 ${api.getLastError()}`);
|
|
422
|
+
let primary;
|
|
423
|
+
let identity;
|
|
424
|
+
try {
|
|
425
|
+
identity = readWindowsPathIdentity(api, handle);
|
|
426
|
+
if ((identity.attributes & KIND_BITS) === 0) {
|
|
427
|
+
if (api.getFileType(handle) !== FILE_TYPE_DISK) throw new Error(`deny preflight refuses non-disk file ${path} under root ${root}`);
|
|
428
|
+
if (identity.links > 1) throw hardlinkError(root, path, identity.links, protectedHome);
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
primary = error instanceof Error ? error : new Error(String(error));
|
|
432
|
+
}
|
|
433
|
+
const closed = api.closeHandle(handle) !== 0;
|
|
434
|
+
if (primary !== void 0) {
|
|
435
|
+
if (!closed) throw new AggregateError([primary, /* @__PURE__ */ new Error(`CloseHandle failed on the inspection handle: Win32 ${api.getLastError()}`)], `deny preflight failed for ${path} under root ${root} and its cleanup also failed`);
|
|
436
|
+
throw primary;
|
|
437
|
+
}
|
|
438
|
+
if (!closed) throw new Error(`deny preflight cannot close the inspection handle for ${path} under root ${root}: Win32 ${api.getLastError()}`);
|
|
439
|
+
return identity;
|
|
440
|
+
};
|
|
441
|
+
const scanNode = (root, path) => {
|
|
442
|
+
const key = path.toLowerCase();
|
|
443
|
+
if (entries.has(key)) return;
|
|
444
|
+
const identity = inspect(root, path);
|
|
445
|
+
entries.set(key, {
|
|
446
|
+
root,
|
|
447
|
+
path,
|
|
448
|
+
identity
|
|
449
|
+
});
|
|
450
|
+
if ((identity.attributes & KIND_BITS) !== FILE_ATTRIBUTE_DIRECTORY) return;
|
|
451
|
+
const canonical = canonicalPath(path);
|
|
452
|
+
if (win32.normalize(canonical).toLowerCase() !== key) throw new Error(`deny preflight refuses aliased directory ${path} under root ${root}: resolves to ${canonical}`);
|
|
453
|
+
let names;
|
|
454
|
+
try {
|
|
455
|
+
names = readdirSync(path).sort();
|
|
456
|
+
} catch (error) {
|
|
457
|
+
throw new Error(`deny preflight cannot list ${path} under root ${root}: ${error instanceof Error ? error.message : String(error)}`);
|
|
458
|
+
}
|
|
459
|
+
for (const name of names) scanNode(root, win32.join(path, name));
|
|
460
|
+
};
|
|
461
|
+
for (const root of roots) {
|
|
462
|
+
const canonicalRoot = win32.normalize(canonicalPath(root));
|
|
463
|
+
const identity = inspect(canonicalRoot, canonicalRoot);
|
|
464
|
+
if ((identity.attributes & KIND_BITS) !== FILE_ATTRIBUTE_DIRECTORY) throw new Error(`deny preflight refuses root ${canonicalRoot}: it must be a plain directory`);
|
|
465
|
+
entries.set(canonicalRoot.toLowerCase(), {
|
|
466
|
+
root: canonicalRoot,
|
|
467
|
+
path: canonicalRoot,
|
|
468
|
+
identity
|
|
469
|
+
});
|
|
470
|
+
let names;
|
|
471
|
+
try {
|
|
472
|
+
names = readdirSync(canonicalRoot).sort();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
throw new Error(`deny preflight cannot list root ${canonicalRoot}: ${error instanceof Error ? error.message : String(error)}`);
|
|
475
|
+
}
|
|
476
|
+
for (const name of names) scanNode(canonicalRoot, win32.join(canonicalRoot, name));
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
entries,
|
|
480
|
+
verifyHandle(liveApi, path, handle) {
|
|
481
|
+
const entry = entries.get(path.toLowerCase());
|
|
482
|
+
if (entry === void 0) throw new Error(`deny preflight has no snapshot for ${path} — refusing to mutate an uninspected path`);
|
|
483
|
+
let fresh;
|
|
484
|
+
try {
|
|
485
|
+
fresh = readWindowsPathIdentity(liveApi, handle);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
throw new Error(`deny preflight cannot re-verify ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
488
|
+
}
|
|
489
|
+
if ((fresh.attributes & KIND_BITS) !== (entry.identity.attributes & KIND_BITS)) throw new Error(`deny preflight refuses ${entry.path}: attributes changed since the scan (was 0x${entry.identity.attributes.toString(16)}, now 0x${fresh.attributes.toString(16)})`);
|
|
490
|
+
if (fresh.volumeSerial !== entry.identity.volumeSerial || fresh.fileIndex !== entry.identity.fileIndex) throw new Error(`deny preflight refuses ${entry.path}: the path was replaced by a different file object since the scan`);
|
|
491
|
+
if ((entry.identity.attributes & KIND_BITS) === 0 && fresh.links !== 1) throw hardlinkError(entry.root, entry.path, fresh.links, protectedHome);
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
//#endregion
|
|
496
|
+
//#region src/windows-broker.ts
|
|
497
|
+
/** Client of the administrator-installed deny-token broker. Requests carry
|
|
498
|
+
* only the caller process token handle and bounded deny identities.
|
|
499
|
+
*/
|
|
500
|
+
function brokerPipeName(userSid) {
|
|
501
|
+
if (!/^S-1-[0-9]+(?:-[0-9]+)+$/.test(userSid)) throw new Error("invalid broker user SID");
|
|
502
|
+
return `DshMultiRootBroker-${createHash("sha256").update(userSid).digest("hex").slice(0, 16)}`;
|
|
503
|
+
}
|
|
504
|
+
/** The broker must acknowledge before any ACL edits or process creation. The
|
|
505
|
+
* caller independently reads the deny-only groups from the token;
|
|
506
|
+
* even an impersonated pipe server cannot fabricate that kernel state.
|
|
507
|
+
*/
|
|
508
|
+
async function requestDenyToken(userSid, tokenHandle, deniedIds) {
|
|
509
|
+
if (tokenHandle <= 0n || tokenHandle > 18446744073709551615n || deniedIds.length === 0 || deniedIds.length > 256 || deniedIds.some((id) => !/^S-1-4-(?:[0-9]{1,10}-){4}3$/.test(id))) throw new Error("invalid deny-token broker request");
|
|
510
|
+
const pipe = `\\\\.\\pipe\\${brokerPipeName(userSid)}`;
|
|
511
|
+
return await new Promise((resolve, reject) => {
|
|
512
|
+
const socket = createConnection(pipe);
|
|
513
|
+
let response = "";
|
|
514
|
+
const timer = setTimeout(() => finish(/* @__PURE__ */ new Error("deny-token broker timed out")), 1e4);
|
|
515
|
+
const finish = (error, handle) => {
|
|
516
|
+
clearTimeout(timer);
|
|
517
|
+
socket.destroy();
|
|
518
|
+
if (error === void 0 && handle !== void 0) resolve(handle);
|
|
519
|
+
else reject(error ?? /* @__PURE__ */ new Error("missing broker token handle"));
|
|
520
|
+
};
|
|
521
|
+
socket.once("connect", () => {
|
|
522
|
+
socket.write(`2|${tokenHandle}|${deniedIds.join(",")}\n`);
|
|
523
|
+
});
|
|
524
|
+
socket.once("error", (error) => finish(/* @__PURE__ */ new Error(`Windows read-only roots require the installed privileged broker (${brokerPipeName(userSid)}): ${error.message}`)));
|
|
525
|
+
socket.once("end", () => {
|
|
526
|
+
if (!response.includes("\n")) finish(/* @__PURE__ */ new Error("deny-token broker disconnected"));
|
|
527
|
+
});
|
|
528
|
+
socket.on("data", (bytes) => {
|
|
529
|
+
response += bytes.toString("utf8");
|
|
530
|
+
if (response.length > 1024) return finish(/* @__PURE__ */ new Error("invalid deny-token broker response"));
|
|
531
|
+
const end = response.indexOf("\n");
|
|
532
|
+
if (end < 0) return;
|
|
533
|
+
const line = response.slice(0, end);
|
|
534
|
+
if (/^OK\|[1-9][0-9]{0,19}$/.test(line)) {
|
|
535
|
+
const handle = BigInt(line.slice(3));
|
|
536
|
+
if (handle <= 18446744073709551615n) return finish(void 0, handle);
|
|
537
|
+
}
|
|
538
|
+
finish(/* @__PURE__ */ new Error(`deny-token broker refused: ${line}`));
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/** Synchronous, bounded RPC inside the public suspended-create binding. The
|
|
543
|
+
* caller verifies the child's kernel token before the helper can resume it.
|
|
544
|
+
*/
|
|
545
|
+
function assignDenyToken(userSid, processHandle, threadHandle, deniedIds, io) {
|
|
546
|
+
if (processHandle <= 0n || threadHandle <= 0n || processHandle > 18446744073709551615n || threadHandle > 18446744073709551615n || deniedIds.length < 1 || deniedIds.length > 256 || deniedIds.some((id) => !/^S-1-4-(?:[0-9]{1,10}-){4}3$/.test(id))) throw new Error("invalid deny-token assignment request");
|
|
547
|
+
const pipe = io.open(`\\\\.\\pipe\\${brokerPipeName(userSid)}`);
|
|
548
|
+
try {
|
|
549
|
+
const deadline = Date.now() + 1e4;
|
|
550
|
+
io.write(pipe, Buffer.from(`3|${processHandle}|${threadHandle}|${deniedIds.join(",")}\n`, "ascii"));
|
|
551
|
+
let response = "";
|
|
552
|
+
while (!response.includes("\n")) {
|
|
553
|
+
if (Date.now() > deadline) throw new Error("deny-token assignment timed out");
|
|
554
|
+
const count = io.available(pipe);
|
|
555
|
+
if (count > 1024 || response.length + count > 1024) throw new Error("invalid deny-token assignment response");
|
|
556
|
+
if (count > 0) response += io.read(pipe, count).toString("utf8");
|
|
557
|
+
else io.pause();
|
|
558
|
+
}
|
|
559
|
+
if (response.slice(0, response.indexOf("\n")) !== "OK") throw new Error(`deny-token assignment refused: ${response.trim()}`);
|
|
560
|
+
} finally {
|
|
561
|
+
io.close(pipe);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/windows-deny.ts
|
|
566
|
+
/** Ambient ACL writes minus registered read-only roots (Windows B9).
|
|
567
|
+
*
|
|
568
|
+
* The restricting list mirrors the caller's enabled identities. Inherited
|
|
569
|
+
* deny-only NORMAL groups identify denied roots to ordinary deny ACEs in
|
|
570
|
+
* the NORMAL access check, so narrowing the SID list cannot discard the deny.
|
|
571
|
+
* Privileges that could bypass the DACL are removed; integrity is not lowered.
|
|
572
|
+
* Standing ACEs retain the restriction for running children and concurrent
|
|
573
|
+
* snapshots. They are never revoked during runner teardown.
|
|
574
|
+
*
|
|
575
|
+
* OWNER RIGHTS suppresses owner-implicit WRITE_DAC; the actual owner receives
|
|
576
|
+
* that right explicitly so the unrestricted host keeps it. Parent guards stop
|
|
577
|
+
* FILE_DELETE_CHILD and ACL rewrites from bypassing a child's DELETE deny.
|
|
578
|
+
* Protected descendants get explicit ACEs (inherited denies can lose to an
|
|
579
|
+
* explicit allow, or fail to reach a protected DACL). Reparse points are not
|
|
580
|
+
* followed: targets have their own canonical policy and must not be mutated.
|
|
581
|
+
*/
|
|
582
|
+
const WRITE_DAC = 262144;
|
|
583
|
+
const MUTATION = 852310;
|
|
584
|
+
async function runWindowsDenied(upstreamRunner, roots, argv, protectedHome) {
|
|
585
|
+
assertWindowsDenyCapacity(roots.length);
|
|
586
|
+
if (process.arch !== "x64" && process.arch !== "arm64") throw new Error("Windows ambient restriction requires a 64-bit process");
|
|
587
|
+
const verifyRoots = () => {
|
|
588
|
+
for (const root of roots) {
|
|
589
|
+
if (!isAbsolute(root) || isWindowsNetworkOrDevicePath(root)) throw new Error(`unsupported deny root: ${root}`);
|
|
590
|
+
if (!statSync(root).isDirectory() || canonicalPath(root).toLowerCase() !== root.toLowerCase()) throw new Error(`deny root was removed or redirected: ${root}`);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
verifyRoots();
|
|
594
|
+
const native = await loadWindowsNative(upstreamRunner);
|
|
595
|
+
const api = native.extendWin32ProcessBindings(({ kernel32, advapi32, bind }) => {
|
|
596
|
+
return Object.fromEntries(Object.entries({
|
|
597
|
+
openProcess: [
|
|
598
|
+
kernel32,
|
|
599
|
+
"void*",
|
|
600
|
+
[
|
|
601
|
+
"uint32",
|
|
602
|
+
"int",
|
|
603
|
+
"uint32"
|
|
604
|
+
]
|
|
605
|
+
],
|
|
606
|
+
openProcessToken: [
|
|
607
|
+
advapi32,
|
|
608
|
+
"int",
|
|
609
|
+
[
|
|
610
|
+
"void*",
|
|
611
|
+
"uint32",
|
|
612
|
+
"void*"
|
|
613
|
+
]
|
|
614
|
+
],
|
|
615
|
+
getTokenInformation: [
|
|
616
|
+
advapi32,
|
|
617
|
+
"int",
|
|
618
|
+
[
|
|
619
|
+
"void*",
|
|
620
|
+
"int",
|
|
621
|
+
"void*",
|
|
622
|
+
"uint32",
|
|
623
|
+
"void*"
|
|
624
|
+
]
|
|
625
|
+
],
|
|
626
|
+
setTokenInformation: [
|
|
627
|
+
advapi32,
|
|
628
|
+
"int",
|
|
629
|
+
[
|
|
630
|
+
"void*",
|
|
631
|
+
"int",
|
|
632
|
+
"void*",
|
|
633
|
+
"uint32"
|
|
634
|
+
]
|
|
635
|
+
],
|
|
636
|
+
createRestrictedToken: [
|
|
637
|
+
advapi32,
|
|
638
|
+
"int",
|
|
639
|
+
[
|
|
640
|
+
"void*",
|
|
641
|
+
"uint32",
|
|
642
|
+
"uint32",
|
|
643
|
+
"void*",
|
|
644
|
+
"uint32",
|
|
645
|
+
"void*",
|
|
646
|
+
"uint32",
|
|
647
|
+
"void*",
|
|
648
|
+
"void*"
|
|
649
|
+
]
|
|
650
|
+
],
|
|
651
|
+
createFileW: [
|
|
652
|
+
kernel32,
|
|
653
|
+
"void*",
|
|
654
|
+
[
|
|
655
|
+
"str16",
|
|
656
|
+
"uint32",
|
|
657
|
+
"uint32",
|
|
658
|
+
"void*",
|
|
659
|
+
"uint32",
|
|
660
|
+
"uint32",
|
|
661
|
+
"void*"
|
|
662
|
+
]
|
|
663
|
+
],
|
|
664
|
+
convertStringSidToSidW: [
|
|
665
|
+
advapi32,
|
|
666
|
+
"int",
|
|
667
|
+
["str16", "void*"]
|
|
668
|
+
],
|
|
669
|
+
localFree: [
|
|
670
|
+
kernel32,
|
|
671
|
+
"void*",
|
|
672
|
+
["void*"]
|
|
673
|
+
],
|
|
674
|
+
getLengthSid: [
|
|
675
|
+
advapi32,
|
|
676
|
+
"uint32",
|
|
677
|
+
["void*"]
|
|
678
|
+
],
|
|
679
|
+
equalSid: [
|
|
680
|
+
advapi32,
|
|
681
|
+
"int",
|
|
682
|
+
["void*", "void*"]
|
|
683
|
+
],
|
|
684
|
+
convertSidToStringSidW: [
|
|
685
|
+
advapi32,
|
|
686
|
+
"int",
|
|
687
|
+
["void*", "void*"]
|
|
688
|
+
],
|
|
689
|
+
getSecurityInfo: [
|
|
690
|
+
advapi32,
|
|
691
|
+
"uint32",
|
|
692
|
+
[
|
|
693
|
+
"void*",
|
|
694
|
+
"int",
|
|
695
|
+
"uint32",
|
|
696
|
+
"void*",
|
|
697
|
+
"void*",
|
|
698
|
+
"void*",
|
|
699
|
+
"void*",
|
|
700
|
+
"void*"
|
|
701
|
+
]
|
|
702
|
+
],
|
|
703
|
+
setSecurityInfo: [
|
|
704
|
+
advapi32,
|
|
705
|
+
"uint32",
|
|
706
|
+
[
|
|
707
|
+
"void*",
|
|
708
|
+
"int",
|
|
709
|
+
"uint32",
|
|
710
|
+
"void*",
|
|
711
|
+
"void*",
|
|
712
|
+
"void*",
|
|
713
|
+
"void*"
|
|
714
|
+
]
|
|
715
|
+
],
|
|
716
|
+
setEntriesInAclW: [
|
|
717
|
+
advapi32,
|
|
718
|
+
"uint32",
|
|
719
|
+
[
|
|
720
|
+
"uint32",
|
|
721
|
+
"void*",
|
|
722
|
+
"void*",
|
|
723
|
+
"void*"
|
|
724
|
+
]
|
|
725
|
+
],
|
|
726
|
+
getFileInformationByHandle: [
|
|
727
|
+
kernel32,
|
|
728
|
+
"int",
|
|
729
|
+
["void*", "void*"]
|
|
730
|
+
],
|
|
731
|
+
convertStringSecurityDescriptorToSecurityDescriptorW: [
|
|
732
|
+
advapi32,
|
|
733
|
+
"int",
|
|
734
|
+
[
|
|
735
|
+
"str16",
|
|
736
|
+
"uint32",
|
|
737
|
+
"void*",
|
|
738
|
+
"void*"
|
|
739
|
+
]
|
|
740
|
+
],
|
|
741
|
+
getSecurityDescriptorDacl: [
|
|
742
|
+
advapi32,
|
|
743
|
+
"int",
|
|
744
|
+
[
|
|
745
|
+
"void*",
|
|
746
|
+
"void*",
|
|
747
|
+
"void*",
|
|
748
|
+
"void*"
|
|
749
|
+
]
|
|
750
|
+
],
|
|
751
|
+
initializeAcl: [
|
|
752
|
+
advapi32,
|
|
753
|
+
"int",
|
|
754
|
+
[
|
|
755
|
+
"void*",
|
|
756
|
+
"uint32",
|
|
757
|
+
"uint32"
|
|
758
|
+
]
|
|
759
|
+
],
|
|
760
|
+
getAce: [
|
|
761
|
+
advapi32,
|
|
762
|
+
"int",
|
|
763
|
+
[
|
|
764
|
+
"void*",
|
|
765
|
+
"uint32",
|
|
766
|
+
"void*"
|
|
767
|
+
]
|
|
768
|
+
],
|
|
769
|
+
addAce: [
|
|
770
|
+
advapi32,
|
|
771
|
+
"int",
|
|
772
|
+
[
|
|
773
|
+
"void*",
|
|
774
|
+
"uint32",
|
|
775
|
+
"uint32",
|
|
776
|
+
"void*",
|
|
777
|
+
"uint32"
|
|
778
|
+
]
|
|
779
|
+
],
|
|
780
|
+
getTempPathW: [
|
|
781
|
+
kernel32,
|
|
782
|
+
"uint32",
|
|
783
|
+
["uint32", "void*"]
|
|
784
|
+
],
|
|
785
|
+
getPackageFullName: [
|
|
786
|
+
kernel32,
|
|
787
|
+
"int32",
|
|
788
|
+
[
|
|
789
|
+
"void*",
|
|
790
|
+
"void*",
|
|
791
|
+
"void*"
|
|
792
|
+
]
|
|
793
|
+
],
|
|
794
|
+
writeFile: [
|
|
795
|
+
kernel32,
|
|
796
|
+
"int",
|
|
797
|
+
[
|
|
798
|
+
"void*",
|
|
799
|
+
"void*",
|
|
800
|
+
"uint32",
|
|
801
|
+
"void*",
|
|
802
|
+
"void*"
|
|
803
|
+
]
|
|
804
|
+
],
|
|
805
|
+
readFile: [
|
|
806
|
+
kernel32,
|
|
807
|
+
"int",
|
|
808
|
+
[
|
|
809
|
+
"void*",
|
|
810
|
+
"void*",
|
|
811
|
+
"uint32",
|
|
812
|
+
"void*",
|
|
813
|
+
"void*"
|
|
814
|
+
]
|
|
815
|
+
],
|
|
816
|
+
peekNamedPipe: [
|
|
817
|
+
kernel32,
|
|
818
|
+
"int",
|
|
819
|
+
[
|
|
820
|
+
"void*",
|
|
821
|
+
"void*",
|
|
822
|
+
"uint32",
|
|
823
|
+
"void*",
|
|
824
|
+
"void*",
|
|
825
|
+
"void*"
|
|
826
|
+
]
|
|
827
|
+
],
|
|
828
|
+
sleep: [
|
|
829
|
+
kernel32,
|
|
830
|
+
"void",
|
|
831
|
+
["uint32"]
|
|
832
|
+
],
|
|
833
|
+
lockFileEx: [
|
|
834
|
+
kernel32,
|
|
835
|
+
"int",
|
|
836
|
+
[
|
|
837
|
+
"void*",
|
|
838
|
+
"uint32",
|
|
839
|
+
"uint32",
|
|
840
|
+
"uint32",
|
|
841
|
+
"uint32",
|
|
842
|
+
"void*"
|
|
843
|
+
]
|
|
844
|
+
],
|
|
845
|
+
unlockFileEx: [
|
|
846
|
+
kernel32,
|
|
847
|
+
"int",
|
|
848
|
+
[
|
|
849
|
+
"void*",
|
|
850
|
+
"uint32",
|
|
851
|
+
"uint32",
|
|
852
|
+
"uint32",
|
|
853
|
+
"void*"
|
|
854
|
+
]
|
|
855
|
+
]
|
|
856
|
+
}).map(([name, [library, result, args]]) => [name, bind(library, name[0].toUpperCase() + name.slice(1), result, args)]));
|
|
857
|
+
});
|
|
858
|
+
const pointer = (buffer, offset = 0) => native.koffi.decode(buffer.subarray(offset), "void*");
|
|
859
|
+
const check = (ok, name) => {
|
|
860
|
+
if (ok === 0) throw new Error(`${name} failed (Win32 ${api.getLastError()})`);
|
|
861
|
+
};
|
|
862
|
+
const status = (code, name) => {
|
|
863
|
+
if (code !== 0) throw new Error(`${name} failed (Win32 ${code})`);
|
|
864
|
+
};
|
|
865
|
+
const parsedSids = [];
|
|
866
|
+
const sid = (text) => {
|
|
867
|
+
const out = Buffer.alloc(8);
|
|
868
|
+
check(api.convertStringSidToSidW(text, out), "ConvertStringSidToSidW");
|
|
869
|
+
const value = pointer(out);
|
|
870
|
+
parsedSids.push(value);
|
|
871
|
+
return value;
|
|
872
|
+
};
|
|
873
|
+
const writePointer = (buffer, offset, value) => {
|
|
874
|
+
buffer.writeBigUInt64LE(native.koffi.address(value), offset);
|
|
875
|
+
};
|
|
876
|
+
const info = (token, cls) => {
|
|
877
|
+
const size = Buffer.alloc(4);
|
|
878
|
+
api.getTokenInformation(token, cls, null, 0, size);
|
|
879
|
+
const length = size.readUInt32LE();
|
|
880
|
+
if (length < 8 || length > 1048576) throw new Error(`invalid TokenInformation size: ${length}`);
|
|
881
|
+
const data = Buffer.alloc(length);
|
|
882
|
+
check(api.getTokenInformation(token, cls, data, length, size), "GetTokenInformation");
|
|
883
|
+
return data;
|
|
884
|
+
};
|
|
885
|
+
const retained = [];
|
|
886
|
+
let currentToken;
|
|
887
|
+
let restrictedToken;
|
|
888
|
+
let pathGuard;
|
|
889
|
+
let primary;
|
|
890
|
+
let threw = false;
|
|
891
|
+
let exitCode = 0;
|
|
892
|
+
try {
|
|
893
|
+
pathGuard = pinWindowsDenyPaths(native, roots);
|
|
894
|
+
const preflight = preflightWindowsDenyRoots(native, roots, protectedHome);
|
|
895
|
+
const processHandle = api.openProcess(1024, 0, process.pid);
|
|
896
|
+
check(Number(processHandle !== null && processHandle !== 0), "OpenProcess");
|
|
897
|
+
const out = Buffer.alloc(8);
|
|
898
|
+
try {
|
|
899
|
+
check(api.openProcessToken(processHandle, 262283, out), "OpenProcessToken");
|
|
900
|
+
} finally {
|
|
901
|
+
check(api.closeHandle(processHandle), "CloseHandle(process)");
|
|
902
|
+
}
|
|
903
|
+
currentToken = pointer(out);
|
|
904
|
+
const user = info(currentToken, 1);
|
|
905
|
+
const groups = info(currentToken, 2);
|
|
906
|
+
retained.push(user, groups);
|
|
907
|
+
const userSid = pointer(user);
|
|
908
|
+
const identities = [userSid];
|
|
909
|
+
const owners = [userSid];
|
|
910
|
+
const count = groups.readUInt32LE();
|
|
911
|
+
if (count > (groups.length - 8) / 16) throw new Error("truncated TokenGroups");
|
|
912
|
+
for (let i = 0; i < count; i++) {
|
|
913
|
+
const offset = 8 + i * 16;
|
|
914
|
+
const attributes = groups.readUInt32LE(offset + 8);
|
|
915
|
+
if ((attributes & 4) === 0 || (attributes & 16) !== 0) continue;
|
|
916
|
+
const group = pointer(groups, offset);
|
|
917
|
+
identities.push(group);
|
|
918
|
+
if ((attributes & 8) !== 0) owners.push(group);
|
|
919
|
+
}
|
|
920
|
+
const ownerRights = sid("S-1-3-4");
|
|
921
|
+
const denies = roots.map(rootDenySid);
|
|
922
|
+
const list = Buffer.alloc(identities.length * 16);
|
|
923
|
+
identities.forEach((value, i) => writePointer(list, i * 16, value));
|
|
924
|
+
const sidOut = Buffer.alloc(8);
|
|
925
|
+
check(api.convertSidToStringSidW(userSid, sidOut), "ConvertSidToStringSidW");
|
|
926
|
+
const text = pointer(sidOut);
|
|
927
|
+
let minted;
|
|
928
|
+
let brokerUser;
|
|
929
|
+
try {
|
|
930
|
+
brokerUser = readWindowsWideString(native.koffi, text);
|
|
931
|
+
const handle = await requestDenyToken(brokerUser, native.koffi.address(currentToken), denies);
|
|
932
|
+
const bytes = Buffer.alloc(8);
|
|
933
|
+
bytes.writeBigUInt64LE(handle);
|
|
934
|
+
minted = pointer(bytes);
|
|
935
|
+
} finally {
|
|
936
|
+
api.localFree(text);
|
|
937
|
+
}
|
|
938
|
+
const restrictedOut = Buffer.alloc(8);
|
|
939
|
+
try {
|
|
940
|
+
check(api.createRestrictedToken(minted, 1, 0, null, 0, null, identities.length, list, restrictedOut), "CreateRestrictedToken");
|
|
941
|
+
} finally {
|
|
942
|
+
api.closeHandle(minted);
|
|
943
|
+
}
|
|
944
|
+
restrictedToken = pointer(restrictedOut);
|
|
945
|
+
const verifyDenyGroups = (token) => {
|
|
946
|
+
const denyGroups = info(token, 2);
|
|
947
|
+
const denyCount = denyGroups.readUInt32LE();
|
|
948
|
+
if (denyCount > (denyGroups.length - 8) / 16) throw new Error("truncated deny TokenGroups");
|
|
949
|
+
const verified = /* @__PURE__ */ new Set();
|
|
950
|
+
for (let i = 0; i < denyCount; i++) {
|
|
951
|
+
const offset = 8 + i * 16;
|
|
952
|
+
const flags = denyGroups.readUInt32LE(offset + 8);
|
|
953
|
+
if ((flags & 16) === 0 || (flags & 6) !== 0) continue;
|
|
954
|
+
const valueOut = Buffer.alloc(8);
|
|
955
|
+
check(api.convertSidToStringSidW(pointer(denyGroups, offset), valueOut), "ConvertSidToStringSidW(group)");
|
|
956
|
+
const value = pointer(valueOut);
|
|
957
|
+
try {
|
|
958
|
+
verified.add(readWindowsWideString(native.koffi, value));
|
|
959
|
+
} finally {
|
|
960
|
+
api.localFree(value);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (denies.some((id) => !verified.has(id))) throw new Error("broker did not install the deny-only normal groups");
|
|
964
|
+
};
|
|
965
|
+
verifyDenyGroups(restrictedToken);
|
|
966
|
+
const entry = (trustee, mode, mask, inheritance) => {
|
|
967
|
+
const data = Buffer.alloc(48);
|
|
968
|
+
data.writeUInt32LE(mask, 0);
|
|
969
|
+
data.writeUInt32LE(mode, 4);
|
|
970
|
+
data.writeUInt32LE(inheritance, 8);
|
|
971
|
+
writePointer(data, 40, trustee);
|
|
972
|
+
return data;
|
|
973
|
+
};
|
|
974
|
+
const tokenAclOut = Buffer.alloc(8);
|
|
975
|
+
status(api.setEntriesInAclW(3, Buffer.concat([
|
|
976
|
+
entry(userSid, 3, 786656, 0),
|
|
977
|
+
entry(userSid, 1, 268435456, 0),
|
|
978
|
+
entry(ownerRights, 1, 131072, 0)
|
|
979
|
+
]), null, tokenAclOut), "SetEntriesInAclW(token)");
|
|
980
|
+
const tokenAcl = pointer(tokenAclOut);
|
|
981
|
+
try {
|
|
982
|
+
status(api.setSecurityInfo(restrictedToken, 6, 4, null, null, tokenAcl, null), "SetSecurityInfo(token)");
|
|
983
|
+
const defaultDacl = Buffer.alloc(8);
|
|
984
|
+
writePointer(defaultDacl, 0, tokenAcl);
|
|
985
|
+
check(api.setTokenInformation(restrictedToken, 6, defaultDacl, 8), "SetTokenInformation(TokenDefaultDacl)");
|
|
986
|
+
} finally {
|
|
987
|
+
api.localFree(tokenAcl);
|
|
988
|
+
}
|
|
989
|
+
const tempPath = Buffer.alloc(65536);
|
|
990
|
+
const tempLength = api.getTempPathW(32768, tempPath);
|
|
991
|
+
if (tempLength === 0 || tempLength >= 32768) throw new Error("GetTempPathW failed");
|
|
992
|
+
const lockDirectory = join(tempPath.subarray(0, tempLength * 2).toString("utf16le"), "dsh-acl-locks");
|
|
993
|
+
mkdirSync(lockDirectory, { recursive: true });
|
|
994
|
+
const protect = (path, denyId, mask, inheritance, verifyContent = true) => {
|
|
995
|
+
const lockPath = join(lockDirectory, `${createHash("sha256").update(path.toLowerCase()).digest("hex").slice(0, 16)}.lock`);
|
|
996
|
+
const lock = api.createFileW(lockPath, 3221225472, 3, null, 4, 0, null);
|
|
997
|
+
if (lock === null || BigInt(lock) === -1n || BigInt(lock) === 18446744073709551615n) throw new Error(`CreateFileW(lock) failed (Win32 ${api.getLastError()})`);
|
|
998
|
+
const overlapped = native.koffi.alloc("uint8", 32);
|
|
999
|
+
native.koffi.encode(overlapped, "uint8", Buffer.alloc(32), 32);
|
|
1000
|
+
let held = false;
|
|
1001
|
+
try {
|
|
1002
|
+
check(api.lockFileEx(lock, 2, 0, 1, 0, overlapped), `LockFileEx(${path})`);
|
|
1003
|
+
held = true;
|
|
1004
|
+
const ownerOut = Buffer.alloc(8);
|
|
1005
|
+
const aclOut = Buffer.alloc(8);
|
|
1006
|
+
const sdOut = Buffer.alloc(8);
|
|
1007
|
+
const handle = api.createFileW(path, 393216, 7, null, 3, 35651584, null);
|
|
1008
|
+
if (handle === null || BigInt(handle) === -1n || BigInt(handle) === 18446744073709551615n) throw new Error(`CreateFileW(${path}) failed (Win32 ${api.getLastError()})`);
|
|
1009
|
+
if (verifyContent) try {
|
|
1010
|
+
preflight.verifyHandle(api, path, handle);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
api.closeHandle(handle);
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
try {
|
|
1016
|
+
status(api.getSecurityInfo(handle, 1, 5, ownerOut, null, aclOut, null, sdOut), `GetSecurityInfo(${path})`);
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
api.closeHandle(handle);
|
|
1019
|
+
throw error;
|
|
1020
|
+
}
|
|
1021
|
+
const descriptor = pointer(sdOut);
|
|
1022
|
+
let merged;
|
|
1023
|
+
try {
|
|
1024
|
+
const owner = pointer(ownerOut);
|
|
1025
|
+
if (!owners.some((value) => api.equalSid(owner, value) !== 0)) throw new Error(`deny ACL requires caller-owned path: ${path}`);
|
|
1026
|
+
const oldAcl = pointer(aclOut);
|
|
1027
|
+
if (oldAcl === null || oldAcl === 0n) throw new Error(`deny ACL cannot preserve a null DACL: ${path}`);
|
|
1028
|
+
const edits = Buffer.concat([entry(ownerRights, 1, 131072, inheritance), entry(owner, 1, WRITE_DAC, inheritance)]);
|
|
1029
|
+
const mergedOut = Buffer.alloc(8);
|
|
1030
|
+
status(api.setEntriesInAclW(2, edits, oldAcl, mergedOut), `SetEntriesInAclW(${path})`);
|
|
1031
|
+
merged = pointer(mergedOut);
|
|
1032
|
+
const descriptorOut = Buffer.alloc(8);
|
|
1033
|
+
const flags = inheritance === 3 ? "OICI" : "";
|
|
1034
|
+
check(api.convertStringSecurityDescriptorToSecurityDescriptorW(`D:(D;${flags};0x${mask.toString(16)};;;${denyId})`, 1, descriptorOut, null), "ConvertStringSecurityDescriptorToSecurityDescriptorW(normal-group deny)");
|
|
1035
|
+
const conditionalDescriptor = pointer(descriptorOut);
|
|
1036
|
+
try {
|
|
1037
|
+
const present = Buffer.alloc(4), acl = Buffer.alloc(8), defaulted = Buffer.alloc(4);
|
|
1038
|
+
check(api.getSecurityDescriptorDacl(conditionalDescriptor, present, acl, defaulted), "GetSecurityDescriptorDacl");
|
|
1039
|
+
const first = Buffer.alloc(8);
|
|
1040
|
+
check(api.getAce(pointer(acl), 0, first), "GetAce(normal-group deny)");
|
|
1041
|
+
const header = Buffer.from(native.koffi.decode(pointer(first), "uint8", 4));
|
|
1042
|
+
const denyBytes = Buffer.from(native.koffi.decode(pointer(first), "uint8", header.readUInt16LE(2)));
|
|
1043
|
+
const count = Buffer.from(native.koffi.decode(merged, "uint8", 8)).readUInt16LE(4);
|
|
1044
|
+
const others = [];
|
|
1045
|
+
for (let index = 0; index < count; index++) {
|
|
1046
|
+
const ace = Buffer.alloc(8);
|
|
1047
|
+
check(api.getAce(merged, index, ace), "GetAce(existing)");
|
|
1048
|
+
const h = Buffer.from(native.koffi.decode(pointer(ace), "uint8", 4));
|
|
1049
|
+
const bytes = Buffer.from(native.koffi.decode(pointer(ace), "uint8", h.readUInt16LE(2)));
|
|
1050
|
+
if (!bytes.equals(denyBytes)) others.push(bytes);
|
|
1051
|
+
}
|
|
1052
|
+
const size = 8 + denyBytes.length + others.reduce((total, bytes) => total + bytes.length, 0);
|
|
1053
|
+
if (size > 65535) throw new Error(`deny ACL exceeds the Windows ACL size limit: ${path}`);
|
|
1054
|
+
const combined = Buffer.alloc(size);
|
|
1055
|
+
check(api.initializeAcl(combined, size, 4), "InitializeAcl");
|
|
1056
|
+
for (const bytes of [denyBytes, ...others]) check(api.addAce(combined, 4, 4294967295, bytes, bytes.length), "AddAce");
|
|
1057
|
+
if (verifyContent) preflight.verifyHandle(api, path, handle);
|
|
1058
|
+
status(api.setSecurityInfo(handle, 1, 4, null, null, combined, null), `SetSecurityInfo(${path})`);
|
|
1059
|
+
} finally {
|
|
1060
|
+
api.localFree(conditionalDescriptor);
|
|
1061
|
+
}
|
|
1062
|
+
} finally {
|
|
1063
|
+
if (merged !== void 0) api.localFree(merged);
|
|
1064
|
+
api.localFree(descriptor);
|
|
1065
|
+
check(api.closeHandle(handle), "CloseHandle(path)");
|
|
1066
|
+
}
|
|
1067
|
+
} finally {
|
|
1068
|
+
try {
|
|
1069
|
+
if (held) check(api.unlockFileEx(lock, 0, 1, 0, overlapped), "UnlockFileEx");
|
|
1070
|
+
} finally {
|
|
1071
|
+
native.koffi.free(overlapped);
|
|
1072
|
+
check(api.closeHandle(lock), "CloseHandle(lock)");
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
const expectedChildren = /* @__PURE__ */ new Map();
|
|
1077
|
+
for (const scanned of preflight.entries.values()) {
|
|
1078
|
+
const parent = dirname(scanned.path).toLowerCase();
|
|
1079
|
+
if (parent === scanned.path.toLowerCase()) continue;
|
|
1080
|
+
const names = expectedChildren.get(parent) ?? /* @__PURE__ */ new Set();
|
|
1081
|
+
names.add(basename(scanned.path));
|
|
1082
|
+
expectedChildren.set(parent, names);
|
|
1083
|
+
}
|
|
1084
|
+
const denyIdFor = new Map(roots.map((root, i) => [root.toLowerCase(), denies[i]]));
|
|
1085
|
+
for (const scanned of preflight.entries.values()) {
|
|
1086
|
+
const isPlainDirectory = (scanned.identity.attributes & 1040) === 16;
|
|
1087
|
+
if (isPlainDirectory) {
|
|
1088
|
+
let names;
|
|
1089
|
+
try {
|
|
1090
|
+
names = readdirSync(scanned.path).sort();
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
throw new Error(`deny tree changed during ACL writes: cannot list ${scanned.path} (${error instanceof Error ? error.message : String(error)})`);
|
|
1093
|
+
}
|
|
1094
|
+
const expected = expectedChildren.get(scanned.path.toLowerCase()) ?? /* @__PURE__ */ new Set();
|
|
1095
|
+
if (names.length !== expected.size || names.some((name) => !expected.has(name))) throw new Error(`deny tree changed since the preflight scan: ${scanned.path}`);
|
|
1096
|
+
}
|
|
1097
|
+
protect(scanned.path, denyIdFor.get(scanned.root.toLowerCase()), MUTATION, isPlainDirectory ? 3 : 0);
|
|
1098
|
+
}
|
|
1099
|
+
roots.forEach((root, i) => {
|
|
1100
|
+
protect(dirname(root), denies[i], 786496, 0, false);
|
|
1101
|
+
});
|
|
1102
|
+
verifyRoots();
|
|
1103
|
+
process.on("SIGINT", () => {});
|
|
1104
|
+
const control = process.env.DSH_SUBPROCESS_CONTROL === "pipe";
|
|
1105
|
+
const child = spawnWindowsDeniedChild(native, api, {
|
|
1106
|
+
token: restrictedToken,
|
|
1107
|
+
command: argv[0],
|
|
1108
|
+
args: argv.slice(1),
|
|
1109
|
+
cwd: process.cwd(),
|
|
1110
|
+
...control ? { controlFileDescriptor: 7 } : {}
|
|
1111
|
+
}, {
|
|
1112
|
+
sourceToken: currentToken,
|
|
1113
|
+
assign(child, thread) {
|
|
1114
|
+
const packageNameLength = Buffer.alloc(4);
|
|
1115
|
+
const packageProbe = api.getPackageFullName(child, packageNameLength, null);
|
|
1116
|
+
if (packageProbe === 122) {
|
|
1117
|
+
const nameBuffer = Buffer.alloc(packageNameLength.readUInt32LE() * 2);
|
|
1118
|
+
const read = api.getPackageFullName(child, packageNameLength, nameBuffer);
|
|
1119
|
+
const fullName = read === 0 ? nameBuffer.subarray(0, (packageNameLength.readUInt32LE() - 1) * 2).toString("utf16le") : `<unreadable, Win32 ${read}>`;
|
|
1120
|
+
throw new Error(`deny launch refused: ${argv[0]} started as packaged (MSIX/Microsoft Store) application ${fullName}; a packaged process carries a package identity the deny token cannot preserve. Install the MSI or ZIP build of PowerShell (or use a non-packaged executable) and put it ahead of the WindowsApps alias on PATH`);
|
|
1121
|
+
}
|
|
1122
|
+
if (packageProbe !== 15700) throw new Error(`GetPackageFullName(child) failed (Win32 ${packageProbe})`);
|
|
1123
|
+
assignDenyToken(brokerUser, native.koffi.address(child), native.koffi.address(thread), denies, {
|
|
1124
|
+
open(pipe) {
|
|
1125
|
+
const handle = api.createFileW(pipe, 3221225472, 0, null, 3, 0, null);
|
|
1126
|
+
if (handle === null || BigInt(handle) === -1n || BigInt(handle) === 18446744073709551615n) throw new Error(`Open broker assignment pipe failed (Win32 ${api.getLastError()})`);
|
|
1127
|
+
return handle;
|
|
1128
|
+
},
|
|
1129
|
+
write(pipe, bytes) {
|
|
1130
|
+
const count = Buffer.alloc(4);
|
|
1131
|
+
check(api.writeFile(pipe, bytes, bytes.length, count, null), "WriteFile(broker)");
|
|
1132
|
+
if (count.readUInt32LE() !== bytes.length) throw new Error("incomplete broker assignment write");
|
|
1133
|
+
},
|
|
1134
|
+
available(pipe) {
|
|
1135
|
+
const count = Buffer.alloc(4);
|
|
1136
|
+
check(api.peekNamedPipe(pipe, null, 0, null, count, null), "PeekNamedPipe(broker)");
|
|
1137
|
+
return count.readUInt32LE();
|
|
1138
|
+
},
|
|
1139
|
+
read(pipe, length) {
|
|
1140
|
+
const data = Buffer.alloc(length), count = Buffer.alloc(4);
|
|
1141
|
+
check(api.readFile(pipe, data, length, count, null), "ReadFile(broker)");
|
|
1142
|
+
if (count.readUInt32LE() === 0) throw new Error("broker assignment disconnected");
|
|
1143
|
+
return data.subarray(0, count.readUInt32LE());
|
|
1144
|
+
},
|
|
1145
|
+
pause() {
|
|
1146
|
+
api.sleep(10);
|
|
1147
|
+
},
|
|
1148
|
+
close(pipe) {
|
|
1149
|
+
api.closeHandle(pipe);
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
const out = Buffer.alloc(8);
|
|
1153
|
+
check(api.openProcessToken(child, 8, out), "OpenProcessToken(assigned child)");
|
|
1154
|
+
const token = pointer(out);
|
|
1155
|
+
try {
|
|
1156
|
+
verifyDenyGroups(token);
|
|
1157
|
+
} finally {
|
|
1158
|
+
api.closeHandle(token);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
let waitError;
|
|
1163
|
+
let waitThrew = false;
|
|
1164
|
+
let waited = false;
|
|
1165
|
+
try {
|
|
1166
|
+
if (control) closeSync(7);
|
|
1167
|
+
waited = true;
|
|
1168
|
+
exitCode = native.waitForProcessExit(api, child.process);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
waitError = error;
|
|
1171
|
+
waitThrew = true;
|
|
1172
|
+
}
|
|
1173
|
+
const drainErrors = [];
|
|
1174
|
+
try {
|
|
1175
|
+
terminateWindowsJobAndWait(native, api, child.job);
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
drainErrors.push(error instanceof Error ? error : new Error(String(error)));
|
|
1178
|
+
}
|
|
1179
|
+
try {
|
|
1180
|
+
check(api.closeHandle(child.job), "CloseHandle(job)");
|
|
1181
|
+
} catch (error) {
|
|
1182
|
+
drainErrors.push(error instanceof Error ? error : new Error(String(error)));
|
|
1183
|
+
}
|
|
1184
|
+
if (!waited) try {
|
|
1185
|
+
check(api.closeHandle(child.process), "CloseHandle(child process)");
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
drainErrors.push(error instanceof Error ? error : new Error(String(error)));
|
|
1188
|
+
}
|
|
1189
|
+
if (waitThrew || drainErrors.length > 0) {
|
|
1190
|
+
const all = [...waitThrew ? [waitError instanceof Error ? waitError : new Error(String(waitError))] : [], ...drainErrors];
|
|
1191
|
+
if (all.length === 1) throw all[0];
|
|
1192
|
+
throw new AggregateError(all, all[0].message);
|
|
1193
|
+
}
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
primary = error;
|
|
1196
|
+
threw = true;
|
|
1197
|
+
}
|
|
1198
|
+
const cleanupErrors = [];
|
|
1199
|
+
const attempt = (operation) => {
|
|
1200
|
+
try {
|
|
1201
|
+
operation();
|
|
1202
|
+
} catch (error) {
|
|
1203
|
+
cleanupErrors.push(error instanceof Error ? error : new Error(String(error)));
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
attempt(() => {
|
|
1207
|
+
if (restrictedToken !== void 0) check(api.closeHandle(restrictedToken), "CloseHandle(restricted token)");
|
|
1208
|
+
});
|
|
1209
|
+
attempt(() => {
|
|
1210
|
+
if (currentToken !== void 0) check(api.closeHandle(currentToken), "CloseHandle(token)");
|
|
1211
|
+
});
|
|
1212
|
+
for (const value of parsedSids) attempt(() => api.localFree(value));
|
|
1213
|
+
retained.length = 0;
|
|
1214
|
+
attempt(() => pathGuard?.close());
|
|
1215
|
+
if (threw) {
|
|
1216
|
+
if (cleanupErrors.length === 0) throw primary;
|
|
1217
|
+
throw new AggregateError([primary instanceof Error ? primary : new Error(String(primary)), ...cleanupErrors], primary instanceof Error ? primary.message : String(primary));
|
|
1218
|
+
}
|
|
1219
|
+
if (cleanupErrors.length === 1) throw cleanupErrors[0];
|
|
1220
|
+
if (cleanupErrors.length > 1) throw new AggregateError(cleanupErrors, "Windows ambient deny cleanup failed");
|
|
1221
|
+
return exitCode;
|
|
1222
|
+
}
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region src/windows-runner.ts
|
|
1225
|
+
/**
|
|
1226
|
+
* Standalone runner: public upstream AclSandbox with a capability for the
|
|
1227
|
+
* whole root set.
|
|
1228
|
+
*
|
|
1229
|
+
* This file is built into `lib/windows-runner.js` and executed as a SEPARATE
|
|
1230
|
+
* Node process. The host's in-process plugin package resolution does not
|
|
1231
|
+
* apply there, so this module (and everything it statically imports — only
|
|
1232
|
+
* `windows-runner-contract.ts`) may import `node:` builtins exclusively. The
|
|
1233
|
+
* upstream ACL package is reached dynamically through `--upstream-runner`:
|
|
1234
|
+
* `createRequire(<upstream runner>).resolve('@deepseek-ai/dsh-sandbox-windows-acl')
|
|
1235
|
+
* resolves the package by self-reference from its own install location — the
|
|
1236
|
+
* one anchor guaranteed to see it.
|
|
1237
|
+
*/
|
|
1238
|
+
async function main() {
|
|
1239
|
+
if (process.platform !== "win32") throw new Error("Windows ACL runner requires Windows");
|
|
1240
|
+
const raw = process.argv.slice(2);
|
|
1241
|
+
const separator = raw.indexOf("--");
|
|
1242
|
+
if (separator < 0 || raw[separator + 1] === void 0) throw new Error("missing command after --");
|
|
1243
|
+
const profile = parseWindowsProfile(raw.slice(0, separator), true);
|
|
1244
|
+
if (profile.mode === "danger-full-access") return await runWindowsDenied(profile.upstreamRunner, profile.deniedRoots, raw.slice(separator + 1), profile.protectedHome);
|
|
1245
|
+
const roots = profile.mode === "read-only" ? [...new Set(profile.additionalRoots)] : [.../* @__PURE__ */ new Set([profile.workspace, ...profile.additionalRoots])];
|
|
1246
|
+
const verifyRoots = () => {
|
|
1247
|
+
for (const root of roots) {
|
|
1248
|
+
if (isWindowsNetworkOrDevicePath(root)) throw new Error(`Windows ACL confinement cannot cover network or device paths: ${root}`);
|
|
1249
|
+
if (!statSync(root).isDirectory() || canonicalPath(root) !== root) throw new Error(`workspace root was removed or redirected: ${root}`);
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
verifyRoots();
|
|
1253
|
+
if (!existsSync(profile.temp) || !statSync(profile.temp).isDirectory()) throw new Error(`--temp is not an existing directory: ${profile.temp}`);
|
|
1254
|
+
const aclEntry = createRequire(profile.upstreamRunner).resolve("@deepseek-ai/dsh-sandbox-windows-acl");
|
|
1255
|
+
const { AclSandbox, assertTempRootOutsideWorkspace, workspaceWriteSid, tempWriteSid } = await import(pathToFileURL(aclEntry).href);
|
|
1256
|
+
if (profile.writeSid !== void 0 && (profile.writeSid !== workspaceWriteSid(profile.workspace) || profile.tempWriteSid !== tempWriteSid(profile.temp))) throw new Error("upstream capability SIDs do not match their paths");
|
|
1257
|
+
for (const root of roots) assertTempRootOutsideWorkspace(root, profile.temp);
|
|
1258
|
+
const temp = profile.mode === "workspace-write" ? mkdtempSync(join(profile.temp, "dsh-multi-root-")) : null;
|
|
1259
|
+
const sandbox = new AclSandbox({
|
|
1260
|
+
writableDirs: roots,
|
|
1261
|
+
tempDir: temp,
|
|
1262
|
+
writeSid: workspaceScopeSid(roots),
|
|
1263
|
+
...temp === null ? {} : { tempWriteSid: tempWriteSid(temp) },
|
|
1264
|
+
mode: "workspace-write"
|
|
1265
|
+
});
|
|
1266
|
+
let initialized = false;
|
|
1267
|
+
try {
|
|
1268
|
+
await sandbox.init();
|
|
1269
|
+
initialized = true;
|
|
1270
|
+
verifyRoots();
|
|
1271
|
+
if (temp !== null) {
|
|
1272
|
+
process.env.TMP = temp;
|
|
1273
|
+
process.env.TEMP = temp;
|
|
1274
|
+
}
|
|
1275
|
+
process.on("SIGINT", () => {});
|
|
1276
|
+
const control = process.env.DSH_SUBPROCESS_CONTROL === "pipe";
|
|
1277
|
+
const child = sandbox.spawn({
|
|
1278
|
+
command: raw[separator + 1],
|
|
1279
|
+
args: raw.slice(separator + 2),
|
|
1280
|
+
stdio: "inherit",
|
|
1281
|
+
...control ? { controlFileDescriptor: 7 } : {}
|
|
1282
|
+
});
|
|
1283
|
+
if (control) closeSync(7);
|
|
1284
|
+
return (await child.wait()).exitCode;
|
|
1285
|
+
} finally {
|
|
1286
|
+
try {
|
|
1287
|
+
if (initialized) sandbox.dispose();
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
reportCleanup(error);
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
if (temp !== null) rmSync(temp, {
|
|
1293
|
+
recursive: true,
|
|
1294
|
+
force: true
|
|
1295
|
+
});
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
reportCleanup(error);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
function reportCleanup(error) {
|
|
1302
|
+
process.stderr.write(`windows-acl-run: cleanup: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1303
|
+
}
|
|
1304
|
+
main().then((code) => {
|
|
1305
|
+
process.exitCode = code;
|
|
1306
|
+
}, (error) => {
|
|
1307
|
+
process.stderr.write(`windows-acl-run: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1308
|
+
process.exitCode = 127;
|
|
1309
|
+
});
|
|
1310
|
+
//#endregion
|
|
1311
|
+
export {};
|