pi-better-background-tasks 0.2.4 → 0.2.6

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.
@@ -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
+ }