@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/CHANGELOG.md +74 -0
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh.md +150 -0
- package/bin/dsh-sbie-run.mjs +23 -0
- package/cordis.patch.yml +79 -0
- package/lib/boxes.mjs +220 -0
- package/lib/cli.mjs +164 -0
- package/lib/client.js +299 -0
- package/lib/core.mjs +59 -0
- package/lib/host.mjs +356 -0
- package/lib/inner.mjs +116 -0
- package/lib/koffi.mjs +95 -0
- package/lib/log.mjs +62 -0
- package/lib/naming.mjs +159 -0
- package/lib/note.mjs +43 -0
- package/lib/protocol.mjs +63 -0
- package/lib/provider.mjs +210 -0
- package/lib/redirect.mjs +167 -0
- package/lib/run.mjs +343 -0
- package/lib/sbie.mjs +268 -0
- package/lib/tool-schema.mjs +109 -0
- package/lib/tool.mjs +323 -0
- package/lib/words.mjs +46 -0
- package/package.json +94 -0
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
|
+
}
|