@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.
- package/lib/.tsbuildinfo +1 -0
- package/lib/acl.d.ts +83 -0
- package/lib/acl.d.ts.map +1 -0
- package/lib/acl.js +255 -0
- package/lib/acl.js.map +1 -0
- package/lib/errors.d.ts +15 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +20 -0
- package/lib/errors.js.map +1 -0
- package/lib/ffi.d.ts +252 -0
- package/lib/ffi.d.ts.map +1 -0
- package/lib/ffi.js +382 -0
- package/lib/ffi.js.map +1 -0
- package/lib/grant.d.ts +60 -0
- package/lib/grant.d.ts.map +1 -0
- package/lib/grant.js +102 -0
- package/lib/grant.js.map +1 -0
- package/lib/index.d.ts +168 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +349 -0
- package/lib/index.js.map +1 -0
- package/lib/invariant.d.ts +15 -0
- package/lib/invariant.d.ts.map +1 -0
- package/lib/invariant.js +17 -0
- package/lib/invariant.js.map +1 -0
- package/lib/path-boundary.d.ts +20 -0
- package/lib/path-boundary.d.ts.map +1 -0
- package/lib/path-boundary.js +37 -0
- package/lib/path-boundary.js.map +1 -0
- package/lib/runner.d.ts +47 -0
- package/lib/runner.d.ts.map +1 -0
- package/lib/runner.js +222 -0
- package/lib/runner.js.map +1 -0
- package/lib/spawn.d.ts +103 -0
- package/lib/spawn.d.ts.map +1 -0
- package/lib/spawn.js +307 -0
- package/lib/spawn.js.map +1 -0
- package/lib/token.d.ts +91 -0
- package/lib/token.d.ts.map +1 -0
- package/lib/token.js +200 -0
- package/lib/token.js.map +1 -0
- package/lib/win32-abi.d.ts +178 -0
- package/lib/win32-abi.d.ts.map +1 -0
- package/lib/win32-abi.js +230 -0
- package/lib/win32-abi.js.map +1 -0
- package/lib/workspace-sid.d.ts +41 -0
- package/lib/workspace-sid.d.ts.map +1 -0
- package/lib/workspace-sid.js +52 -0
- package/lib/workspace-sid.js.map +1 -0
- 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
|