pi-better-background-tasks 0.2.20 → 0.4.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.
@@ -18,32 +18,55 @@
18
18
  * `SandboxSeams` argument so callers can plan deterministically in tests.
19
19
  */
20
20
 
21
- import { spawnSync } from "node:child_process";
21
+ import { spawn, spawnSync } from "node:child_process";
22
22
  import { platform as osPlatform } from "node:os";
23
23
  import {
24
24
  accessSync,
25
25
  closeSync,
26
26
  constants,
27
27
  existsSync,
28
+ lstatSync,
28
29
  mkdirSync,
29
30
  openSync,
31
+ readdirSync,
32
+ readlinkSync,
30
33
  realpathSync,
31
34
  statSync,
32
35
  writeFileSync,
33
36
  } from "node:fs";
34
- import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
37
+ import { basename, delimiter, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
35
38
 
36
39
  /** Identifies which kernel mechanism a plan will use. */
37
40
  export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
38
41
 
42
+ /**
43
+ * File access levels for the Project files and Outside project rows.
44
+ *
45
+ * - `off`: no access.
46
+ * - `read`: read only.
47
+ * - `write`: read, create and overwrite in place. Nothing can be removed or
48
+ * renamed away (unlink, rmdir, rename of an existing entry, or a rename that
49
+ * replaces one), except in the always-disposable places: temp, hidden (dot)
50
+ * entries of the home directory, and git worktree folders (`.worktrees/`,
51
+ * `*-worktrees/`). For Outside project this is the broad-write profile:
52
+ * writes across home and temp, and the fixed deny list.
53
+ * - `read-write`: "Write & delete" — the historical read/write level.
54
+ */
55
+ export type SandboxFileAccess = "off" | "read" | "write" | "read-write";
56
+
39
57
  export type SandboxPermissions = {
40
- projectFiles: "off" | "read" | "read-write";
41
- outsideProject: "off" | "read" | "read-write";
58
+ projectFiles: SandboxFileAccess;
59
+ outsideProject: SandboxFileAccess;
42
60
  storedCredentials: "off" | "read" | "read-write";
43
61
  commands: boolean;
44
62
  network: boolean;
45
63
  };
46
64
 
65
+ /** Whether a file access level allows creating and overwriting files. */
66
+ export function canWrite(mode: SandboxFileAccess | undefined): boolean {
67
+ return mode === "write" || mode === "read-write";
68
+ }
69
+
47
70
  /**
48
71
  * What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
49
72
  * be relative or contain symlinks; they are canonicalized before use.
@@ -121,6 +144,16 @@ export type SandboxSeams = {
121
144
  materializeDenyPath?: (path: string) => boolean;
122
145
  /** Defaults to /usr/bin/getconf with a minimal environment (macOS only). */
123
146
  getconf?: (name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR") => string | undefined;
147
+ /** Broad-write Linux planning: directory listing (defaults to `readdirSync`). */
148
+ listDirectory?: (path: string) => { name: string; directory: boolean; symlink: boolean }[];
149
+ /** Broad-write Linux planning: existence (defaults to `existsSync`). */
150
+ pathExists?: (path: string) => boolean;
151
+ /** Broad-write Linux planning: whether an existing path is a directory. */
152
+ isDirectory?: (path: string) => boolean;
153
+ /** Broad-write Linux planning: create an absent directory-type deny entry. */
154
+ makeDirectory?: (path: string) => void;
155
+ /** Broad-write Linux planning: empty mode-000 mask sources. */
156
+ maskSources?: (profilePath: string) => { directory: string; file: string };
124
157
  };
125
158
 
126
159
  /** A policy with every path canonicalized, deduplicated, and ordered. */
@@ -132,6 +165,20 @@ export type CompiledSandboxWritePolicy = {
132
165
  readonly credentialPaths?: readonly string[];
133
166
  readonly runtimeWrite?: readonly string[];
134
167
  readonly compatibilityWrite?: readonly string[];
168
+ /** Present only for the broad-write profile (Outside project = `write`). */
169
+ readonly broad?: CompiledBroadWrite;
170
+ };
171
+
172
+ /** The canonical inputs of a broad-write profile. */
173
+ export type CompiledBroadWrite = {
174
+ /** Canonical home directory. Writes are allowed under it, minus the deny list. */
175
+ readonly home: string;
176
+ /** Canonical temp roots (writable and removal-allowed). */
177
+ readonly tempRoots: readonly string[];
178
+ /** Canonical removal-allowed roots besides temp, dot entries and worktree folders: a Write & delete workspace and runtime scratch. */
179
+ readonly deleteRoots: readonly string[];
180
+ /** Code that runs later: never writable, removable or renamable. Still readable. */
181
+ readonly codePaths: readonly string[];
135
182
  };
136
183
 
137
184
  /** Why a write target is or is not permitted by a compiled policy. */
@@ -140,7 +187,7 @@ export type WriteAccessDecision =
140
187
  | {
141
188
  allowed: false;
142
189
  path: string;
143
- reason: "outside-writable-root" | "write-denied" | "permission-denied";
190
+ reason: "outside-writable-root" | "write-denied" | "permission-denied" | "delete-denied";
144
191
  /** The compiled deny entry that matched, for `write-denied` only. */
145
192
  deniedBy?: string;
146
193
  };
@@ -173,6 +220,43 @@ const CREDENTIAL_LOCATIONS = [
173
220
  ".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
174
221
  ] as const;
175
222
 
223
+ /**
224
+ * Credential stores added by the broad-write profile. They are never readable
225
+ * or writable there, whatever the Stored credentials row says.
226
+ */
227
+ const BROAD_CREDENTIAL_LOCATIONS = [
228
+ "Library/Keychains", ".claude/.credentials.json", ".claude.json", ".gnupg",
229
+ ".codex/auth.json", ".cargo/credentials.toml", ".cargo/credentials", ".pgpass", ".config/rclone",
230
+ ] as const;
231
+
232
+ /**
233
+ * Code that runs later: shell startup files, agent/harness configuration and
234
+ * login agents. The broad-write profile keeps them readable (skills and
235
+ * extension sources are read by confined tasks) but never writable, removable
236
+ * or renamable. This list is fixed; callers extend it with `denyWrite`.
237
+ */
238
+ const CODE_LATER_LOCATIONS = [
239
+ ".bashrc", ".bash_profile", ".bash_login", ".bash_logout", ".profile",
240
+ ".zshrc", ".zshenv", ".zprofile", ".zlogin", ".zlogout",
241
+ ".config/fish", ".config/git", ".gitconfig", ".config/systemd/user", ".config/autostart",
242
+ ".pi", ".claude", ".agents", "Library/LaunchAgents",
243
+ ".local/bin", "bin", ".git-templates", ".oh-my-zsh/custom", ".gradle/init.d",
244
+ ".cargo/config.toml", ".cargo/config",
245
+ ] as const;
246
+
247
+ /** Deny-list entries that are directories when they exist (Linux materializes them as such). */
248
+ const DIRECTORY_LOCATIONS = new Set([
249
+ ".ssh", ".aws", ".config/gh", ".config/gcloud", ".azure", ".kube", "Library/Keychains",
250
+ ".config/fish", ".config/git", ".config/systemd/user", ".config/autostart",
251
+ ".pi", ".claude", ".agents", "Library/LaunchAgents", ".gnupg", ".config/rclone",
252
+ ".local/bin", "bin", ".git-templates", ".oh-my-zsh/custom", ".gradle/init.d",
253
+ ]);
254
+
255
+ const BROAD_TEMP_ROOTS: Record<string, readonly string[]> = {
256
+ darwin: ["/private/tmp", "/private/var/folders", "/private/var/tmp"],
257
+ linux: ["/tmp", "/var/tmp", "/dev/shm"],
258
+ };
259
+
176
260
  // Executables, dynamic libraries and OS frameworks needed to start a child.
177
261
  // This deliberately excludes the home directory, /etc, and credential stores.
178
262
  // A process relying on /etc or /proc configuration (DNS, certificates, NSS)
@@ -277,25 +361,153 @@ function compile(
277
361
  ? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
278
362
  : canonicalizePath(policy.writableRoot, seams);
279
363
 
364
+ // The broad profile also keeps each literal entry: its removal grants would
365
+ // otherwise let a task replace a symlinked control path.
366
+ const lexicalDeny = isBroadWritePermissions(policy.permissions);
280
367
  const denyWrite = [
281
- ...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
368
+ ...new Set((policy.denyWrite ?? []).flatMap((entry) => lexicalDeny
369
+ ? [resolve(entry), ...symlinkHops(entry), canonicalizePath(entry, seams)] : [canonicalizePath(entry, seams)])),
282
370
  ].sort();
283
371
 
284
- const compatibilityWrite = compatibilityPaths(policy, seams);
372
+ const broadMode = isBroadWritePermissions(policy.permissions);
373
+ const compatibilityWrite = broadMode ? [] : compatibilityPaths(policy, seams);
374
+ const runtimeWrite = [...new Set([
375
+ ...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
376
+ ...compatibilityWrite,
377
+ ])];
285
378
  return {
286
379
  writableRoot, denyWrite, home: policy.home,
287
380
  ...(policy.permissions && {
288
- permissions: { ...policy.permissions },
289
- credentialPaths: credentialFilePaths(policy.home, seams),
381
+ // The broad-write profile's credential deny list is not optional.
382
+ permissions: broadMode
383
+ ? { ...policy.permissions, storedCredentials: "off" as const }
384
+ : { ...policy.permissions },
385
+ credentialPaths: broadMode ? broadCredentialPaths(policy.home, seams) : credentialFilePaths(policy.home, seams),
290
386
  compatibilityWrite,
291
- runtimeWrite: [...new Set([
292
- ...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
293
- ...compatibilityWrite,
294
- ])],
387
+ runtimeWrite,
388
+ }),
389
+ ...(broadMode && {
390
+ broad: compileBroadWrite(policy, writableRoot, runtimeWrite, seams),
295
391
  }),
296
392
  };
297
393
  }
298
394
 
395
+ /** True when a permission profile selects the broad-write profile. */
396
+ export function isBroadWritePermissions(permissions: SandboxPermissions | undefined): boolean {
397
+ return permissions?.outsideProject === "write";
398
+ }
399
+
400
+ function expandHome(path: string, home: string): string {
401
+ return path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
402
+ }
403
+
404
+ /**
405
+ * A fixed-list location both as the literal entry under the canonical home and
406
+ * as the path it resolves to. A dotfiles manager (stow, chezmoi) makes
407
+ * `~/.zshrc` a symlink into an ordinary folder: guarding only the target would
408
+ * let a task remove the link and put a real file in its place.
409
+ */
410
+ function lexicalAndCanonical(home: string, name: string, seams: SandboxSeams): string[] {
411
+ const lexical = join(canonicalizePath(home, seams), name);
412
+ return [lexical, ...symlinkHops(lexical), canonicalizePath(lexical, seams)];
413
+ }
414
+
415
+ /**
416
+ * Every symlink directory entry met while resolving `path`, component by
417
+ * component (as `readlink` would follow them). A chain such as
418
+ * `~/.zshrc -> ~/.dotfiles/zshrc -> ~/dotfiles/zshrc` has an intermediate link
419
+ * in a removable directory; replacing it would retarget the protected entry, so
420
+ * each hop the user could replace is protected too. Bounded: a loop or more
421
+ * than 40 hops stops the walk (such a path resolves to nothing).
422
+ */
423
+ function symlinkHops(path: string): string[] {
424
+ const hops: string[] = [];
425
+ const seen = new Set<string>();
426
+ let followed = 0;
427
+ const absolute = resolve(path);
428
+ let current = parse(absolute).root;
429
+ let pending = absolute.slice(current.length).split(sep);
430
+ while (pending.length) {
431
+ const component = pending.shift()!;
432
+ if (!component || component === ".") continue;
433
+ if (component === "..") { current = dirname(current); continue; }
434
+ const entry = join(current, component);
435
+ let link: boolean;
436
+ try { link = lstatSync(entry).isSymbolicLink(); } catch { break; }
437
+ if (!link) { current = entry; continue; }
438
+ if (++followed > 40 || seen.has(entry)) break;
439
+ seen.add(entry);
440
+ // Only a link the user can replace matters (not e.g. macOS's root-owned /tmp -> private/tmp).
441
+ try { accessSync(current, constants.W_OK); hops.push(entry); } catch { /* immutable entry */ }
442
+ let target: string;
443
+ try { target = readlinkSync(entry); } catch { break; }
444
+ if (isAbsolute(target)) {
445
+ current = parse(target).root;
446
+ pending = [...target.slice(current.length).split(sep), ...pending];
447
+ } else {
448
+ pending = [...target.split(sep), ...pending];
449
+ }
450
+ }
451
+ return hops;
452
+ }
453
+
454
+ function broadCredentialPaths(home: string, seams: SandboxSeams): string[] {
455
+ const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
456
+ return [...new Set([
457
+ ...credentialFilePaths(home, seams),
458
+ ...[...CREDENTIAL_LOCATIONS, ...BROAD_CREDENTIAL_LOCATIONS].flatMap((name) => lexicalAndCanonical(home, name, seams)),
459
+ ...(configuredAgentDir ? [resolve(expandHome(configuredAgentDir, home), "auth.json"),
460
+ ...symlinkHops(resolve(expandHome(configuredAgentDir, home), "auth.json"))] : []),
461
+ ])].sort();
462
+ }
463
+
464
+ function compileBroadWrite(
465
+ policy: SandboxWritePolicy,
466
+ writableRoot: string,
467
+ runtimeWrite: readonly string[],
468
+ seams: SandboxSeams,
469
+ ): CompiledBroadWrite {
470
+ const permissions = policy.permissions!;
471
+ const home = canonicalizePath(policy.home, seams);
472
+ if (home === sep || RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), home))) {
473
+ throw new Error("The broad-write sandbox profile needs a real home directory, not / or a system runtime root.");
474
+ }
475
+ if (writableRoot === home || contains(writableRoot, home)) {
476
+ throw new Error("The broad-write sandbox profile needs a project directory inside or beside home, not home itself.");
477
+ }
478
+ const tempRoots = [...new Set((BROAD_TEMP_ROOTS[currentPlatform(seams)] ?? [])
479
+ .map((path) => canonicalizePath(path, seams)))];
480
+ const configuredAgentDir = process.env.PI_CODING_AGENT_DIR;
481
+ const codePaths = [...new Set([
482
+ ...CODE_LATER_LOCATIONS.flatMap((name) => lexicalAndCanonical(home, name, seams)),
483
+ ...(configuredAgentDir ? [resolve(expandHome(configuredAgentDir, home)),
484
+ ...symlinkHops(expandHome(configuredAgentDir, home)),
485
+ canonicalizePath(expandHome(configuredAgentDir, home), seams)] : []),
486
+ ])].sort();
487
+ return {
488
+ home,
489
+ tempRoots,
490
+ deleteRoots: [...new Set([
491
+ ...(permissions.projectFiles === "read-write" ? [writableRoot] : []),
492
+ ...runtimeWrite,
493
+ ])].sort(),
494
+ codePaths,
495
+ };
496
+ }
497
+
498
+ /** Inside a git worktree folder (`.worktrees/`, `*-worktrees/`) below `root`: its contents, not the folder. */
499
+ function insideWorktreeFolder(path: string, root: string): boolean {
500
+ if (!contains(root, path) || path === root) return false;
501
+ return path.slice(root.length + 1).split(sep).slice(0, -1)
502
+ .some((part) => part === ".worktrees" || /^[^/]+-worktrees$/.test(part));
503
+ }
504
+
505
+ /** Hidden entries of home, and git worktree folders anywhere under it. */
506
+ function disposableHomePath(path: string, home: string): boolean {
507
+ if (!contains(home, path) || path === home) return false;
508
+ return path.slice(home.length + 1).startsWith(".") || insideWorktreeFolder(path, home);
509
+ }
510
+
299
511
  /**
300
512
  * Canonicalize a write policy once so containment checks and backend rules
301
513
  * agree on exactly which paths they are talking about.
@@ -329,6 +541,11 @@ export function evaluateReadAccess(
329
541
  const path = canonicalizePath(target, seams);
330
542
  const permissions = policy.permissions;
331
543
  if (!permissions) return { allowed: true, path };
544
+ if (policy.broad) {
545
+ const denied = isCredential(path, policy) ||
546
+ (permissions.projectFiles === "off" && contains(policy.writableRoot, path));
547
+ return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
548
+ }
332
549
  const mode = isCredential(path, policy) ? permissions.storedCredentials
333
550
  : policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
334
551
  : contains(policy.writableRoot, path) ? permissions.projectFiles
@@ -349,6 +566,7 @@ export function evaluateWriteAccess(
349
566
  seams: SandboxSeams = {},
350
567
  ): WriteAccessDecision {
351
568
  const path = canonicalizePath(target, seams);
569
+ if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
352
570
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
353
571
  return { allowed: false, path, reason: "outside-writable-root" };
354
572
  }
@@ -364,11 +582,180 @@ export function evaluateWriteAccess(
364
582
  : policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
365
583
  : contains(canonicalizePath("/dev", seams), path) ? "read-write"
366
584
  : policy.permissions.outsideProject;
367
- if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
585
+ if (!canWrite(mode)) return { allowed: false, path, reason: "permission-denied" };
368
586
  }
369
587
  return { allowed: true, path };
370
588
  }
371
589
 
590
+ function evaluateBroadWrite(
591
+ path: string,
592
+ policy: CompiledSandboxWritePolicy,
593
+ broad: CompiledBroadWrite,
594
+ seams: SandboxSeams,
595
+ ): WriteAccessDecision {
596
+ for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
597
+ if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
598
+ }
599
+ if (isCredential(path, policy)) return { allowed: false, path, reason: "permission-denied" };
600
+ if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
601
+ if (contains(policy.writableRoot, path)) {
602
+ return canWrite(policy.permissions!.projectFiles)
603
+ ? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
604
+ }
605
+ if ([...broad.tempRoots, ...broad.deleteRoots, canonicalizePath("/dev", seams)].some((root) => contains(root, path))) {
606
+ return { allowed: true, path };
607
+ }
608
+ if (contains(broad.home, path)) {
609
+ // Linux cannot separate removal from writing, so its fallback keeps
610
+ // ordinary (non-dot) top-level home folders read-only instead.
611
+ if (currentPlatform(seams) === "linux" && !disposableHomePath(path, broad.home)) {
612
+ return { allowed: false, path, reason: "permission-denied" };
613
+ }
614
+ return { allowed: true, path };
615
+ }
616
+ return { allowed: false, path, reason: "permission-denied" };
617
+ }
618
+
619
+ /**
620
+ * Decide whether `target` may be removed or renamed away (unlink, rmdir,
621
+ * rename of the source, or rename over an existing destination). Outside the
622
+ * broad-write profile removal follows write access.
623
+ */
624
+ export function evaluateDeleteAccess(
625
+ target: string,
626
+ policy: CompiledSandboxWritePolicy,
627
+ seams: SandboxSeams = {},
628
+ ): WriteAccessDecision {
629
+ const write = evaluateWriteAccess(target, policy, seams);
630
+ const broad = policy.broad;
631
+ if (!write.allowed) return write;
632
+ const path = write.path;
633
+ if (!broad) {
634
+ // Project files = Write outside the broad profile: nothing in the
635
+ // project is removable except worktree folders' contents.
636
+ return policy.permissions?.projectFiles === "write" && contains(policy.writableRoot, path) &&
637
+ !insideWorktreeFolder(path, policy.writableRoot)
638
+ ? { allowed: false, path, reason: "delete-denied" } : write;
639
+ }
640
+ const anchors = broadProtectedPaths(policy, broad);
641
+ if (anchors.some((protectedPath) => protectedPath !== path && contains(path, protectedPath))) {
642
+ return { allowed: false, path, reason: "delete-denied" };
643
+ }
644
+ if (path === policy.writableRoot || contains(path, policy.writableRoot)) {
645
+ return { allowed: false, path, reason: "delete-denied" };
646
+ }
647
+ if ([...broad.tempRoots, ...broad.deleteRoots].some((root) => contains(root, path)) ||
648
+ disposableHomePath(path, broad.home)) {
649
+ return write;
650
+ }
651
+ return { allowed: false, path, reason: "delete-denied" };
652
+ }
653
+
654
+ /**
655
+ * Whether an already-canonical path lies where a Write level still allows
656
+ * removal: temp, hidden home entries, worktree folders, a Write & delete
657
+ * workspace and runtime scratch (broad profile), or a worktree folder inside a
658
+ * Project files = Write workspace. The leaf is not re-resolved, so a caller can
659
+ * ask about a symlink's own directory entry.
660
+ */
661
+ export function isRemovableUnderWrite(path: string, policy: CompiledSandboxWritePolicy): boolean {
662
+ const broad = policy.broad;
663
+ if (broad) {
664
+ if (broadProtectedPaths(policy, broad).some((guard) => contains(guard, path) || contains(path, guard))) return false;
665
+ return [...broad.tempRoots, ...broad.deleteRoots].some((root) => contains(root, path)) ||
666
+ disposableHomePath(path, broad.home);
667
+ }
668
+ return policy.permissions?.projectFiles === "write" && insideWorktreeFolder(path, policy.writableRoot);
669
+ }
670
+
671
+ /** Outcome of a finished snapshot attempt. */
672
+ export type RecoverySnapshotOutcome = { ok: boolean; detail: string };
673
+
674
+ /** Result of a best-effort recovery snapshot request. */
675
+ export type RecoverySnapshotResult =
676
+ | { started: true; done: Promise<RecoverySnapshotOutcome> }
677
+ | { started: false; reason: "not-needed" | "unsupported" | "rate-limited" };
678
+
679
+ export type RecoverySnapshotSeams = {
680
+ platform?: () => string;
681
+ now?: () => number;
682
+ /** Defaults to an unref'd `/usr/bin/tmutil localsnapshot` child with a 30 s limit. */
683
+ run?: () => Promise<RecoverySnapshotOutcome>;
684
+ };
685
+
686
+ let lastRecoverySnapshot = 0;
687
+ const RECOVERY_SNAPSHOT_INTERVAL_MS = 15 * 60 * 1000;
688
+
689
+ function runLocalSnapshot(): Promise<RecoverySnapshotOutcome> {
690
+ return new Promise((resolveOutcome) => {
691
+ let output = "";
692
+ let settled = false;
693
+ const finish = (outcome: RecoverySnapshotOutcome) => {
694
+ if (settled) return;
695
+ settled = true;
696
+ clearTimeout(timer);
697
+ resolveOutcome(outcome);
698
+ };
699
+ const child = spawn("/usr/bin/tmutil", ["localsnapshot"], {
700
+ env: { PATH: "/usr/bin:/bin", LANG: "C" }, stdio: ["ignore", "pipe", "pipe"],
701
+ });
702
+ const timer = setTimeout(() => { child.kill("SIGKILL"); finish({ ok: false, detail: "tmutil timed out after 30 s" }); }, 30_000);
703
+ timer.unref();
704
+ child.unref();
705
+ for (const stream of [child.stdout, child.stderr]) {
706
+ stream?.on("data", (chunk: Buffer) => { output = (output + chunk.toString()).slice(-2048); });
707
+ (stream as { unref?: () => void } | null)?.unref?.();
708
+ }
709
+ child.on("error", (error) => finish({ ok: false, detail: error.message }));
710
+ child.on("close", (code) => finish(code === 0
711
+ ? { ok: true, detail: output.trim() }
712
+ : { ok: false, detail: output.trim() || `tmutil exited ${code}` }));
713
+ });
714
+ }
715
+
716
+ /**
717
+ * Start an APFS local snapshot (`tmutil localsnapshot`) before a confined run
718
+ * whose Outside project level lets it change files outside the project: Write
719
+ * & delete can remove them, and Write can still truncate, chmod or chflags
720
+ * them in place. macOS only; it needs no elevated privileges. The snapshot
721
+ * runs in the background and never blocks or fails the run; callers log a
722
+ * failed outcome. Snapshots are volume-wide and purgeable, so one per 15
723
+ * minutes per process is enough. `PI_SANDBOX_RECOVERY_SNAPSHOT=off` disables it
724
+ * (test suites set it so they do not create real snapshots).
725
+ */
726
+ export function takeRecoverySnapshot(
727
+ permissions: SandboxPermissions | undefined,
728
+ seams: RecoverySnapshotSeams = {},
729
+ ): RecoverySnapshotResult {
730
+ if (!canWrite(permissions?.outsideProject) || process.env.PI_SANDBOX_RECOVERY_SNAPSHOT === "off") {
731
+ return { started: false, reason: "not-needed" };
732
+ }
733
+ if ((seams.platform ?? osPlatform)() !== "darwin") return { started: false, reason: "unsupported" };
734
+ const now = (seams.now ?? Date.now)();
735
+ if (lastRecoverySnapshot && now - lastRecoverySnapshot < RECOVERY_SNAPSHOT_INTERVAL_MS) {
736
+ return { started: false, reason: "rate-limited" };
737
+ }
738
+ lastRecoverySnapshot = now;
739
+ let done: Promise<RecoverySnapshotOutcome>;
740
+ try {
741
+ done = (seams.run ?? runLocalSnapshot)().catch((error: unknown) =>
742
+ ({ ok: false, detail: error instanceof Error ? error.message : String(error) }));
743
+ } catch (error) {
744
+ done = Promise.resolve({ ok: false, detail: error instanceof Error ? error.message : String(error) });
745
+ }
746
+ return { started: true, done };
747
+ }
748
+
749
+ /** Test hook: forget the last snapshot time. */
750
+ export function resetRecoverySnapshotClock(): void {
751
+ lastRecoverySnapshot = 0;
752
+ }
753
+
754
+ function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
755
+ return [...policy.denyWrite, ...(policy.credentialPaths ?? []), ...broad.codePaths,
756
+ ...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
757
+ }
758
+
372
759
  /** Quote a path as an SBPL string literal. */
373
760
  function sbpl(path: string): string {
374
761
  return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
@@ -399,6 +786,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
399
786
  ...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
400
787
  ...(policy.compatibilityWrite ?? []),
401
788
  ];
789
+ if (permissions.outsideProject === "write") throw new Error("Outside project = Write uses the broad-write profile.");
402
790
  const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
403
791
  if (permissions.outsideProject === "off") {
404
792
  rules.push("(deny file-read*)", "(allow file-read-metadata)", '(allow file-read* (literal "/"))');
@@ -416,16 +804,29 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
416
804
  const scoped = (root: string, mode: SandboxPermissions["projectFiles"]) => {
417
805
  if (mode === "off") rules.push(`(deny file-read* (subpath ${sbpl(root)}))`);
418
806
  else rules.push(`(allow file-read* (subpath ${sbpl(root)}))`);
419
- if (mode === "read-write") rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
807
+ if (canWrite(mode)) rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
420
808
  else rules.push(`(deny file-write* (subpath ${sbpl(root)}))`);
421
809
  };
422
810
  // Last matching SBPL rule wins. Credential rules override project and outside;
423
811
  // explicit denyWrite entries always override every write allowance.
424
812
  for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
425
813
  scoped(policy.writableRoot, permissions.projectFiles);
814
+ if (permissions.projectFiles === "write" && !TEMP_ROOTS.filter((root) => root !== "/dev")
815
+ .some((root) => contains(canonicalizePath(root, seams), policy.writableRoot))) {
816
+ // Write without delete: only a worktree folder's contents stay removable.
817
+ const project = sbplRegexPath(policy.writableRoot);
818
+ rules.push(`(deny file-write-unlink (subpath ${sbpl(policy.writableRoot)}))`,
819
+ `(allow file-write-unlink (regex #"^${project}/(.+/)?[.]worktrees/."))`,
820
+ `(allow file-write-unlink (regex #"^${project}/(.+/)?[^/]+-worktrees/."))`);
821
+ }
426
822
  for (const path of (policy.runtimeWrite ?? []).filter((path) => !policy.compatibilityWrite?.includes(path))) scoped(path, "read-write");
427
823
  for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
428
- for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
824
+ // Explicit unlink denials too: a `file-write-unlink` allowance above would
825
+ // otherwise outrank a `file-write*` denial.
826
+ for (const path of policy.denyWrite) {
827
+ rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
828
+ if (permissions.projectFiles === "write") rules.push(`(deny file-write-unlink (subpath ${sbpl(path)}))`);
829
+ }
429
830
  // Protect the directory entries, not their contents: unrelated children can
430
831
  // still be created, while renaming a parent cannot move a denied subtree.
431
832
  for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
@@ -434,13 +835,73 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
434
835
  return [...rules, ""].join("\n");
435
836
  }
436
837
 
838
+ /** Escape a canonical path for an SBPL regular expression literal. */
839
+ function sbplRegexPath(path: string): string {
840
+ return path.replace(/[\\.^$|?*+()[\]{}"]/g, (char) => `\\${char}`);
841
+ }
842
+
843
+ /**
844
+ * The broad-write profile on Seatbelt.
845
+ *
846
+ * Seatbelt has no separate rename operation: `file-write-unlink` guards unlink,
847
+ * rmdir, the source of a rename, and an existing destination a rename replaces.
848
+ * Denying it outside the disposable roots therefore refuses `rm`, `rm -rf` and
849
+ * `mv dir elsewhere`, and also a save that renames a temp file over an existing
850
+ * file there. Writing a file in place (truncate + write) stays allowed.
851
+ */
852
+ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
853
+ const permissions = policy.permissions!;
854
+ const broad = policy.broad!;
855
+ const rules = ["(version 1)", "(allow default)", "(deny file-write*)",
856
+ `(allow file-write* (subpath ${sbpl(broad.home)}))`];
857
+ const project = policy.writableRoot;
858
+ for (const root of [...broad.tempRoots, canonicalizePath("/dev", seams), ...broad.deleteRoots,
859
+ ...(policy.runtimeWrite ?? []), ...(canWrite(permissions.projectFiles) ? [project] : [])]) {
860
+ rules.push(`(allow file-write* (subpath ${sbpl(root)}))`);
861
+ }
862
+ if (permissions.projectFiles === "off") rules.push(`(deny file-read* (subpath ${sbpl(project)}))`);
863
+ rules.push("(deny file-write-unlink)");
864
+ for (const root of [...broad.tempRoots, ...broad.deleteRoots]) {
865
+ rules.push(`(allow file-write-unlink (subpath ${sbpl(root)}))`);
866
+ }
867
+ const home = sbplRegexPath(broad.home);
868
+ rules.push(
869
+ `(allow file-write-unlink (regex #"^${home}/[.][^/]+$"))`,
870
+ `(allow file-write-unlink (regex #"^${home}/[.][^/]+/"))`,
871
+ `(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
872
+ `(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
873
+ );
874
+ // The fixed deny list overrides every grant above. A rule naming
875
+ // `file-write-unlink` outranks a `file-write*` wildcard whatever their
876
+ // order (verified on the real kernel), so each protected path also gets an
877
+ // explicit unlink denial; otherwise the removal grants above would reopen it.
878
+ const denyWrite = (path: string) => rules.push(
879
+ `(deny file-write* (subpath ${sbpl(path)}))`, `(deny file-write-unlink (subpath ${sbpl(path)}))`);
880
+ if (!canWrite(permissions.projectFiles)) denyWrite(project);
881
+ for (const path of broad.codePaths) denyWrite(path);
882
+ for (const path of policy.credentialPaths ?? []) {
883
+ rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
884
+ denyWrite(path);
885
+ }
886
+ for (const path of policy.denyWrite) denyWrite(path);
887
+ // Renaming an ancestor would move a protected subtree to an unprotected path,
888
+ // and renaming the workspace's ancestors could redirect the next launch.
889
+ const anchors = [...broadProtectedPaths(policy, broad), project];
890
+ for (const path of [...new Set([...protectedAncestors(anchors), project])]) {
891
+ rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
892
+ }
893
+ if (!permissions.network) rules.push("(deny network*)");
894
+ return [...rules, ""].join("\n");
895
+ }
896
+
437
897
  /** Build the macOS sandbox-exec wrapper and its SBPL profile. */
438
898
  function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
439
899
  // Match on the real (symlink-resolved) path — sandbox-exec evaluates the
440
900
  // canonical path, so /tmp/x must be written as /private/tmp/x.
441
901
  const policy = compile(args.policy, seams, false);
442
902
 
443
- const profile = policy.permissions ? buildPermissionProfile(policy, seams) : [
903
+ const profile = policy.broad ? buildBroadProfile(policy, seams)
904
+ : policy.permissions ? buildPermissionProfile(policy, seams) : [
444
905
  "(version 1)",
445
906
  "(allow default)", // permissive base: reads, exec, network
446
907
  "(deny file-write*)", // ...then deny all writes...
@@ -566,6 +1027,7 @@ function buildLinuxSandboxCommand(
566
1027
  // boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
567
1028
  // widening the writable root.
568
1029
  const policy = compile(args.policy, seams, true);
1030
+ if (policy.broad) return buildLinuxBroadCommand(bwrap, args, policy, seams);
569
1031
  if (policy.permissions) return buildLinuxPermissionCommand(bwrap, args, policy, seams);
570
1032
  const materialize = seams.materializeDenyPath ?? materializeDenyPath;
571
1033
  const denyBinds = policy.denyWrite.flatMap((path) => {
@@ -600,6 +1062,9 @@ function buildLinuxPermissionCommand(
600
1062
  ): SandboxCommand {
601
1063
  const permissions = policy.permissions!;
602
1064
  validateRuntimeHome(policy, seams);
1065
+ if (permissions.projectFiles === "write" || permissions.outsideProject === "write") {
1066
+ throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
1067
+ }
603
1068
  const project = policy.writableRoot;
604
1069
  const credentials = policy.credentialPaths ?? [];
605
1070
  const writableProject = permissions.projectFiles === "read-write";
@@ -772,6 +1237,184 @@ function buildLinuxPermissionCommand(
772
1237
  };
773
1238
  }
774
1239
 
1240
+ type DirectoryEntry = { name: string; directory: boolean; symlink: boolean };
1241
+
1242
+ function systemListDirectory(path: string): DirectoryEntry[] {
1243
+ try {
1244
+ return readdirSync(path, { withFileTypes: true }).map((entry) => ({
1245
+ name: entry.name, directory: entry.isDirectory(), symlink: entry.isSymbolicLink(),
1246
+ }));
1247
+ } catch {
1248
+ return [];
1249
+ }
1250
+ }
1251
+
1252
+ /**
1253
+ * Worktree folders (`.worktrees`, `*-worktrees`) within three levels of home.
1254
+ * Bounded so a large home cannot stall a launch; a folder created after launch
1255
+ * or deeper than that stays read-only on Linux.
1256
+ */
1257
+ function discoverWorktreeFolders(home: string, list: (path: string) => DirectoryEntry[]): string[] {
1258
+ const found: string[] = [];
1259
+ let budget = 4000;
1260
+ const walk = (directory: string, depth: number) => {
1261
+ for (const entry of list(directory)) {
1262
+ if (--budget < 0 || !entry.directory || entry.symlink) continue;
1263
+ const path = join(directory, entry.name);
1264
+ if (entry.name === ".worktrees" || entry.name.endsWith("-worktrees")) found.push(path);
1265
+ else if (!entry.name.startsWith(".") && depth < 3) walk(path, depth + 1);
1266
+ }
1267
+ };
1268
+ walk(home, 1);
1269
+ return found;
1270
+ }
1271
+
1272
+ /** Empty, mode-000 mask sources a hidden credential is bound over. */
1273
+ function maskSources(profilePath: string): { directory: string; file: string } {
1274
+ const base = join(dirname(profilePath), ".pi-sandbox-mask");
1275
+ mkdirSync(base, { recursive: true, mode: 0o700 });
1276
+ const directory = join(base, "directory");
1277
+ const file = join(base, "file");
1278
+ if (!existsSync(directory)) mkdirSync(directory, { mode: 0o000 });
1279
+ if (!existsSync(file)) closeSync(openSync(file, "wx", 0o000));
1280
+ for (const [path, isDirectory] of [[directory, true], [file, false]] as const) {
1281
+ const info = lstatSync(path);
1282
+ if (info.isDirectory() !== isDirectory || (info.mode & 0o777) !== 0 || (!isDirectory && info.size !== 0)) {
1283
+ throw new Error(`Unsafe sandbox mask source: ${path}`);
1284
+ }
1285
+ }
1286
+ return { directory, file };
1287
+ }
1288
+
1289
+ /**
1290
+ * The broad-write profile on Bubblewrap.
1291
+ *
1292
+ * Mount namespaces cannot separate removal from writing, and Landlock is not
1293
+ * reachable from Node or bwrap here, so Linux uses the documented fallback:
1294
+ * the root is read-only; temp, the workspace, hidden home entries and worktree
1295
+ * folders are writable (removal included); ordinary top-level home folders and
1296
+ * home itself stay read-only, so sibling repositories cannot even be edited in
1297
+ * place there. Credentials are masked by empty mode-000 mounts; code that runs
1298
+ * later and caller-denied paths are read-only binds. Every writable bind and
1299
+ * protected leaf is a mount point, which Linux refuses to rename.
1300
+ */
1301
+ function buildLinuxBroadCommand(
1302
+ bwrap: string,
1303
+ args: SandboxCommandArgs,
1304
+ policy: CompiledSandboxWritePolicy,
1305
+ seams: SandboxSeams,
1306
+ ): SandboxCommand {
1307
+ const permissions = policy.permissions!;
1308
+ const broad = policy.broad!;
1309
+ const project = policy.writableRoot;
1310
+ if (permissions.projectFiles === "off") {
1311
+ throw new Error("Linux bubblewrap cannot hide a project inside the broad-write profile's visible root.");
1312
+ }
1313
+ if (permissions.projectFiles === "write") {
1314
+ throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
1315
+ }
1316
+ const credentials = policy.credentialPaths ?? [];
1317
+ const writeDenied = [...broad.codePaths, ...policy.denyWrite];
1318
+ if (permissions.projectFiles === "read-write" &&
1319
+ [...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
1320
+ throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
1321
+ }
1322
+ const list = seams.listDirectory ?? systemListDirectory;
1323
+ const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
1324
+ const protectedPaths = [...credentials, ...writeDenied];
1325
+ const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
1326
+
1327
+ const writable = new Set<string>();
1328
+ for (const root of broad.tempRoots) if (exists(root)) writable.add(root);
1329
+ for (const entry of list(broad.home)) {
1330
+ if (entry.symlink) continue;
1331
+ const path = join(broad.home, entry.name);
1332
+ if (entry.name.startsWith(".") && !insideProtected(path)) writable.add(path);
1333
+ }
1334
+ for (const path of discoverWorktreeFolders(broad.home, list)) if (!insideProtected(path)) writable.add(path);
1335
+ for (const path of [...broad.deleteRoots, ...(policy.runtimeWrite ?? [])]) {
1336
+ if (path === project || !exists(path)) continue;
1337
+ if (insideProtected(path)) throw new Error(`Writable sandbox root overlaps a protected path: ${path}`);
1338
+ writable.add(path);
1339
+ }
1340
+ if (permissions.projectFiles === "read-write") writable.add(project);
1341
+ const writableRoots = [...writable];
1342
+ const writableAt = (path: string) => writableRoots.some((root) => contains(root, path));
1343
+
1344
+ const mask = (seams.maskSources ?? maskSources)(args.profilePath);
1345
+ const materialize = seams.materializeDenyPath ?? materializeDenyPath;
1346
+ const hidden: { path: string; source: string }[] = [];
1347
+ const readOnly = new Set<string>();
1348
+ const directoryLocation = (path: string) => [...DIRECTORY_LOCATIONS]
1349
+ .some((name) => canonicalizePath(join(broad.home, name), seams) === path);
1350
+ const guard = (path: string, hide: boolean) => {
1351
+ let directory: boolean | undefined;
1352
+ if (canonicalizePath(path, seams) !== path) {
1353
+ // A literal twin of a canonical deny entry: its target is guarded on
1354
+ // its own. A symlink leaf inside a writable root could be replaced,
1355
+ // so its directory becomes read-only; home itself already is.
1356
+ let link = false;
1357
+ try { link = lstatSync(path).isSymbolicLink(); } catch { /* absent or intermediate link */ }
1358
+ if (link && dirname(path) !== broad.home && writableAt(dirname(path))) readOnly.add(dirname(path));
1359
+ return;
1360
+ }
1361
+ if (!exists(path)) {
1362
+ if (!writableAt(path)) return; // Nothing inside the sandbox can create it.
1363
+ let ancestor = dirname(path);
1364
+ while (!exists(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
1365
+ if (directoryLocation(path)) {
1366
+ (seams.makeDirectory ?? ((target: string) => mkdirSync(target, { recursive: true, mode: 0o700 })))(path);
1367
+ directory = true;
1368
+ } else if (writableRoots.includes(ancestor) || contains(project, ancestor) ||
1369
+ broad.tempRoots.some((root) => contains(root, ancestor))) {
1370
+ if (!materialize(path)) throw new Error(`Cannot protect denied path: ${path}`);
1371
+ } else {
1372
+ readOnly.add(ancestor);
1373
+ return;
1374
+ }
1375
+ }
1376
+ if (hide) {
1377
+ directory ??= seams.isDirectory ? seams.isDirectory(path) : statSync(path).isDirectory();
1378
+ hidden.push({ path, source: directory ? mask.directory : mask.file });
1379
+ } else {
1380
+ readOnly.add(path);
1381
+ }
1382
+ };
1383
+ for (const path of credentials) guard(path, true);
1384
+ for (const path of writeDenied) guard(path, false);
1385
+ if (permissions.projectFiles === "read") readOnly.add(project);
1386
+
1387
+ // Intermediate directories between a writable root and anything bound
1388
+ // inside it become mount points too, so none of them can be renamed away.
1389
+ const anchors = new Set<string>();
1390
+ for (const target of [...writableRoots, ...readOnly, ...hidden.map((entry) => entry.path)]) {
1391
+ for (const parent of protectedAncestors([target])) {
1392
+ if (writableRoots.some((root) => root !== parent && contains(root, parent)) &&
1393
+ !readOnly.has(parent) && !writable.has(parent)) anchors.add(parent);
1394
+ }
1395
+ }
1396
+ type Mount = { option: "--bind" | "--ro-bind"; source: string; path: string; rank: number };
1397
+ const mounts: Mount[] = [
1398
+ ...[...anchors].map((path) => ({ option: "--bind" as const, source: path, path, rank: 0 })),
1399
+ ...writableRoots.map((path) => ({ option: "--bind" as const, source: path, path, rank: 1 })),
1400
+ ...[...readOnly].map((path) => ({ option: "--ro-bind" as const, source: path, path, rank: 2 })),
1401
+ ...hidden.map(({ path, source }) => ({ option: "--ro-bind" as const, source, path, rank: 3 })),
1402
+ ].sort((a, b) => a.path.length - b.path.length || a.rank - b.rank);
1403
+ const seen = new Set<string>();
1404
+ const fileArgs = ["--ro-bind", "/", "/", "--dev", "/dev"];
1405
+ for (const mount of mounts) {
1406
+ const key = `${mount.option}\0${mount.source}\0${mount.path}`;
1407
+ if (seen.has(key)) continue;
1408
+ seen.add(key);
1409
+ fileArgs.push(mount.option, mount.source, mount.path);
1410
+ }
1411
+ return {
1412
+ file: bwrap,
1413
+ fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1414
+ "--", args.execPath, ...args.execArgs],
1415
+ };
1416
+ }
1417
+
775
1418
  function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
776
1419
  const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
777
1420
  if (!bwrap) return undefined;