@lone-wolf-akela/dsh-sandboxie-redirect 0.3.0

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/run.mjs ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * `dsh-sbie-run` — the DSH sandbox runner that turns "deny writes outside the
3
+ * workspace" into "let them succeed, but land in a throwaway copy".
4
+ *
5
+ * DSH's `@deepseek-ai/dsh-sandbox-local` provider, when configured with a
6
+ * `runnerCommand`, stops enforcing anything itself and hands over:
7
+ *
8
+ * [<runnerCommand...>, ...bwrapProfileArgs(policy), "--", <real argv...>]
9
+ *
10
+ * `bwrapProfileArgs` is bubblewrap vocabulary that has no meaning on Windows.
11
+ * We read exactly two facts out of it — the workspace root (`--bind <ws> <ws>`)
12
+ * and whether the mode grants writes (the presence of that `--bind`) — then
13
+ * ignore the rest.
14
+ *
15
+ * Everything after the `--` is the command the model asked for. It is forwarded
16
+ * verbatim: this process never re-quotes or re-parses it.
17
+ *
18
+ * launcher (unboxed, owns the pipes)
19
+ * | SbieDll_RunSandboxed(box, "node inner.mjs --port N --token T")
20
+ * v
21
+ * relay (boxed, no usable handles)
22
+ * | loopback TCP: argv/cwd/env in, stdout/stderr/exit out
23
+ * | spawn(argv) -> the command inherits the box
24
+ * v
25
+ * the model's command
26
+ */
27
+ import { randomBytes } from "node:crypto";
28
+ import net from "node:net";
29
+ import path from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { boxNameFor, canonicalWorkspace, ensureBox } from "./boxes.mjs";
32
+ import { Frame, createFrameDecoder, encodeFrame, encodeJsonFrame } from "./protocol.mjs";
33
+ import { runSandboxed, killProcess, sbieDir } from "./sbie.mjs";
34
+
35
+ const here = path.dirname(fileURLToPath(import.meta.url));
36
+ const relayScript = path.join(here, "inner.mjs");
37
+
38
+ /** Our own leading switches; anything else before `--` is the runner's profile dialect. */
39
+ const OUR_FLAGS = new Map([
40
+ ["--box", 1],
41
+ ["--workspace", 1],
42
+ ["--mode", 1],
43
+ ["--relay-timeout-ms", 1],
44
+ ["--dry-run", 0],
45
+ ["--verbose", 0]
46
+ ]);
47
+
48
+ function usage() {
49
+ return [
50
+ "usage: dsh-sbie-run [--box NAME] [--workspace DIR] [--mode workspace-write|read-only] <runner profile args...> -- <command argv...>",
51
+ " dsh-sbie-run --manage list|clean <workspace>|delete-box <box>"
52
+ ].join("\n");
53
+ }
54
+
55
+ /**
56
+ * Split the runner argv into (our switches, the provider's profile args, the command).
57
+ * @param {string[]} argv
58
+ */
59
+ export function parseRunnerArgv(argv) {
60
+ const options = { box: undefined, workspace: undefined, mode: undefined, relayTimeoutMs: 20000, dryRun: false, verbose: false };
61
+ let index = 0;
62
+ while (index < argv.length && OUR_FLAGS.has(argv[index])) {
63
+ const name = argv[index];
64
+ const arity = OUR_FLAGS.get(name);
65
+ index += 1;
66
+ let value;
67
+ if (arity === 1) {
68
+ value = argv[index];
69
+ index += 1;
70
+ if (value === undefined) throw new Error(`${name} requires a value`);
71
+ }
72
+ if (name === "--box") options.box = value;
73
+ else if (name === "--workspace") options.workspace = value;
74
+ else if (name === "--mode") options.mode = value;
75
+ else if (name === "--relay-timeout-ms") options.relayTimeoutMs = Number(value);
76
+ else if (name === "--dry-run") options.dryRun = true;
77
+ else if (name === "--verbose") options.verbose = true;
78
+ }
79
+
80
+ const separator = argv.indexOf("--", index);
81
+ if (separator < 0) throw new Error(`missing "--" separator before the command\n${usage()}`);
82
+ const profileArgs = argv.slice(index, separator);
83
+ const command = argv.slice(separator + 1);
84
+
85
+ // The only facts we need out of the bubblewrap dialect.
86
+ let workspace;
87
+ let mode = "read-only";
88
+ for (let i = 0; i < profileArgs.length; i += 1) {
89
+ if (profileArgs[i] === "--bind" && profileArgs[i + 1] !== undefined) {
90
+ workspace = profileArgs[i + 1];
91
+ mode = "workspace-write";
92
+ i += 2;
93
+ }
94
+ }
95
+ if (options.workspace !== undefined) workspace = options.workspace;
96
+ if (options.mode !== undefined) mode = options.mode === "read-only" ? "read-only" : "workspace-write";
97
+ if (workspace === undefined) workspace = process.env.DSH_SBIE_WORKSPACE ?? process.cwd();
98
+ return { options, profileArgs, command, workspace: canonicalWorkspace(workspace), mode };
99
+ }
100
+
101
+ function fail(message, code = 127) {
102
+ process.stderr.write(`dsh-sbie-run: ${message}\n`);
103
+ process.exit(code);
104
+ }
105
+
106
+ function quote(value) {
107
+ return `"${value.replaceAll('"', '\\"')}"`;
108
+ }
109
+
110
+ /**
111
+ * Bind the loopback listener the boxed relay will dial.
112
+ *
113
+ * Returns as soon as the socket is bound — the caller must start the relay and
114
+ * only then await `accepted`, otherwise it waits for a connection nobody has
115
+ * been asked to make yet.
116
+ */
117
+ function openChannel(timeoutMs) {
118
+ const token = randomBytes(24).toString("hex");
119
+ const server = net.createServer();
120
+ let settle;
121
+ let fail;
122
+ const accepted = new Promise((resolve, reject) => {
123
+ settle = resolve;
124
+ fail = reject;
125
+ });
126
+ const timer = setTimeout(() => fail(new Error(`the boxed relay did not connect within ${timeoutMs} ms`)), timeoutMs);
127
+ server.on("error", (error) => {
128
+ clearTimeout(timer);
129
+ fail(error);
130
+ });
131
+ server.once("connection", (socket) => {
132
+ socket.setNoDelay(true);
133
+ const decode = createFrameDecoder((type, body) => {
134
+ if (type !== Frame.HELLO) return;
135
+ let hello;
136
+ try {
137
+ hello = JSON.parse(body.toString("utf8"));
138
+ } catch {
139
+ socket.destroy();
140
+ fail(new Error("malformed relay hello"));
141
+ return;
142
+ }
143
+ if (hello.token !== token) {
144
+ socket.destroy();
145
+ fail(new Error("relay presented the wrong token"));
146
+ return;
147
+ }
148
+ clearTimeout(timer);
149
+ settle(socket);
150
+ });
151
+ socket.on("data", decode);
152
+ socket.on("error", (error) => {
153
+ clearTimeout(timer);
154
+ fail(error);
155
+ });
156
+ });
157
+ return new Promise((resolve, reject) => {
158
+ server.listen(0, "127.0.0.1", () => {
159
+ resolve({
160
+ server,
161
+ token,
162
+ port: server.address().port,
163
+ accepted: accepted.catch((error) => {
164
+ server.close();
165
+ throw error;
166
+ }),
167
+ dispose: () => {
168
+ clearTimeout(timer);
169
+ server.close();
170
+ }
171
+ });
172
+ });
173
+ server.once("error", reject);
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Run one confined command.
179
+ *
180
+ * @param {string[]} argv - the runner argv DSH produced.
181
+ * @param {{stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream}} [io]
182
+ * @returns {Promise<number>} the command's exit code.
183
+ */
184
+ export async function run(argv, io = {}) {
185
+ const stdin = io.stdin ?? process.stdin;
186
+ const stdout = io.stdout ?? process.stdout;
187
+ const stderr = io.stderr ?? process.stderr;
188
+
189
+ const parsed = parseRunnerArgv(argv);
190
+ const { options, command, workspace, mode } = parsed;
191
+ if (command.length === 0) throw new Error(`no command after "--"\n${usage()}`);
192
+
193
+ const box = options.box ?? process.env.DSH_SBIE_BOX ?? boxNameFor(workspace, mode);
194
+ const log = options.verbose ? (message) => process.stderr.write(`dsh-sbie-run: ${message}\n`) : () => {};
195
+
196
+ if (sbieDir() === undefined) throw new Error("Sandboxie-Plus is not installed (SbieDll.dll not found)");
197
+
198
+ const ensured = ensureBox({ box, workspace, mode, logger: log });
199
+ const env = { ...process.env, DSH_SANDBOX_ROOT: ensured.root, DSH_SBIE_BOX: ensured.box };
200
+
201
+ if (options.dryRun) {
202
+ stdout.write(
203
+ `${JSON.stringify({ box: ensured.box, boxRoot: ensured.root, workspace: ensured.workspace, mode: ensured.mode, created: ensured.created, cwd: process.cwd(), argv: command }, null, 2)}\n`
204
+ );
205
+ return 0;
206
+ }
207
+
208
+ const channel = await startRelay(ensured, options, log);
209
+ return await relay({ server: channel.server, socket: channel.socket }, { command, cwd: process.cwd(), env, stdin, stdout, stderr, box: ensured });
210
+ }
211
+
212
+ /**
213
+ * Start the boxed relay, retrying a few times.
214
+ *
215
+ * Sandboxie occasionally fails to bring a box up: its isolated IPC object
216
+ * directory creation can lose a race with the previous teardown
217
+ * (`SBIE2308 … C0000024`), the box's GUI server is then refused
218
+ * (`SBIE2336 … C0000022`), the boxed process fails to initialise
219
+ * (`SBIE2335`) and the launch aborts with `SBIE2337 / Win32 1067`
220
+ * (`ERROR_PROCESS_ABORTED`). Sometimes the process starts but never reaches
221
+ * the listener. Both shapes are transient — a fresh attempt on a new channel
222
+ * has always succeeded — so they are retried, with a short settle pause so the
223
+ * teardown can finish, and a stranded relay is killed rather than left behind.
224
+ */
225
+ async function startRelay(ensured, options, log) {
226
+ // A healthy relay connects in well under a second, so a long first wait only
227
+ // wastes time when the box is struggling; later attempts get more room for a
228
+ // genuinely slow first start of a box.
229
+ const attemptTimeouts = [Math.min(options.relayTimeoutMs, 7000), 10000, 15000];
230
+ const attempts = attemptTimeouts.length;
231
+ let lastError;
232
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
233
+ const timeoutMs = attemptTimeouts[attempt - 1];
234
+ let channel;
235
+ let launched;
236
+ try {
237
+ channel = await openChannel(timeoutMs);
238
+ const relayCommandLine = `${quote(process.execPath)} ${quote(relayScript)} --port ${channel.port} --token ${channel.token}`;
239
+ // The relay's own cwd only has to be a directory the Sandboxie service
240
+ // can resolve; the command's real cwd travels in the spec.
241
+ launched = runSandboxed({ box: ensured.box, commandLine: relayCommandLine, cwd: here });
242
+ if (!launched.ok) throw new Error(`cannot start the boxed relay in ${ensured.box}: ${launched.error}`);
243
+ log(`boxed relay pid ${launched.pid} in ${ensured.box} (attempt ${attempt})`);
244
+ const socket = await channel.accepted;
245
+ return { server: channel.server, socket, pid: launched.pid };
246
+ } catch (error) {
247
+ lastError = error;
248
+ channel?.dispose();
249
+ if (launched?.ok === true) killProcess(launched.hProcess);
250
+ const detail = error instanceof Error ? error.message : String(error);
251
+ log(`relay attempt ${attempt}/${attempts} failed: ${detail}`);
252
+ if (attempt < attempts) await sleep(300 * attempt);
253
+ }
254
+ }
255
+ throw lastError;
256
+ }
257
+
258
+ function sleep(ms) {
259
+ return new Promise((resolve) => setTimeout(resolve, ms));
260
+ }
261
+
262
+ /** Pump one command's stdio through the channel and settle on its exit code. */
263
+ function relay(channel, session) {
264
+ const { socket, server } = channel;
265
+ const { stdin, stdout, stderr } = session;
266
+ return new Promise((resolve, reject) => {
267
+ let settled = false;
268
+ const done = (code) => {
269
+ if (settled) return;
270
+ settled = true;
271
+ server.close();
272
+ socket.destroy();
273
+ resolve(code);
274
+ };
275
+ const bail = (error) => {
276
+ if (settled) return;
277
+ settled = true;
278
+ server.close();
279
+ socket.destroy();
280
+ reject(error);
281
+ };
282
+
283
+ const decode = createFrameDecoder((type, body) => {
284
+ switch (type) {
285
+ case Frame.STDOUT:
286
+ stdout.write(body);
287
+ break;
288
+ case Frame.STDERR:
289
+ stderr.write(body);
290
+ break;
291
+ case Frame.EXIT: {
292
+ const { code } = JSON.parse(body.toString("utf8"));
293
+ done(code === null ? 1 : code);
294
+ break;
295
+ }
296
+ case Frame.FAIL:
297
+ // The boxed side could not start the command at all: that is a runner
298
+ // failure, not a command failure, so it takes the fatal signature and
299
+ // the runner's own exit code.
300
+ stderr.write(`dsh-sbie-run: ${body.toString("utf8")}\n`);
301
+ done(127);
302
+ break;
303
+ default:
304
+ break;
305
+ }
306
+ });
307
+ socket.on("data", decode);
308
+ socket.on("error", (error) => bail(error));
309
+ socket.on("close", () => {
310
+ if (!settled) bail(new Error("the boxed relay closed the channel before reporting an exit code"));
311
+ });
312
+
313
+ socket.write(encodeJsonFrame(Frame.SPEC, { argv: session.command, cwd: session.cwd, env: session.env }));
314
+
315
+ const forward = (chunk) => {
316
+ if (socket.destroyed) return;
317
+ if (typeof chunk === "string") socket.write(encodeFrame(Frame.STDIN, Buffer.from(chunk, "utf8")));
318
+ else socket.write(encodeFrame(Frame.STDIN, chunk));
319
+ };
320
+ if (stdin !== null && stdin !== undefined && typeof stdin.on === "function" && stdin.readable !== false) {
321
+ stdin.on("data", forward);
322
+ stdin.on("end", () => {
323
+ if (!socket.destroyed) socket.write(encodeFrame(Frame.STDIN_EOF));
324
+ });
325
+ stdin.on("error", () => {
326
+ if (!socket.destroyed) socket.write(encodeFrame(Frame.STDIN_EOF));
327
+ });
328
+ if (stdin.readableEnded === true) socket.write(encodeFrame(Frame.STDIN_EOF));
329
+ } else {
330
+ socket.write(encodeFrame(Frame.STDIN_EOF));
331
+ }
332
+ });
333
+ }
334
+
335
+ /** Entry point for the bin shim: exits with the command's code, or 127 on runner failure. */
336
+ export async function main(argv = process.argv.slice(2)) {
337
+ try {
338
+ const code = await run(argv);
339
+ process.exitCode = code;
340
+ } catch (error) {
341
+ fail(error instanceof Error ? error.message : String(error));
342
+ }
343
+ }
package/lib/sbie.mjs ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Sandboxie engine access.
3
+ *
4
+ * Two entry points exist and both are used here, for different reasons:
5
+ *
6
+ * - `SbieDll.dll!SbieDll_RunSandboxed` (stdcall, 6 args / 24 bytes — pin the
7
+ * arity against `SboxDll32.def`, `SbieDll_RunSandboxed=_SbieDll_RunSandboxed@24`)
8
+ * starts a process *inside* a box with no GUI involvement. It is the only
9
+ * documented-ish way to do that without `Start.exe`, which is a GUI-subsystem
10
+ * binary and therefore cannot carry pipes (measured, see M0 notes).
11
+ * The process is materialised by `SbieSvc` (Sandboxie's own comment in
12
+ * `start.cpp` says so), which is why our `STARTUPINFOW` std handles are
13
+ * *ignored*: the boxed side has to talk back over TCP.
14
+ * - `SbieIni.exe` mutates box configuration. It writes `Sandboxie.ini` through
15
+ * the service, so it works from an ordinary, non-elevated, non-admin process.
16
+ * Writing that file directly would need an administrator token.
17
+ */
18
+ import { spawnSync } from "node:child_process";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { loadKoffi } from "./koffi.mjs";
22
+
23
+ export const SBIE_DIR_CANDIDATES = [
24
+ process.env.DSH_SBIE_DIR,
25
+ process.env.ProgramFiles && path.join(process.env.ProgramFiles, "Sandboxie-Plus"),
26
+ process.env["ProgramFiles(x86)"] && path.join(process.env["ProgramFiles(x86)"], "Sandboxie-Plus"),
27
+ process.env.ProgramW6432 && path.join(process.env.ProgramW6432, "Sandboxie-Plus"),
28
+ "C:\\Program Files\\Sandboxie-Plus"
29
+ ].filter((value) => typeof value === "string" && value.length > 0);
30
+
31
+ /** The Sandboxie installation directory, or undefined when not installed. */
32
+ export function sbieDir() {
33
+ for (const dir of SBIE_DIR_CANDIDATES) {
34
+ if (fs.existsSync(path.join(dir, "SbieDll.dll"))) return dir;
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export function sbieExe(name) {
40
+ const dir = sbieDir();
41
+ if (dir === undefined) throw new Error("Sandboxie-Plus is not installed (SbieDll.dll not found)");
42
+ return path.join(dir, name);
43
+ }
44
+
45
+ /** Sandboxie's own default per-user sandbox root (re-exported from the pure naming module). */
46
+ export { sbieSandboxRoot } from "./naming.mjs";
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // SbieDll.dll
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const STARTF_USESHOWWINDOW = 0x00000001;
53
+ const SW_HIDE = 0;
54
+
55
+ let api;
56
+
57
+ /**
58
+ * Load `SbieDll.dll` and declare only the exports this plugin needs.
59
+ * Loading the DLL into a plain Node process is safe: outside a box its hooks
60
+ * are pass-through (`Dll_BoxName` is unset), which is exactly what `Start.exe`
61
+ * does for itself.
62
+ */
63
+ export function sbieApi() {
64
+ if (api !== undefined) return api;
65
+ const dir = sbieDir();
66
+ if (dir === undefined) throw new Error("Sandboxie-Plus is not installed (SbieDll.dll not found)");
67
+ const koffi = loadKoffi();
68
+ const kernel32 = koffi.load("kernel32.dll");
69
+ const dll = koffi.load(path.join(dir, "SbieDll.dll"));
70
+
71
+ const STARTUPINFOW = koffi.struct("STARTUPINFOW", {
72
+ cb: "uint32",
73
+ lpReserved: "void *",
74
+ lpDesktop: "void *",
75
+ lpTitle: "void *",
76
+ dwX: "uint32",
77
+ dwY: "uint32",
78
+ dwXSize: "uint32",
79
+ dwYSize: "uint32",
80
+ dwXCountChars: "uint32",
81
+ dwYCountChars: "uint32",
82
+ dwFillAttribute: "uint32",
83
+ dwFlags: "uint32",
84
+ wShowWindow: "uint16",
85
+ cbReserved2: "uint16",
86
+ lpReserved2: "void *",
87
+ hStdInput: "void *",
88
+ hStdOutput: "void *",
89
+ hStdError: "void *"
90
+ });
91
+ const PROCESS_INFORMATION = koffi.struct("PROCESS_INFORMATION", {
92
+ hProcess: "void *",
93
+ hThread: "void *",
94
+ dwProcessId: "uint32",
95
+ dwThreadId: "uint32"
96
+ });
97
+
98
+ api = {
99
+ koffi,
100
+ STARTUPINFOW,
101
+ PROCESS_INFORMATION,
102
+ runSandboxed: dll.func("__stdcall", "SbieDll_RunSandboxed", "bool", [
103
+ "void *", // const WCHAR *boxName
104
+ "void *", // WCHAR *commandLine (mutable: CreateProcess semantics)
105
+ "void *", // WCHAR *currentDirectory
106
+ "uint32", // ULONG flags — CreateProcess creation flags, 0 is what Start.exe passes
107
+ koffi.inout(koffi.pointer(STARTUPINFOW)),
108
+ koffi.out(koffi.pointer(PROCESS_INFORMATION))
109
+ ]),
110
+ getStartError: dll.func("__stdcall", "SbieDll_GetStartError", "void *", []),
111
+ enumBoxes: dll.func("__stdcall", "SbieApi_EnumBoxes", "int32", ["uint32", "void *"]),
112
+ reloadConf: dll.func("__stdcall", "SbieApi_ReloadConf", "int32", ["int32", "uint32"]),
113
+ queryBoxPath: dll.func("__stdcall", "SbieApi_QueryBoxPath", "int32", [
114
+ "void *", "void *", "void *", "void *", "void *", "void *", "void *"
115
+ ]),
116
+ waitForSingleObject: kernel32.func("WaitForSingleObject", "uint32", ["void *", "uint32"]),
117
+ getExitCodeProcess: kernel32.func("GetExitCodeProcess", "bool", ["void *", koffi.out(koffi.pointer("uint32"))]),
118
+ closeHandle: kernel32.func("CloseHandle", "bool", ["void *"]),
119
+ terminateProcess: kernel32.func("TerminateProcess", "bool", ["void *", "uint32"]),
120
+ getLastError: kernel32.func("GetLastError", "uint32", []),
121
+ freeMem: dll.func("__stdcall", "SbieDll_FreeMem", "bool", ["void *"])
122
+ };
123
+ return api;
124
+ }
125
+
126
+ const wstr = (value) => Buffer.from(`${value}\0`, "utf16le");
127
+
128
+ /** Human-readable text for the failure of the most recent `SbieDll_RunSandboxed`. */
129
+ export function startErrorText() {
130
+ try {
131
+ const { getStartError, koffi } = sbieApi();
132
+ const ptr = getStartError();
133
+ if (ptr === null || ptr === 0 || ptr === 0n) return undefined;
134
+ const text = koffi.decode(ptr, "str16");
135
+ return typeof text === "string" && text.length > 0 ? text : undefined;
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Start `commandLine` inside `box` through the Sandboxie service.
143
+ *
144
+ * @param {{box: string, commandLine: string, cwd: string, flags?: number, inheritStdHandles?: boolean}} request
145
+ * @returns {{ok: true, pid: number, hProcess: unknown, hThread: unknown} | {ok: false, error: string}}
146
+ */
147
+ export function runSandboxed(request) {
148
+ const a = sbieApi();
149
+ const si = {
150
+ cb: 104,
151
+ lpReserved: null,
152
+ lpDesktop: null,
153
+ lpTitle: null,
154
+ dwX: 0,
155
+ dwY: 0,
156
+ dwXSize: 0,
157
+ dwYSize: 0,
158
+ dwXCountChars: 0,
159
+ dwYCountChars: 0,
160
+ dwFillAttribute: 0,
161
+ dwFlags: STARTF_USESHOWWINDOW,
162
+ wShowWindow: SW_HIDE,
163
+ cbReserved2: 0,
164
+ lpReserved2: null,
165
+ hStdInput: null,
166
+ hStdOutput: null,
167
+ hStdError: null
168
+ };
169
+ const pi = {};
170
+ const ok = a.runSandboxed(wstr(request.box), wstr(request.commandLine), wstr(request.cwd), request.flags ?? 0, si, pi);
171
+ if (!ok) return { ok: false, error: startErrorText() ?? `SbieDll_RunSandboxed failed (Win32 error ${a.getLastError()})` };
172
+ return { ok: true, pid: pi.dwProcessId, hProcess: pi.hProcess, hThread: pi.hThread };
173
+ }
174
+
175
+ /** Wait for a process handle; returns the exit code, or undefined on timeout. */
176
+ export function waitForProcess(hProcess, timeoutMs) {
177
+ const a = sbieApi();
178
+ const rc = a.waitForSingleObject(hProcess, timeoutMs);
179
+ if (rc !== 0) return undefined;
180
+ const code = [0];
181
+ a.getExitCodeProcess(hProcess, code);
182
+ return code[0];
183
+ }
184
+
185
+ export function closeHandle(handle) {
186
+ if (handle === null || handle === undefined) return;
187
+ try {
188
+ sbieApi().closeHandle(handle);
189
+ } catch {}
190
+ }
191
+
192
+ /** Best-effort kill of a process we started through the engine (relay cleanup on failure). */
193
+ export function killProcess(hProcess, exitCode = 1) {
194
+ if (hProcess === null || hProcess === undefined) return;
195
+ try {
196
+ const a = sbieApi();
197
+ a.terminateProcess(hProcess, exitCode);
198
+ a.closeHandle(hProcess);
199
+ } catch {}
200
+ }
201
+
202
+ /**
203
+ * Every box name Sandboxie currently knows about.
204
+ *
205
+ * `SbieApi_EnumBoxes` reports `index + 1` for the box it just filled in and a
206
+ * non-positive value once the enumeration is exhausted — it is not a plain
207
+ * NTSTATUS, which is easy to get wrong (measured).
208
+ */
209
+ export function listBoxNames() {
210
+ const a = sbieApi();
211
+ const buffer = Buffer.alloc(34 * 2);
212
+ const names = [];
213
+ for (let index = 0; index < 512; index += 1) {
214
+ buffer.fill(0);
215
+ const status = a.enumBoxes(index, buffer);
216
+ if (status <= 0) break;
217
+ const name = buffer.toString("utf16le").replace(/\0.*$/s, "");
218
+ if (name.length > 0) names.push(name);
219
+ }
220
+ return names;
221
+ }
222
+
223
+ export function boxExists(name) {
224
+ return listBoxNames().some((box) => box.toLowerCase() === name.toLowerCase());
225
+ }
226
+
227
+ /** Ask the engine to re-read `Sandboxie.ini` (`Start.exe /reload` does the same). */
228
+ export function reloadConfig() {
229
+ try {
230
+ sbieApi().reloadConf(-1, 0);
231
+ return true;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // SbieIni.exe — configuration writes without an administrator token
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Run `SbieIni.exe` and return its trimmed stdout.
243
+ * @param {string[]} args
244
+ * @returns {{ok: boolean, stdout: string, stderr: string, status: number|null}}
245
+ */
246
+ export function sbieIni(args, options = {}) {
247
+ const result = spawnSync(sbieExe("SbieIni.exe"), args, {
248
+ encoding: "utf8",
249
+ windowsHide: true,
250
+ timeout: options.timeoutMs ?? 20000
251
+ });
252
+ return {
253
+ ok: result.status === 0,
254
+ stdout: (result.stdout ?? "").trim(),
255
+ stderr: (result.stderr ?? "").trim(),
256
+ status: result.status
257
+ };
258
+ }
259
+
260
+ export function queryBoxSetting(box, key) {
261
+ const result = sbieIni(["query", box, key]);
262
+ return result.ok && result.stdout.length > 0 ? result.stdout : undefined;
263
+ }
264
+
265
+ export function setBoxSetting(box, key, value) {
266
+ const result = sbieIni(["set", box, key, value]);
267
+ return result.ok;
268
+ }