pi-better-sandbox 0.1.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/rules-page.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The `/sandbox rules` page.
3
+ *
4
+ * A compact keyboard-first editor for the write-deny rules: one selector
5
+ * listing the rules in force in the open project, then any stored rule that
6
+ * cannot apply here, then three actions (add, remove the highlighted rule,
7
+ * restore the packaged defaults). Arrow keys and enter drive it; escape closes
8
+ * it. Nothing here validates a path, reads or
9
+ * writes the override, or touches the controller — every action is one call
10
+ * into `DenyRuleManager`, the same object `/sandbox deny …` drives, which is
11
+ * what makes the page and the commands incapable of disagreeing.
12
+ *
13
+ * The page is human-only in the same three ways the rest of the package is: it
14
+ * is reachable only from a slash command, no tool is registered that can open
15
+ * or drive it, and it refuses to run without an interactive UI rather than
16
+ * silently applying a default answer.
17
+ */
18
+
19
+ import { basename } from "node:path";
20
+
21
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
22
+
23
+ import { DenyRuleError, type DenyRule, type DenyRuleManager, type DenyRuleReport } from "./deny-rules.ts";
24
+
25
+ const ADD_ACTION = "+ Add a write-denied path…";
26
+ const RESTORE_ACTION = "↺ Restore the packaged defaults";
27
+ const CLOSE_ACTION = "Close";
28
+
29
+ const ADD_PROMPT = "Deny writes to";
30
+ const ADD_PLACEHOLDER = "project-relative path, ~/path, or /absolute/path";
31
+
32
+ export const RULES_PAGE_NO_UI_REJECTION =
33
+ "/sandbox rules needs an interactive UI. Use /sandbox deny list, add, remove, or reset instead.";
34
+
35
+ /**
36
+ * Open the rules page and run it until the human closes it.
37
+ *
38
+ * Resolves once the page is closed. Every accepted change is already persisted
39
+ * and in force by then, because the manager applies and announces before it
40
+ * returns.
41
+ */
42
+ export async function openSandboxRulesPage(
43
+ manager: DenyRuleManager,
44
+ ctx: ExtensionCommandContext,
45
+ ): Promise<void> {
46
+ if (!ctx.hasUI) {
47
+ ctx.ui.notify(RULES_PAGE_NO_UI_REJECTION, "error");
48
+ return;
49
+ }
50
+
51
+ let report = manager.report();
52
+ for (;;) {
53
+ // Rules in force first, then any stored rule that cannot apply here.
54
+ // Both are selectable, because a stale global rule is exactly the kind a
55
+ // human opens this page to delete.
56
+ const choices = new Map<string, Row>();
57
+ for (const rule of report.rules) choices.set(describeRule(rule), { template: rule.template, label: rule.path });
58
+ for (const rule of report.inert) {
59
+ choices.set(`${rule.template} (not applied in this project)`, {
60
+ template: rule.template,
61
+ label: rule.template,
62
+ inertReason: rule.reason,
63
+ });
64
+ }
65
+ const options = [...choices.keys(), ADD_ACTION, RESTORE_ACTION, CLOSE_ACTION];
66
+
67
+ // Escape returns undefined, which closes the page the same way Close does.
68
+ const chosen = await ctx.ui.select(pageTitle(report), options);
69
+ if (chosen === undefined || chosen === CLOSE_ACTION) return;
70
+
71
+ if (chosen === ADD_ACTION) {
72
+ report = (await addRule(manager, ctx)) ?? report;
73
+ continue;
74
+ }
75
+
76
+ if (chosen === RESTORE_ACTION) {
77
+ report = (await restoreDefaults(manager, ctx, report)) ?? report;
78
+ continue;
79
+ }
80
+
81
+ const row = choices.get(chosen);
82
+ if (row === undefined) continue;
83
+ report = (await removeRule(manager, ctx, row)) ?? report;
84
+ }
85
+ }
86
+
87
+ /** One selectable line: a rule in force, or a stored rule that is not. */
88
+ type Row = { template: string; label: string; inertReason?: string };
89
+
90
+ /** `<n> write-denied paths · <project> · packaged defaults | your override` */
91
+ function pageTitle(report: DenyRuleReport): string {
92
+ const project = report.status.projectRoot;
93
+ const count = report.rules.length;
94
+ return [
95
+ `${count} write-denied ${count === 1 ? "path" : "paths"}`,
96
+ project === undefined ? undefined : basename(project),
97
+ report.origin === "override" ? "your override" : "packaged defaults",
98
+ ]
99
+ .filter((part) => part !== undefined)
100
+ .join(" · ");
101
+ }
102
+
103
+ /**
104
+ * One rule as one line: the canonical absolute path it denies right now, and
105
+ * the stored template when that differs, so a relative rule never looks like it
106
+ * only applies here.
107
+ */
108
+ function describeRule(rule: DenyRule): string {
109
+ return rule.template === rule.path ? rule.path : `${rule.path} [${rule.template}]`;
110
+ }
111
+
112
+ async function addRule(
113
+ manager: DenyRuleManager,
114
+ ctx: ExtensionCommandContext,
115
+ ): Promise<DenyRuleReport | undefined> {
116
+ const entry = await ctx.ui.input(ADD_PROMPT, ADD_PLACEHOLDER);
117
+ if (entry === undefined || entry.trim() === "") return undefined;
118
+ return applyChange(ctx, () => manager.add(entry));
119
+ }
120
+
121
+ async function removeRule(
122
+ manager: DenyRuleManager,
123
+ ctx: ExtensionCommandContext,
124
+ row: Row,
125
+ ): Promise<DenyRuleReport | undefined> {
126
+ const confirmed = await ctx.ui.confirm(
127
+ row.inertReason === undefined
128
+ ? "Stop denying writes to this path?"
129
+ : "Delete this rule from your global rule set?",
130
+ row.inertReason === undefined
131
+ ? `${row.label}\n\nOperations started after this will be able to write there again.`
132
+ : `${row.label}\n\nIt is not applied here — ${row.inertReason} Deleting it removes it from every project.`,
133
+ );
134
+ if (!confirmed) return undefined;
135
+ return applyChange(ctx, () => manager.remove(row.template));
136
+ }
137
+
138
+ async function restoreDefaults(
139
+ manager: DenyRuleManager,
140
+ ctx: ExtensionCommandContext,
141
+ report: DenyRuleReport,
142
+ ): Promise<DenyRuleReport | undefined> {
143
+ if (report.origin !== "override" && report.overrideProblem === undefined) {
144
+ ctx.ui.notify("The packaged defaults are already in force.", "info");
145
+ return undefined;
146
+ }
147
+ const confirmed = await ctx.ui.confirm(
148
+ "Restore the packaged write-deny defaults?",
149
+ `Your override at ${report.overridePath} will be deleted and every rule you added or removed will be forgotten.`,
150
+ );
151
+ if (!confirmed) return undefined;
152
+ return applyChange(ctx, () => manager.reset());
153
+ }
154
+
155
+ /**
156
+ * Run one manager call, show the outcome, and keep the page open either way.
157
+ *
158
+ * A refused change is reported and the page is left showing the rules that are
159
+ * really in force — never a hopeful view of the change that did not happen.
160
+ */
161
+ function applyChange(
162
+ ctx: ExtensionCommandContext,
163
+ change: () => DenyRuleReport,
164
+ ): DenyRuleReport | undefined {
165
+ try {
166
+ const report = change();
167
+ ctx.ui.notify(report.summary, "info");
168
+ return report;
169
+ } catch (error) {
170
+ if (error instanceof DenyRuleError) {
171
+ ctx.ui.notify(error.message, "error");
172
+ return undefined;
173
+ }
174
+ throw error;
175
+ }
176
+ }
@@ -0,0 +1,462 @@
1
+ // Generated from packages/sandbox-core/index.ts. Do not edit directly.
2
+ /**
3
+ * OS-level write sandbox mechanism shared by Pi extensions.
4
+ *
5
+ * Kernel-enforced confinement: the sandboxed process may READ anywhere and use
6
+ * the network (so web_fetch and the model API keep working), but may only WRITE
7
+ * under a single canonical root plus the system paths pi itself needs. Unlike a
8
+ * cooperative guardrails layer (which pattern-matches tool inputs), this cannot
9
+ * be evaded by a crafted bash command — the write syscall itself is denied.
10
+ *
11
+ * This module owns the mechanism only: backend discovery, canonical path
12
+ * containment, write-deny compilation, macOS SBPL profile construction, Linux
13
+ * Bubblewrap mount construction, ordered executable/argv wrapping, and support
14
+ * diagnostics. It owns no Pi tool, TUI, background-task, or subagent lifecycle
15
+ * policy — callers decide when a sandbox is requested and what it may write.
16
+ *
17
+ * Every platform/filesystem dependency is reachable through the optional
18
+ * `SandboxSeams` argument so callers can plan deterministically in tests.
19
+ */
20
+
21
+ import { platform as osPlatform } from "node:os";
22
+ import {
23
+ accessSync,
24
+ closeSync,
25
+ constants,
26
+ existsSync,
27
+ mkdirSync,
28
+ openSync,
29
+ realpathSync,
30
+ statSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
34
+
35
+ /** Identifies which kernel mechanism a plan will use. */
36
+ export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
37
+
38
+ /**
39
+ * What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
40
+ * be relative or contain symlinks; they are canonicalized before use.
41
+ */
42
+ export type SandboxWritePolicy = {
43
+ /** The single directory subtree the sandboxed process may write under. */
44
+ writableRoot: string;
45
+ /**
46
+ * Concrete paths that stay non-writable even inside `writableRoot`. A
47
+ * directory entry denies its whole subtree; a file entry denies that file.
48
+ *
49
+ * An entry need not exist. The Linux backend needs a mount point, so it
50
+ * materializes an absent entry as an empty file (see `materializeDenyPath`);
51
+ * an entry that has to be a *directory* must therefore already exist when the
52
+ * command is built. Callers denying their own state directory create it
53
+ * first, which they do anyway to write into it.
54
+ */
55
+ denyWrite?: readonly string[];
56
+ /** Home directory whose `~/.pi` state stays writable on macOS. */
57
+ home: string;
58
+ };
59
+
60
+ /** The executable and argv to run inside the sandbox, preserved verbatim. */
61
+ export type SandboxTarget = {
62
+ execPath: string;
63
+ execArgs: readonly string[];
64
+ };
65
+
66
+ export type SandboxCommandArgs = SandboxTarget & {
67
+ /** Where the macOS backend writes its generated SBPL profile. */
68
+ profilePath: string;
69
+ policy: SandboxWritePolicy;
70
+ };
71
+
72
+ /** The wrapper command to spawn: the backend executable and its full argv. */
73
+ export type SandboxCommand = { file: string; fileArgs: string[] };
74
+
75
+ /** The caller's default-on / explicit-request / opt-out decision. */
76
+ export type SandboxRequest = {
77
+ sandboxEnabled: boolean;
78
+ explicitSandbox: boolean;
79
+ /**
80
+ * What this caller's operator can actually do about a missing backend,
81
+ * appended when an explicit request has to be refused. Surfaces differ: a
82
+ * subagent tool takes `sandbox:false`, a foreground session takes
83
+ * `/sandbox off`, so the remedy cannot be stated here.
84
+ */
85
+ remedy?: string;
86
+ };
87
+
88
+ /** Injectable platform and filesystem dependencies. Defaults hit the real OS. */
89
+ export type SandboxSeams = {
90
+ /** Defaults to `os.platform()`. */
91
+ platform?: () => string;
92
+ /** Defaults to a PATH scan that stats and access-checks without executing. */
93
+ lookupExecutable?: (name: string) => string | undefined;
94
+ /** Defaults to `fs.realpathSync`. Must throw when the path does not exist. */
95
+ canonicalize?: (path: string) => string;
96
+ /** Defaults to `fs.writeFileSync`. */
97
+ writeProfile?: (path: string, contents: string) => void;
98
+ /**
99
+ * Defaults to creating an empty placeholder file for an absent denied path
100
+ * (see `materializeDenyPath`). Returns whether the path exists afterwards.
101
+ * Injected by tests that plan Linux argv for paths that do not exist on the
102
+ * host running them.
103
+ */
104
+ materializeDenyPath?: (path: string) => boolean;
105
+ };
106
+
107
+ /** A policy with every path canonicalized, deduplicated, and ordered. */
108
+ export type CompiledSandboxWritePolicy = {
109
+ readonly writableRoot: string;
110
+ readonly denyWrite: readonly string[];
111
+ readonly home: string;
112
+ };
113
+
114
+ /** Why a write target is or is not permitted by a compiled policy. */
115
+ export type WriteAccessDecision =
116
+ | { allowed: true; path: string }
117
+ | {
118
+ allowed: false;
119
+ path: string;
120
+ reason: "outside-writable-root" | "write-denied";
121
+ /** The compiled deny entry that matched, for `write-denied` only. */
122
+ deniedBy?: string;
123
+ };
124
+
125
+ /** What the current platform can enforce, and why it cannot when it cannot. */
126
+ export type SandboxSupport =
127
+ | { supported: true; platform: string; backend: SandboxBackendId; executable: string }
128
+ | {
129
+ supported: false;
130
+ platform: string;
131
+ backend: undefined;
132
+ executable: undefined;
133
+ reason: string;
134
+ };
135
+
136
+ type SandboxBackend = {
137
+ id: SandboxBackendId;
138
+ executable: string;
139
+ buildCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand;
140
+ };
141
+
142
+ const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
143
+
144
+ function currentPlatform(seams: SandboxSeams): string {
145
+ return (seams.platform ?? osPlatform)();
146
+ }
147
+
148
+ /**
149
+ * Resolve `path` to an absolute canonical path. Symlinks are resolved on the
150
+ * longest existing ancestor so a target that does not exist yet still
151
+ * canonicalizes through its real parent chain.
152
+ */
153
+ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string {
154
+ const canonicalize = seams.canonicalize ?? realpathSync;
155
+ const absolute = resolve(path);
156
+ try {
157
+ return canonicalize(absolute);
158
+ } catch {
159
+ // Not created yet (or unreadable): canonicalize the parent instead.
160
+ }
161
+ const parent = dirname(absolute);
162
+ if (parent === absolute) return absolute;
163
+ return join(canonicalizePath(parent, seams), basename(absolute));
164
+ }
165
+
166
+ function compile(
167
+ policy: SandboxWritePolicy,
168
+ seams: SandboxSeams,
169
+ strictRoot: boolean,
170
+ ): CompiledSandboxWritePolicy {
171
+ // The Linux backend has always required the writable root to exist before it
172
+ // bind-mounts it; the macOS backend has always tolerated a not-yet-created
173
+ // one. Keep both behaviors rather than unifying them here.
174
+ const writableRoot = strictRoot
175
+ ? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
176
+ : canonicalizePath(policy.writableRoot, seams);
177
+
178
+ const denyWrite = [
179
+ ...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
180
+ ].sort();
181
+
182
+ return { writableRoot, denyWrite, home: policy.home };
183
+ }
184
+
185
+ /**
186
+ * Canonicalize a write policy once so containment checks and backend rules
187
+ * agree on exactly which paths they are talking about.
188
+ */
189
+ export function compileWritePolicy(
190
+ policy: SandboxWritePolicy,
191
+ seams: SandboxSeams = {},
192
+ ): CompiledSandboxWritePolicy {
193
+ return compile(policy, seams, false);
194
+ }
195
+
196
+ function contains(root: string, target: string): boolean {
197
+ if (target === root) return true;
198
+ return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
199
+ }
200
+
201
+ /**
202
+ * Decide whether an in-process write to `target` is permitted by a compiled
203
+ * policy. This is the same containment rule the kernel backends enforce, for
204
+ * callers that mutate files directly instead of spawning a child.
205
+ */
206
+ export function evaluateWriteAccess(
207
+ target: string,
208
+ policy: CompiledSandboxWritePolicy,
209
+ seams: SandboxSeams = {},
210
+ ): WriteAccessDecision {
211
+ const path = canonicalizePath(target, seams);
212
+ if (!contains(policy.writableRoot, path)) {
213
+ return { allowed: false, path, reason: "outside-writable-root" };
214
+ }
215
+ for (const denied of policy.denyWrite) {
216
+ if (contains(denied, path)) {
217
+ return { allowed: false, path, reason: "write-denied", deniedBy: denied };
218
+ }
219
+ }
220
+ return { allowed: true, path };
221
+ }
222
+
223
+ /** Quote a path as an SBPL string literal. */
224
+ function sbpl(path: string): string {
225
+ return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
226
+ }
227
+
228
+ /** Build the macOS sandbox-exec wrapper and its SBPL profile. */
229
+ function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
230
+ // Match on the real (symlink-resolved) path — sandbox-exec evaluates the
231
+ // canonical path, so /tmp/x must be written as /private/tmp/x.
232
+ const policy = compile(args.policy, seams, false);
233
+
234
+ const profile = [
235
+ "(version 1)",
236
+ "(allow default)", // permissive base: reads, exec, network
237
+ "(deny file-write*)", // ...then deny all writes...
238
+ `(allow file-write* (subpath ${sbpl(policy.writableRoot)}))`, // ...except here
239
+ `(allow file-write* (subpath ${sbpl(`${policy.home}/.pi`)}))`, // pi state
240
+ '(allow file-write* (subpath "/private/var/folders"))', // macOS temp / our runtime
241
+ '(allow file-write* (subpath "/private/tmp"))',
242
+ '(allow file-write* (subpath "/dev"))', // /dev/null etc.
243
+ // Deny rules come last: SBPL applies the last matching rule, so these
244
+ // carve holes back out of the allowances above.
245
+ ...policy.denyWrite.map((path) => `(deny file-write* (subpath ${sbpl(path)}))`),
246
+ "",
247
+ ].join("\n");
248
+ (seams.writeProfile ?? writeFileSync)(args.profilePath, profile);
249
+
250
+ return {
251
+ file: MACOS_SANDBOX_EXEC,
252
+ fileArgs: ["-f", args.profilePath, args.execPath, ...args.execArgs],
253
+ };
254
+ }
255
+
256
+ const macOSSandboxBackend: SandboxBackend = {
257
+ id: "macos-seatbelt",
258
+ executable: MACOS_SANDBOX_EXEC,
259
+ buildCommand: buildMacOSSandboxCommand,
260
+ };
261
+
262
+ /** Resolve an executable from PATH without starting it or probing namespaces. */
263
+ export function executableFromPath(name: string): string | undefined {
264
+ const path = process.env.PATH;
265
+ if (!path) return undefined;
266
+
267
+ for (const entry of path.split(delimiter)) {
268
+ const candidate = resolve(entry || ".", name);
269
+ try {
270
+ if (!statSync(candidate).isFile()) continue;
271
+ accessSync(candidate, constants.X_OK);
272
+ return candidate;
273
+ } catch {
274
+ // A PATH entry may disappear or be inaccessible between lookup and use.
275
+ }
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ /**
281
+ * Give an absent denied path something the kernel can hold out.
282
+ *
283
+ * A mount needs a mount point. `--ro-bind-try` skips a source that does not
284
+ * exist, so before this a denied path that had not been created yet was not
285
+ * denied at all: inside the sandbox `echo secret > .env.local` simply created
286
+ * it. `.env` and `.env.local` are absent in most projects, which made the
287
+ * packaged defaults hold on macOS — SBPL denies by resolved path, existing or
288
+ * not — and not on Linux.
289
+ *
290
+ * Nothing bubblewrap offers closes that without a mount point, and every
291
+ * bubblewrap operation that would create one (`--dir`, `--file`, `--tmpfs`)
292
+ * creates it through the read-write bind of the project, which is to say on the
293
+ * real filesystem anyway. So the placeholder is created here, deliberately and
294
+ * visibly, rather than as a side effect of a mount operation.
295
+ *
296
+ * An empty regular file is the least destructive placeholder: a later
297
+ * `cp .env.example .env` overwrites it, where an empty *directory* at that path
298
+ * would fail. Callers whose denied entry has to be a directory create it before
299
+ * building the command, and an entry that already exists — file or directory —
300
+ * is bound as it is. The file is created with O_EXCL, so anything that appears
301
+ * in the meantime is bound rather than clobbered, and it is left in place
302
+ * afterwards because a resumed task re-runs the launch vector it captured and
303
+ * its `--ro-bind` sources have to still be there.
304
+ *
305
+ * Returns false when the placeholder could not be created. That is not a hole:
306
+ * the confined process runs as this same user, so a path this process cannot
307
+ * create is a path that process cannot create either.
308
+ */
309
+ function materializeDenyPath(path: string): boolean {
310
+ if (existsSync(path)) return true;
311
+ try {
312
+ mkdirSync(dirname(path), { recursive: true });
313
+ closeSync(openSync(path, "wx"));
314
+ return true;
315
+ } catch {
316
+ // Re-check rather than trust the errno. EEXIST from the O_EXCL create
317
+ // means the path appeared in between, which is the outcome we wanted and
318
+ // not ours to overwrite; EEXIST from the mkdir means a parent is a
319
+ // regular file, and nothing can exist under it. Only the first leaves a
320
+ // source a bind can use.
321
+ return existsSync(path);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Whether a denied path lies in a region this backend binds read-write.
327
+ *
328
+ * Everywhere else is already covered by the read-only bind of `/`, so a
329
+ * placeholder there would deny nothing that is not denied already — and would
330
+ * scatter empty files across the host for the sake of it. Materializing is
331
+ * confined to the two regions that are genuinely writable inside the sandbox:
332
+ * the writable root, and the `/tmp` rebind that pi's own tooling needs.
333
+ */
334
+ function writableInsideLinuxSandbox(path: string, writableRoot: string): boolean {
335
+ return contains(writableRoot, path) || contains("/tmp", path);
336
+ }
337
+
338
+ function buildLinuxSandboxCommand(
339
+ bwrap: string,
340
+ args: SandboxCommandArgs,
341
+ seams: SandboxSeams,
342
+ ): SandboxCommand {
343
+ // The caller creates the selected work directory before it reaches this
344
+ // boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
345
+ // widening the writable root.
346
+ const policy = compile(args.policy, seams, true);
347
+ const materialize = seams.materializeDenyPath ?? materializeDenyPath;
348
+ const denyBinds = policy.denyWrite.flatMap((path) => {
349
+ const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
350
+ return [mountable ? "--ro-bind" : "--ro-bind-try", path, path];
351
+ });
352
+ return {
353
+ file: bwrap,
354
+ fileArgs: [
355
+ "--ro-bind", "/", "/",
356
+ "--bind", policy.writableRoot, policy.writableRoot,
357
+ "--bind", "/tmp", "/tmp",
358
+ "--dev", "/dev",
359
+ // Layered last so a denied path wins over every writable bind above.
360
+ // A denied path need not exist yet, so one inside a writable region
361
+ // is materialized first; `-try` remains for the paths that are
362
+ // read-only regardless and for the ones that could not be created,
363
+ // which are paths the confined process cannot create either.
364
+ ...denyBinds,
365
+ "--",
366
+ args.execPath, ...args.execArgs,
367
+ ],
368
+ };
369
+ }
370
+
371
+ function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
372
+ const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
373
+ if (!bwrap) return undefined;
374
+ return {
375
+ id: "linux-bubblewrap",
376
+ executable: bwrap,
377
+ buildCommand: (args, buildSeams) => buildLinuxSandboxCommand(bwrap, args, buildSeams),
378
+ };
379
+ }
380
+
381
+ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
382
+ const platform = currentPlatform(seams);
383
+ if (platform === "darwin") return macOSSandboxBackend;
384
+ if (platform === "linux") return linuxSandboxBackend(seams);
385
+ return undefined;
386
+ }
387
+
388
+ /**
389
+ * Why no backend applies here. The requirement only: what a caller can do
390
+ * instead is a property of that caller's surface, not of the platform, and is
391
+ * supplied through `SandboxRequest.remedy`.
392
+ */
393
+ function unavailableMessage(platform: string): string {
394
+ if (platform === "linux") {
395
+ return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap to enable it.";
396
+ }
397
+ if (platform === "darwin") {
398
+ return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
399
+ }
400
+ return `sandbox is unsupported on ${platform}.`;
401
+ }
402
+
403
+ /** Report which backend this platform would select, and why it would not. */
404
+ export function describeSandboxSupport(seams: SandboxSeams = {}): SandboxSupport {
405
+ const platform = currentPlatform(seams);
406
+ const backend = selectedSandboxBackend(seams);
407
+ if (!backend) {
408
+ return {
409
+ supported: false,
410
+ platform,
411
+ backend: undefined,
412
+ executable: undefined,
413
+ reason: unavailableMessage(platform),
414
+ };
415
+ }
416
+ return { supported: true, platform, backend: backend.id, executable: backend.executable };
417
+ }
418
+
419
+ /** The message explaining why no backend is available on this platform. */
420
+ export function sandboxUnavailableMessage(seams: SandboxSeams = {}): string {
421
+ return unavailableMessage(currentPlatform(seams));
422
+ }
423
+
424
+ /** True when an OS write-sandbox backend can be applied on this platform. */
425
+ export function sandboxSupported(seams: SandboxSeams = {}): boolean {
426
+ return selectedSandboxBackend(seams) !== undefined;
427
+ }
428
+
429
+ /**
430
+ * Resolve the caller's default-on, explicit-request, and opt-out policy before
431
+ * spawning. A selected backend always returns its wrapper; callers never retry
432
+ * the child directly when that wrapper exits or cannot initialize.
433
+ */
434
+ export function maybeBuildSandboxCommand(
435
+ args: SandboxCommandArgs,
436
+ request: SandboxRequest,
437
+ seams: SandboxSeams = {},
438
+ ): SandboxCommand | undefined {
439
+ if (!request.sandboxEnabled) return undefined;
440
+
441
+ const backend = selectedSandboxBackend(seams);
442
+ if (!backend) {
443
+ if (request.explicitSandbox) {
444
+ const reason = sandboxUnavailableMessage(seams);
445
+ throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
446
+ }
447
+ return undefined;
448
+ }
449
+ return backend.buildCommand(args, seams);
450
+ }
451
+
452
+ /**
453
+ * Return the selected backend's executable and ordered argv wrapper around the
454
+ * target. The fallback preserves the pre-existing direct-call result for callers
455
+ * that bypass the request-policy helper above.
456
+ */
457
+ export function buildSandboxCommand(
458
+ args: SandboxCommandArgs,
459
+ seams: SandboxSeams = {},
460
+ ): SandboxCommand {
461
+ return (selectedSandboxBackend(seams) ?? macOSSandboxBackend).buildCommand(args, seams);
462
+ }