@bo-agent/sandbox-windows-acl 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/lib/.tsbuildinfo +1 -0
  2. package/lib/acl.d.ts +83 -0
  3. package/lib/acl.d.ts.map +1 -0
  4. package/lib/acl.js +255 -0
  5. package/lib/acl.js.map +1 -0
  6. package/lib/errors.d.ts +15 -0
  7. package/lib/errors.d.ts.map +1 -0
  8. package/lib/errors.js +20 -0
  9. package/lib/errors.js.map +1 -0
  10. package/lib/ffi.d.ts +252 -0
  11. package/lib/ffi.d.ts.map +1 -0
  12. package/lib/ffi.js +382 -0
  13. package/lib/ffi.js.map +1 -0
  14. package/lib/grant.d.ts +60 -0
  15. package/lib/grant.d.ts.map +1 -0
  16. package/lib/grant.js +102 -0
  17. package/lib/grant.js.map +1 -0
  18. package/lib/index.d.ts +168 -0
  19. package/lib/index.d.ts.map +1 -0
  20. package/lib/index.js +349 -0
  21. package/lib/index.js.map +1 -0
  22. package/lib/invariant.d.ts +15 -0
  23. package/lib/invariant.d.ts.map +1 -0
  24. package/lib/invariant.js +17 -0
  25. package/lib/invariant.js.map +1 -0
  26. package/lib/path-boundary.d.ts +20 -0
  27. package/lib/path-boundary.d.ts.map +1 -0
  28. package/lib/path-boundary.js +37 -0
  29. package/lib/path-boundary.js.map +1 -0
  30. package/lib/runner.d.ts +47 -0
  31. package/lib/runner.d.ts.map +1 -0
  32. package/lib/runner.js +222 -0
  33. package/lib/runner.js.map +1 -0
  34. package/lib/spawn.d.ts +103 -0
  35. package/lib/spawn.d.ts.map +1 -0
  36. package/lib/spawn.js +307 -0
  37. package/lib/spawn.js.map +1 -0
  38. package/lib/token.d.ts +91 -0
  39. package/lib/token.d.ts.map +1 -0
  40. package/lib/token.js +200 -0
  41. package/lib/token.js.map +1 -0
  42. package/lib/win32-abi.d.ts +178 -0
  43. package/lib/win32-abi.d.ts.map +1 -0
  44. package/lib/win32-abi.js +230 -0
  45. package/lib/win32-abi.js.map +1 -0
  46. package/lib/workspace-sid.d.ts +41 -0
  47. package/lib/workspace-sid.d.ts.map +1 -0
  48. package/lib/workspace-sid.js +52 -0
  49. package/lib/workspace-sid.js.map +1 -0
  50. package/package.json +33 -0
package/lib/runner.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The windows-acl confinement runner: the argv-prefix wrapper the sandbox
3
+ * seam spawns in place of the caller's command. It creates the
4
+ * WRITE_RESTRICTED token with the workspace write-SID allowlist, spawns the
5
+ * wrapped argv under it with the CALLER'S stdio inherited (bytes flow
6
+ * straight through), mirrors the child's exit code, and revokes its temp
7
+ * grant on exit (workspace ACEs stay standing as the reuse cache).
8
+ *
9
+ * Stable argv contract (the seam builds it; a native-exe replacement would
10
+ * keep the same contract):
11
+ * [node, runner.js, '--workspace', <dir>, '--temp', <dir>,
12
+ * '--mode', <read-only|workspace-write>,
13
+ * ['--write-sid', <S-1-4-…>,
14
+ * '--temp-write-sid', <S-1-4-…>], '--', <argv...>]
15
+ *
16
+ * Modes:
17
+ * - workspace-write: the workspace and temp directories carry distinct
18
+ * capability-SID Write grants; other ACL-addressable writes are denied
19
+ * except for the documented Everyone and hard-link boundaries.
20
+ * - read-only: no capability-SID grants; the restricting list carries no
21
+ * capability SID, so a standing grant ACE from an earlier
22
+ * workspace-write period stays inert. BOTH modes drop Authenticated Users
23
+ * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the
24
+ * Public tree writes are denied); the two lists share the keep-alive group
25
+ * (logon SID, EVERYONE) and differ only by the capabilities.
26
+ *
27
+ * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the
28
+ * CALLER has already materialized distinct workspace and private-temp ACEs
29
+ * and owns their revocation, so the runner neither grants nor revokes
30
+ * (`manageDacls: false`). Both values are checked against their owning paths.
31
+ * Without the pair (standalone/agentless use), workspace-write treats
32
+ * `--temp` as a ROOT, creates a random private child directory, derives its
33
+ * own temp SID, and removes that directory after the child exits. In both
34
+ * flows the runner rewrites TMP/TEMP in its OWN environment to the private
35
+ * directory before spawning; the child inherits that block (`lpEnvironment`
36
+ * NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in
37
+ * CreateProcessAsUserW, verified empirically). Read-only leaves the ambient
38
+ * temp entries untouched (writes there are denied anyway).
39
+ *
40
+ * Failure contract: every runner-side failure (bad args, missing
41
+ * directories, token/grant/spawn errors) prints `windows-acl-run: <detail>`
42
+ * to stderr and exits 127 — the seam's RUNNER_FAILURE_RULES matches that
43
+ * signature. The child is NEVER spawned unrestricted.
44
+ * @module @bo-agent/sandbox-windows-acl/runner
45
+ */
46
+ import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
47
+ import { join } from 'node:path';
48
+ import { win32 } from "./ffi.js";
49
+ import { AclSandbox, assertTempRootOutsideWorkspace } from "./index.js";
50
+ import { tempWriteSid, workspaceWriteSid } from "./workspace-sid.js";
51
+ const RUNNER_SIGNATURE = 'windows-acl-run';
52
+ const RUNNER_FAILURE_EXIT = 127;
53
+ class RunnerFailure extends Error {
54
+ }
55
+ /** Print the runner-failure signature line and unwind. */
56
+ function fail(detail) {
57
+ process.stderr.write(`${RUNNER_SIGNATURE}: ${detail}\n`);
58
+ throw new RunnerFailure(detail);
59
+ }
60
+ function parseArgs(raw) {
61
+ let workspace;
62
+ let temp;
63
+ let mode;
64
+ let writeSid;
65
+ let parsedTempWriteSid;
66
+ let index = 0;
67
+ for (; index < raw.length; index++) {
68
+ const token = raw[index];
69
+ if (token === '--') {
70
+ index++;
71
+ break;
72
+ }
73
+ index++;
74
+ const value = raw[index];
75
+ if (value === undefined)
76
+ fail(`missing value after ${token}`);
77
+ switch (token) {
78
+ case '--workspace':
79
+ workspace = value;
80
+ break;
81
+ case '--temp':
82
+ temp = value;
83
+ break;
84
+ case '--mode':
85
+ mode = value;
86
+ break;
87
+ case '--write-sid':
88
+ writeSid = value;
89
+ break;
90
+ case '--temp-write-sid':
91
+ parsedTempWriteSid = value;
92
+ break;
93
+ default: fail(`unknown argument: ${token}`);
94
+ }
95
+ }
96
+ if (workspace === undefined)
97
+ fail('missing --workspace');
98
+ if (temp === undefined)
99
+ fail('missing --temp');
100
+ if (mode !== 'read-only' && mode !== 'workspace-write')
101
+ fail(`unknown mode: ${String(mode)}`);
102
+ const argv = raw.slice(index);
103
+ const command = argv[0];
104
+ if (command === undefined)
105
+ fail('missing command after --');
106
+ return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) };
107
+ }
108
+ function requireDirectory(label, path) {
109
+ if (!existsSync(path) || !statSync(path).isDirectory()) {
110
+ fail(`${label} is not an existing directory: ${path}`);
111
+ }
112
+ }
113
+ async function main() {
114
+ const parsed = parseArgs(process.argv.slice(2));
115
+ // Both directories are validated in both modes: a provider bug that passes
116
+ // a bogus root must fail loudly at the runner boundary, never mid-child.
117
+ requireDirectory('--workspace', parsed.workspace);
118
+ requireDirectory('--temp', parsed.temp);
119
+ const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined;
120
+ if (parsed.mode === 'read-only' && seamManaged) {
121
+ fail('read-only does not accept --write-sid or --temp-write-sid');
122
+ }
123
+ if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) {
124
+ fail('workspace-write requires --write-sid and --temp-write-sid together');
125
+ }
126
+ if (parsed.mode === 'workspace-write') {
127
+ assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp);
128
+ }
129
+ const api = await win32();
130
+ // Ignore this process's own CTRL+C: the confined child (same console) keeps
131
+ // handling its own; the runner must survive to revoke grants and mirror the
132
+ // child's exit code.
133
+ if (api.setConsoleCtrlHandler(null, 1) === 0) {
134
+ fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`);
135
+ }
136
+ let ownedTempDir;
137
+ let sandbox;
138
+ let initialized = false;
139
+ try {
140
+ let privateTempDir = null;
141
+ let writeSid;
142
+ let privateTempSid;
143
+ if (parsed.mode === 'workspace-write') {
144
+ writeSid = workspaceWriteSid(parsed.workspace);
145
+ if (seamManaged) {
146
+ if (parsed.writeSid !== writeSid)
147
+ fail('--write-sid does not match --workspace');
148
+ privateTempDir = parsed.temp;
149
+ privateTempSid = tempWriteSid(privateTempDir);
150
+ if (parsed.tempWriteSid !== privateTempSid)
151
+ fail('--temp-write-sid does not match --temp');
152
+ }
153
+ else {
154
+ ownedTempDir = mkdtempSync(join(parsed.temp, 'dsh-'));
155
+ privateTempDir = ownedTempDir;
156
+ privateTempSid = tempWriteSid(privateTempDir);
157
+ }
158
+ }
159
+ sandbox = new AclSandbox({
160
+ writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [],
161
+ tempDir: privateTempDir,
162
+ mode: parsed.mode,
163
+ ...writeSid === undefined ? {} : { writeSid },
164
+ ...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid },
165
+ manageDacls: !seamManaged,
166
+ });
167
+ await sandbox.init();
168
+ initialized = true;
169
+ if (privateTempDir !== null) {
170
+ if (api.setEnvironmentVariableW('TMP', privateTempDir) === 0) {
171
+ fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`);
172
+ }
173
+ if (api.setEnvironmentVariableW('TEMP', privateTempDir) === 0) {
174
+ fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`);
175
+ }
176
+ }
177
+ const child = sandbox.spawn({
178
+ command: parsed.command,
179
+ args: parsed.args,
180
+ stdio: 'inherit',
181
+ });
182
+ const result = await child.wait();
183
+ return result.exitCode;
184
+ }
185
+ finally {
186
+ // Cleanup failures must not mask the child's exit code: report and keep going.
187
+ if (initialized) {
188
+ try {
189
+ sandbox?.dispose();
190
+ }
191
+ catch (error) {
192
+ process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`);
193
+ }
194
+ }
195
+ if (ownedTempDir !== undefined) {
196
+ try {
197
+ rmSync(ownedTempDir, { recursive: true, force: true });
198
+ }
199
+ catch (error) {
200
+ process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`);
201
+ }
202
+ }
203
+ }
204
+ }
205
+ main().then((exitCode) => {
206
+ // Exit-code mirroring is full-width on Windows, verified empirically on
207
+ // this machine (Windows 11 build 26200, Node 24): a child that exits
208
+ // with the NTSTATUS 0xC0000005 (STATUS_ACCESS_VIOLATION) is read back
209
+ // by GetExitCodeProcess as the uint32 3221225477, and after
210
+ // process.exitCode = 3221225477 the parent observes exactly
211
+ // 3221225477 (spawnSync status). PowerShell's $LASTEXITCODE and cmd
212
+ // print the signed view (-1073741819), but no truncation or masking
213
+ // happens anywhere in the chain — the mirror contract holds for the
214
+ // full 32-bit range, so no re-mapping is needed.
215
+ process.exitCode = exitCode;
216
+ }, (error) => {
217
+ if (!(error instanceof RunnerFailure)) {
218
+ process.stderr.write(`${RUNNER_SIGNATURE}: ${error instanceof Error ? error.message : String(error)}\n`);
219
+ }
220
+ process.exitCode = RUNNER_FAILURE_EXIT;
221
+ });
222
+ //# sourceMappingURL=runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"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"}
package/lib/spawn.d.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with
3
+ * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then
4
+ * asynchronous pipe draining and exit waiting. Console isolation
5
+ * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this
6
+ * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED
7
+ * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is
8
+ * pipe-based and unaffected; the child shares the host console.
9
+ * @module @bo-agent/sandbox-windows-acl/spawn
10
+ */
11
+ import type { NativePtr, Win32Bindings } from './ffi.ts';
12
+ /**
13
+ * Quote one argument per the CommandLineToArgvW parsing rules: backslashes
14
+ * are doubled only before a quote character — including the closing quote
15
+ * this function appends, so a trailing backslash run is doubled as well
16
+ * (otherwise an odd run would escape the closing quote into a literal
17
+ * character and corrupt the rest of the command line). Mirrors the CRT
18
+ * ArgvQuote behavior Microsoft documents for command-line arguments.
19
+ * @param argument - one argv entry to quote.
20
+ * @returns the quoted entry (bare when quoting is unnecessary).
21
+ */
22
+ export declare function quoteArg(argument: string): string;
23
+ /**
24
+ * Build the single command line CreateProcess parses from program + argv.
25
+ * @param program - the executable (argv[0]).
26
+ * @param args - the remaining argv entries.
27
+ * @returns the joined, quoted command line.
28
+ */
29
+ export declare function buildCommandLine(program: string, args: readonly string[]): string;
30
+ /** A confined child spawned with piped stdio: process handle plus the pipe read ends to drain. */
31
+ export interface SpawnedNative {
32
+ pid: number;
33
+ process: NativePtr;
34
+ stdoutRead: NativePtr;
35
+ stderrRead: NativePtr;
36
+ }
37
+ /**
38
+ * Create a process under the restricted token with piped stdio. The child's
39
+ * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends
40
+ * are returned for draining. The child inherits the caller's environment block
41
+ * (lpEnvironment NULL); the caller rewrites entries through
42
+ * SetEnvironmentVariableW before spawning (the runner's per-session temp
43
+ * contract) — passing an explicit block through koffi trips
44
+ * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically).
45
+ * @param api - the binding table.
46
+ * @param token - the restricted token the child runs under.
47
+ * @param options - command, args, and working directory.
48
+ * @returns the spawned child's handles.
49
+ */
50
+ export declare function spawnSandboxed(api: Win32Bindings, token: NativePtr, options: {
51
+ command: string;
52
+ args: readonly string[];
53
+ cwd: string;
54
+ }): SpawnedNative;
55
+ /**
56
+ * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling.
57
+ * @param api - the binding table.
58
+ * @param handle - the pipe read end to drain (closed when done).
59
+ * @returns the complete pipe contents.
60
+ */
61
+ export declare function drainPipe(api: Win32Bindings, handle: NativePtr): Promise<Buffer>;
62
+ /**
63
+ * Wait for process exit and return its exit code. Call only after both drains
64
+ * have resolved — the drains finish when the child closed its pipe ends, i.e.
65
+ * the child has already exited, so this wait returns immediately. Calling it
66
+ * earlier would block the event loop and starve the drains (the pipe-buffer
67
+ * deadlock the POC comments warn about).
68
+ * @param api - the binding table.
69
+ * @param process - the child process handle (closed when done).
70
+ * @returns the child's exit code.
71
+ */
72
+ export declare function waitForExit(api: Win32Bindings, process: NativePtr): number;
73
+ /** A confined child spawned with inherited stdio: process handle plus its kill-on-close job. */
74
+ export interface SpawnedInherited {
75
+ pid: number;
76
+ process: NativePtr;
77
+ /** Kill-on-close job the child was placed in; caller closes it after the child exits. */
78
+ job: NativePtr;
79
+ }
80
+ /**
81
+ * Create a process under the restricted token whose stdio passes straight
82
+ * through to the caller's pipes. This is the runner shape: the harness spawns
83
+ * the runner with piped stdio, and the runner's confined child writes to
84
+ * those same pipes.
85
+ *
86
+ * Node clears the inheritability of its stdio handles at startup
87
+ * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit
88
+ * bit around the call (libuv instead duplicates the handles; re-enabling is
89
+ * equivalent here and cheaper) and pass them explicitly via
90
+ * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles
91
+ * ("The handle is invalid", verified the hard way). The child starts
92
+ * suspended so it can be assigned to a kill-on-close job before it runs.
93
+ * @param api - the binding table.
94
+ * @param token - the restricted token the child runs under.
95
+ * @param options - command, args, and working directory.
96
+ * @returns the spawned child's handles and job.
97
+ */
98
+ export declare function spawnSandboxedInherited(api: Win32Bindings, token: NativePtr, options: {
99
+ command: string;
100
+ args: readonly string[];
101
+ cwd: string;
102
+ }): SpawnedInherited;
103
+ //# sourceMappingURL=spawn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../src/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAGH,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AAGxD;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAoBjD;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAEjF;AAuBD,kGAAkG;AAClG,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,SAAS,CAAA;IAClB,UAAU,EAAE,SAAS,CAAA;IACrB,UAAU,EAAE,SAAS,CAAA;CACtB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,aAAa,EAClB,KAAK,EAAE,SAAS,EAChB,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACjE,aAAa,CA+Df;AAED;;;;;GAKG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,aAAa,EAAE,MAAM,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BtF;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,aAAa,EAAE,OAAO,EAAE,SAAS,GAAG,MAAM,CAO1E;AAsBD,gGAAgG;AAChG,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,SAAS,CAAA;IAClB,yFAAyF;IACzF,GAAG,EAAE,SAAS,CAAA;CACf;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,aAAa,EAClB,KAAK,EAAE,SAAS,EAChB,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACjE,gBAAgB,CAoFlB"}
package/lib/spawn.js ADDED
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Restricted-process spawning: anonymous pipes for stdio, STARTUPINFOW with
3
+ * STARTF_USESTDHANDLES, CreateProcessAsUserW under the restricted token, then
4
+ * asynchronous pipe draining and exit waiting. Console isolation
5
+ * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE) is intentionally absent: under this
6
+ * restriction scheme hidden-console children die with STATUS_DLL_INIT_FAILED
7
+ * (0xC0000142) — verified empirically, see win32-abi.ts. Stdio redirection is
8
+ * pipe-based and unaffected; the child shares the host console.
9
+ * @module @bo-agent/sandbox-windows-acl/spawn
10
+ */
11
+ import { allocPtrSlot, allocProcessInfo, allocStartupInfo, allocUint32, decodePtr, decodeProcessInfo, decodeUint32, encodeStartupInfo, isNullPtr, throwLastError, throwWin32 } from "./ffi.js";
12
+ import * as abi from "./win32-abi.js";
13
+ /**
14
+ * Quote one argument per the CommandLineToArgvW parsing rules: backslashes
15
+ * are doubled only before a quote character — including the closing quote
16
+ * this function appends, so a trailing backslash run is doubled as well
17
+ * (otherwise an odd run would escape the closing quote into a literal
18
+ * character and corrupt the rest of the command line). Mirrors the CRT
19
+ * ArgvQuote behavior Microsoft documents for command-line arguments.
20
+ * @param argument - one argv entry to quote.
21
+ * @returns the quoted entry (bare when quoting is unnecessary).
22
+ */
23
+ export function quoteArg(argument) {
24
+ if (argument === '')
25
+ return '""';
26
+ if (!/[\s"]/u.test(argument))
27
+ return argument;
28
+ let quoted = '"';
29
+ for (let index = 0; index < argument.length; index++) {
30
+ let backslashes = 0;
31
+ while (index < argument.length && argument.charAt(index) === '\\') {
32
+ backslashes++;
33
+ index++;
34
+ }
35
+ if (index === argument.length) {
36
+ // Trailing backslash run: doubled so it cannot escape the closing quote.
37
+ quoted += '\\'.repeat(backslashes * 2);
38
+ }
39
+ else if (argument.charAt(index) === '"') {
40
+ quoted += '\\'.repeat(backslashes * 2 + 1) + '"';
41
+ }
42
+ else {
43
+ quoted += '\\'.repeat(backslashes) + argument.charAt(index);
44
+ }
45
+ }
46
+ return quoted + '"';
47
+ }
48
+ /**
49
+ * Build the single command line CreateProcess parses from program + argv.
50
+ * @param program - the executable (argv[0]).
51
+ * @param args - the remaining argv entries.
52
+ * @returns the joined, quoted command line.
53
+ */
54
+ export function buildCommandLine(program, args) {
55
+ return [program, ...args].map(quoteArg).join(' ');
56
+ }
57
+ function createPipe(api) {
58
+ const readSlot = allocPtrSlot();
59
+ const writeSlot = allocPtrSlot();
60
+ if (api.createPipe(readSlot, writeSlot, null, 0) === 0)
61
+ throwLastError(api, 'CreatePipe');
62
+ const read = decodePtr(readSlot);
63
+ const write = decodePtr(writeSlot);
64
+ if (read === null || write === null)
65
+ throwLastError(api, 'CreatePipe', 'null pipe handle');
66
+ return { read, write };
67
+ }
68
+ function setInheritable(api, handle, label) {
69
+ if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) {
70
+ throwLastError(api, 'SetHandleInformation', label);
71
+ }
72
+ }
73
+ /**
74
+ * Create a process under the restricted token with piped stdio. The child's
75
+ * stdin is closed immediately (EOF), matching the POC; stdout/stderr read ends
76
+ * are returned for draining. The child inherits the caller's environment block
77
+ * (lpEnvironment NULL); the caller rewrites entries through
78
+ * SetEnvironmentVariableW before spawning (the runner's per-session temp
79
+ * contract) — passing an explicit block through koffi trips
80
+ * ERROR_INVALID_PARAMETER in CreateProcessAsUserW (verified empirically).
81
+ * @param api - the binding table.
82
+ * @param token - the restricted token the child runs under.
83
+ * @param options - command, args, and working directory.
84
+ * @returns the spawned child's handles.
85
+ */
86
+ export function spawnSandboxed(api, token, options) {
87
+ const stdIn = createPipe(api);
88
+ const stdOut = createPipe(api);
89
+ const stdErr = createPipe(api);
90
+ // Child side of each pipe must be inheritable (POC lines 262-268).
91
+ setInheritable(api, stdIn.read, 'stdin read end');
92
+ setInheritable(api, stdOut.write, 'stdout write end');
93
+ setInheritable(api, stdErr.write, 'stderr write end');
94
+ const startupInfo = allocStartupInfo();
95
+ encodeStartupInfo(startupInfo, {
96
+ cb: abi.STARTUPINFOW_SIZE,
97
+ dwFlags: abi.STARTF_USESTDHANDLES,
98
+ hStdInput: stdIn.read,
99
+ hStdOutput: stdOut.write,
100
+ hStdError: stdErr.write,
101
+ });
102
+ const processInfo = allocProcessInfo();
103
+ const commandLine = buildCommandLine(options.command, options.args);
104
+ const created = api.createProcessAsUserW(token, null, commandLine, null, null, 1, // bInheritHandles: required for redirection
105
+ 0, // no creation flags: suspended/no-window variants are unusable under the restriction
106
+ null, options.cwd, startupInfo, processInfo);
107
+ // Capture the failure before CloseHandle calls clobber GetLastError, then
108
+ // close every pipe handle created so far — the six-close contract this test
109
+ // surface pins (tests/failure-paths.spec.ts).
110
+ if (created === 0) {
111
+ const win32Code = api.getLastError();
112
+ api.closeHandle(stdIn.read);
113
+ api.closeHandle(stdIn.write);
114
+ api.closeHandle(stdOut.read);
115
+ api.closeHandle(stdOut.write);
116
+ api.closeHandle(stdErr.read);
117
+ api.closeHandle(stdErr.write);
118
+ throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`);
119
+ }
120
+ const info = decodeProcessInfo(processInfo);
121
+ const processHandle = info.hProcess;
122
+ const threadHandle = info.hThread;
123
+ if (processHandle === null || threadHandle === null) {
124
+ throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`);
125
+ }
126
+ // Host-side cleanup: child handles are now duplicated in the child; the
127
+ // host closes its copies so ReadFile sees EOF when the child exits.
128
+ api.closeHandle(stdIn.read);
129
+ api.closeHandle(stdOut.write);
130
+ api.closeHandle(stdErr.write);
131
+ api.closeHandle(stdIn.write);
132
+ api.closeHandle(threadHandle);
133
+ return {
134
+ pid: info.dwProcessId,
135
+ process: processHandle,
136
+ stdoutRead: stdOut.read,
137
+ stderrRead: stdErr.read,
138
+ };
139
+ }
140
+ /**
141
+ * Drain one pipe read end to a Buffer via non-blocking PeekNamedPipe polling.
142
+ * @param api - the binding table.
143
+ * @param handle - the pipe read end to drain (closed when done).
144
+ * @returns the complete pipe contents.
145
+ */
146
+ export async function drainPipe(api, handle) {
147
+ const chunks = [];
148
+ for (;;) {
149
+ const bytesReadSlot = allocUint32();
150
+ const totalAvailSlot = allocUint32();
151
+ const leftThisMessageSlot = allocUint32();
152
+ const peeked = api.peekNamedPipe(handle, null, 0, bytesReadSlot, totalAvailSlot, leftThisMessageSlot);
153
+ if (peeked === 0) {
154
+ const win32Code = api.getLastError();
155
+ if (win32Code === abi.ERROR_BROKEN_PIPE || win32Code === abi.ERROR_NO_DATA)
156
+ break; // child closed its end: clean EOF
157
+ throwLastError(api, 'PeekNamedPipe', `drain failure after ${chunks.length} chunk(s)`);
158
+ }
159
+ const available = decodeUint32(totalAvailSlot);
160
+ if (available > 0) {
161
+ const chunk = Buffer.alloc(available);
162
+ const readSlot = allocUint32();
163
+ if (api.readFile(handle, chunk, chunk.length, readSlot, null) === 0) {
164
+ throwLastError(api, 'ReadFile', `drain failure after ${chunks.length} chunk(s)`);
165
+ }
166
+ chunks.push(chunk.subarray(0, decodeUint32(readSlot)));
167
+ }
168
+ // Small backoff instead of setImmediate: a bare next-tick would busy-poll
169
+ // the pipe at full event-loop speed while the child produces no output.
170
+ await new Promise(resolve => setTimeout(resolve, 1));
171
+ }
172
+ api.closeHandle(handle);
173
+ return Buffer.concat(chunks);
174
+ }
175
+ /**
176
+ * Wait for process exit and return its exit code. Call only after both drains
177
+ * have resolved — the drains finish when the child closed its pipe ends, i.e.
178
+ * the child has already exited, so this wait returns immediately. Calling it
179
+ * earlier would block the event loop and starve the drains (the pipe-buffer
180
+ * deadlock the POC comments warn about).
181
+ * @param api - the binding table.
182
+ * @param process - the child process handle (closed when done).
183
+ * @returns the child's exit code.
184
+ */
185
+ export function waitForExit(api, process) {
186
+ const waitResult = api.waitForSingleObject(process, abi.INFINITE);
187
+ if (waitResult === 0xFFFFFFFF)
188
+ throwLastError(api, 'WaitForSingleObject');
189
+ const exitCodeSlot = allocUint32();
190
+ if (api.getExitCodeProcess(process, exitCodeSlot) === 0)
191
+ throwLastError(api, 'GetExitCodeProcess');
192
+ api.closeHandle(process);
193
+ return decodeUint32(exitCodeSlot);
194
+ }
195
+ /**
196
+ * Create a kill-on-close job object (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE at
197
+ * LimitFlags offset 16 of JOBOBJECT_EXTENDED_LIMIT_INFORMATION, layout
198
+ * verified by abi-probe.cpp). When the caller dies with the job handle open,
199
+ * Windows terminates every process in the job — the orphan-child backstop.
200
+ * The caller keeps the returned handle open for the child's lifetime.
201
+ */
202
+ function createKillOnCloseJob(api) {
203
+ const job = api.createJobObjectW(null, null);
204
+ if (isNullPtr(job))
205
+ throwLastError(api, 'CreateJobObjectW');
206
+ const information = Buffer.alloc(abi.JOBOBJECT_EXTENDED_LIMIT_SIZE);
207
+ information.writeUInt32LE(abi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, abi.JOBOBJECT_EXTENDED_LIMIT_FLAGS_OFFSET);
208
+ if (api.setInformationJobObject(job, abi.JobObjectExtendedLimitInformation, information, information.length) === 0) {
209
+ const win32Code = api.getLastError();
210
+ api.closeHandle(job);
211
+ throwWin32(api, 'SetInformationJobObject', win32Code);
212
+ }
213
+ return job;
214
+ }
215
+ /**
216
+ * Create a process under the restricted token whose stdio passes straight
217
+ * through to the caller's pipes. This is the runner shape: the harness spawns
218
+ * the runner with piped stdio, and the runner's confined child writes to
219
+ * those same pipes.
220
+ *
221
+ * Node clears the inheritability of its stdio handles at startup
222
+ * (uv_disable_stdio_inheritance), so raw spawns must re-enable the inherit
223
+ * bit around the call (libuv instead duplicates the handles; re-enabling is
224
+ * equivalent here and cheaper) and pass them explicitly via
225
+ * STARTF_USESTDHANDLES — otherwise the child receives INVALID std handles
226
+ * ("The handle is invalid", verified the hard way). The child starts
227
+ * suspended so it can be assigned to a kill-on-close job before it runs.
228
+ * @param api - the binding table.
229
+ * @param token - the restricted token the child runs under.
230
+ * @param options - command, args, and working directory.
231
+ * @returns the spawned child's handles and job.
232
+ */
233
+ export function spawnSandboxedInherited(api, token, options) {
234
+ const job = createKillOnCloseJob(api);
235
+ const stdIn = api.getStdHandle(abi.STD_INPUT_HANDLE);
236
+ const stdOut = api.getStdHandle(abi.STD_OUTPUT_HANDLE);
237
+ const stdErr = api.getStdHandle(abi.STD_ERROR_HANDLE);
238
+ if (isNullPtr(stdIn) || isNullPtr(stdOut) || isNullPtr(stdErr)) {
239
+ api.closeHandle(job);
240
+ throwLastError(api, 'GetStdHandle', 'null standard handle');
241
+ }
242
+ const makeInheritable = (handle, label) => {
243
+ if (api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, abi.HANDLE_FLAG_INHERIT) === 0) {
244
+ throwLastError(api, 'SetHandleInformation', `${label} (enable inherit)`);
245
+ }
246
+ };
247
+ const restoreInherit = (handle) => {
248
+ // Best-effort hygiene: the runner spawns nothing else; failures here must
249
+ // not mask the child outcome, so the result is deliberately unchecked.
250
+ api.setHandleInformation(handle, abi.HANDLE_FLAG_INHERIT, 0);
251
+ };
252
+ makeInheritable(stdIn, 'stdin');
253
+ makeInheritable(stdOut, 'stdout');
254
+ makeInheritable(stdErr, 'stderr');
255
+ const startupInfo = allocStartupInfo();
256
+ encodeStartupInfo(startupInfo, {
257
+ cb: abi.STARTUPINFOW_SIZE,
258
+ dwFlags: abi.STARTF_USESTDHANDLES,
259
+ hStdInput: stdIn,
260
+ hStdOutput: stdOut,
261
+ hStdError: stdErr,
262
+ });
263
+ const processInfo = allocProcessInfo();
264
+ const commandLine = buildCommandLine(options.command, options.args);
265
+ const created = api.createProcessAsUserW(token, null, commandLine, null, null, 1, // bInheritHandles: the re-enabled std handles must be inheritable
266
+ abi.CREATE_SUSPENDED, // suspended so job assignment precedes any execution
267
+ null, options.cwd, startupInfo, processInfo);
268
+ restoreInherit(stdIn);
269
+ restoreInherit(stdOut);
270
+ restoreInherit(stdErr);
271
+ if (created === 0) {
272
+ const win32Code = api.getLastError();
273
+ api.closeHandle(job);
274
+ throwWin32(api, 'CreateProcessAsUserW', win32Code, `command: ${options.command}, cwd: ${options.cwd}`);
275
+ }
276
+ const info = decodeProcessInfo(processInfo);
277
+ const processHandle = info.hProcess;
278
+ const threadHandle = info.hThread;
279
+ if (processHandle === null || threadHandle === null) {
280
+ api.closeHandle(job);
281
+ throw new Error(`CreateProcessAsUserW succeeded but returned null process/thread handles (pid ${info.dwProcessId})`);
282
+ }
283
+ if (api.assignProcessToJobObject(job, processHandle) === 0) {
284
+ // The child was created suspended and is NOT in the kill-on-close job:
285
+ // closing handles would leave it suspended forever. Terminate it first,
286
+ // then drop the handles and throw.
287
+ const win32Code = api.getLastError();
288
+ api.terminateProcess(processHandle, 1);
289
+ api.closeHandle(threadHandle);
290
+ api.closeHandle(processHandle);
291
+ api.closeHandle(job);
292
+ throwWin32(api, 'AssignProcessToJobObject', win32Code, `pid ${info.dwProcessId}`);
293
+ }
294
+ if (api.resumeThread(threadHandle) === 0xFFFFFFFF) {
295
+ // Closing the job triggers kill-on-close, so the suspended child dies
296
+ // instead of hanging until this process exits; the process/thread handles
297
+ // must go too.
298
+ const win32Code = api.getLastError();
299
+ api.closeHandle(threadHandle);
300
+ api.closeHandle(processHandle);
301
+ api.closeHandle(job);
302
+ throwWin32(api, 'ResumeThread', win32Code, `pid ${info.dwProcessId}`);
303
+ }
304
+ api.closeHandle(threadHandle);
305
+ return { pid: info.dwProcessId, process: processHandle, job };
306
+ }
307
+ //# sourceMappingURL=spawn.js.map