pi-better-harness 0.12.0 → 0.12.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -552,50 +552,18 @@ function contains(root: string, target: string): boolean {
552
552
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
553
553
  }
554
554
 
555
- /** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
556
- const caseFoldingByDevice = new Map<number, boolean>();
557
-
558
- /**
559
- * Whether the volume holding `path` (its nearest existing ancestor) ignores
560
- * case: an existing component whose case-swapped spelling names the same
561
- * inode proves it. macOS only; false whenever it cannot be shown.
562
- */
563
- function caseInsensitiveVolume(path: string): boolean {
564
- if (process.platform !== "darwin") return false;
565
- let current = path;
566
- while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
567
- let device: number;
568
- try { device = statSync(current).dev; } catch { return false; }
569
- const known = caseFoldingByDevice.get(device);
570
- if (known !== undefined) return known;
571
- for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
572
- const name = basename(probe);
573
- const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
574
- if (swapped === name) continue;
575
- try {
576
- const original = statSync(probe);
577
- if (original.dev !== device) break;
578
- let folded = false;
579
- try {
580
- const other = statSync(join(dirname(probe), swapped));
581
- folded = other.dev === original.dev && other.ino === original.ino;
582
- } catch { /* The swapped name is absent: case-sensitive. */ }
583
- caseFoldingByDevice.set(device, folded);
584
- return folded;
585
- } catch { return false; }
586
- }
587
- return false;
588
- }
589
-
590
555
  /**
591
- * Spell `path` the way the policy spells it when they differ only in case on a
592
- * case-insensitive volume. `canonicalizePath` fixes the case of every existing
593
- * component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
594
- * the caller's case, and the kernel still treats it as the protected name. The
595
- * longest policy path that matches case-insensitively wins, so the pre-check
596
- * decides as the kernel does (#354).
556
+ * Spell `path` the way the policy spells it when they differ only in case, on
557
+ * macOS. `canonicalizePath` fixes the case of every existing component on a
558
+ * case-insensitive volume, but a missing tail (`~/.NPMRC` while `~/.npmrc` is
559
+ * absent) keeps the caller's case, and the kernel still treats it as the
560
+ * protected name. Seatbelt matches paths case-insensitively on every volume,
561
+ * case-sensitive APFS included, so this folds on darwin without probing the
562
+ * volume: the pre-check decides as the kernel does (#354, #358). The longest
563
+ * policy path that matches case-insensitively wins. Other platforms never fold.
597
564
  */
598
- function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
565
+ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
566
+ if (currentPlatform(seams) !== "darwin") return path;
599
567
  const broad = policy.broad;
600
568
  const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
601
569
  ...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
@@ -608,8 +576,7 @@ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): st
608
576
  if (folded.length !== candidate.length || !contains(folded, lower)) continue;
609
577
  if (!best || candidate.length > best.length) best = candidate;
610
578
  }
611
- // Already spelled as the policy spells it: nothing to probe.
612
- if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
579
+ if (!best || path.startsWith(best)) return path;
613
580
  return `${best}${path.slice(best.length)}`;
614
581
  }
615
582
 
@@ -627,7 +594,7 @@ export function evaluateReadAccess(
627
594
  policy: CompiledSandboxWritePolicy,
628
595
  seams: SandboxSeams = {},
629
596
  ): ReadAccessDecision {
630
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
597
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
631
598
  const permissions = policy.permissions;
632
599
  if (!permissions) return { allowed: true, path };
633
600
  if (policy.broad) {
@@ -654,7 +621,7 @@ export function evaluateWriteAccess(
654
621
  policy: CompiledSandboxWritePolicy,
655
622
  seams: SandboxSeams = {},
656
623
  ): WriteAccessDecision {
657
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
624
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
658
625
  if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
659
626
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
660
627
  return { allowed: false, path, reason: "outside-writable-root" };
@@ -1582,7 +1549,7 @@ function buildLinuxBroadCommand(
1582
1549
  fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1583
1550
  "--", args.execPath, ...args.execArgs],
1584
1551
  ...(created.length && { notices: [
1585
- `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
1552
+ `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place (remove it before creating the real target): ${created.join(", ")}`,
1586
1553
  ] }),
1587
1554
  };
1588
1555
  }
@@ -121,9 +121,9 @@ the sandbox is enabled again.
121
121
 
122
122
  | | macOS (Seatbelt) | Linux (Bubblewrap) | Windows |
123
123
  | - | - | - | - |
124
- | Outside project = Write | Writes across home and temp. Removal and renaming refused outside the disposable places. | Fallback: ordinary top-level home folders (`~/projects`, `~/Documents`, …) and home itself are **read-only**. Dot entries, temp, worktree folders (found within three levels of home), and the workspace are writable, removal included. A directory holding a protected symlink (typically `~/.config` with a stow link such as `~/.config/git`) keeps its existing entries writable but refuses new top-level entries. | No backend: confined launches fail closed. |
124
+ | Outside project = Write | Writes across home and temp. Removal and renaming refused outside the disposable places. | Fallback: ordinary top-level home folders (`~/projects`, `~/Documents`, …) and home itself are **read-only**. Dot entries, temp, worktree folders (found within three levels of home), and the workspace are writable, removal included. A directory holding a protected symlink (typically `~/.config` with a stow link such as `~/.config/git`) keeps its existing entries writable but refuses new top-level entries. A protected symlink pointing at nothing can get an empty read-only placeholder that stays on disk; the launch says so, and you remove the placeholder before creating the real target. | No backend: confined launches fail closed. |
125
125
  | Project files = Write | Enforced. | **Refused at launch** (bind mounts cannot separate removal from writing). Use Write & delete or Read. | No backend. |
126
- | Deny list | Path rules. | Credentials masked by empty mode-000 mounts. Code that runs later is bound read-only. | No backend. |
126
+ | Deny list | Path rules, matched ignoring case on every volume (as Seatbelt does). | Credentials masked by empty mode-000 mounts. Code that runs later is bound read-only. | No backend. |
127
127
 
128
128
  Linux could separate removal from writing with Landlock (5.19+), but nothing
129
129
  here can apply it: Node has no binding and `bwrap` does not expose it. Windows
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -552,50 +552,18 @@ function contains(root: string, target: string): boolean {
552
552
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
553
553
  }
554
554
 
555
- /** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
556
- const caseFoldingByDevice = new Map<number, boolean>();
557
-
558
- /**
559
- * Whether the volume holding `path` (its nearest existing ancestor) ignores
560
- * case: an existing component whose case-swapped spelling names the same
561
- * inode proves it. macOS only; false whenever it cannot be shown.
562
- */
563
- function caseInsensitiveVolume(path: string): boolean {
564
- if (process.platform !== "darwin") return false;
565
- let current = path;
566
- while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
567
- let device: number;
568
- try { device = statSync(current).dev; } catch { return false; }
569
- const known = caseFoldingByDevice.get(device);
570
- if (known !== undefined) return known;
571
- for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
572
- const name = basename(probe);
573
- const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
574
- if (swapped === name) continue;
575
- try {
576
- const original = statSync(probe);
577
- if (original.dev !== device) break;
578
- let folded = false;
579
- try {
580
- const other = statSync(join(dirname(probe), swapped));
581
- folded = other.dev === original.dev && other.ino === original.ino;
582
- } catch { /* The swapped name is absent: case-sensitive. */ }
583
- caseFoldingByDevice.set(device, folded);
584
- return folded;
585
- } catch { return false; }
586
- }
587
- return false;
588
- }
589
-
590
555
  /**
591
- * Spell `path` the way the policy spells it when they differ only in case on a
592
- * case-insensitive volume. `canonicalizePath` fixes the case of every existing
593
- * component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
594
- * the caller's case, and the kernel still treats it as the protected name. The
595
- * longest policy path that matches case-insensitively wins, so the pre-check
596
- * decides as the kernel does (#354).
556
+ * Spell `path` the way the policy spells it when they differ only in case, on
557
+ * macOS. `canonicalizePath` fixes the case of every existing component on a
558
+ * case-insensitive volume, but a missing tail (`~/.NPMRC` while `~/.npmrc` is
559
+ * absent) keeps the caller's case, and the kernel still treats it as the
560
+ * protected name. Seatbelt matches paths case-insensitively on every volume,
561
+ * case-sensitive APFS included, so this folds on darwin without probing the
562
+ * volume: the pre-check decides as the kernel does (#354, #358). The longest
563
+ * policy path that matches case-insensitively wins. Other platforms never fold.
597
564
  */
598
- function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
565
+ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
566
+ if (currentPlatform(seams) !== "darwin") return path;
599
567
  const broad = policy.broad;
600
568
  const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
601
569
  ...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
@@ -608,8 +576,7 @@ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): st
608
576
  if (folded.length !== candidate.length || !contains(folded, lower)) continue;
609
577
  if (!best || candidate.length > best.length) best = candidate;
610
578
  }
611
- // Already spelled as the policy spells it: nothing to probe.
612
- if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
579
+ if (!best || path.startsWith(best)) return path;
613
580
  return `${best}${path.slice(best.length)}`;
614
581
  }
615
582
 
@@ -627,7 +594,7 @@ export function evaluateReadAccess(
627
594
  policy: CompiledSandboxWritePolicy,
628
595
  seams: SandboxSeams = {},
629
596
  ): ReadAccessDecision {
630
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
597
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
631
598
  const permissions = policy.permissions;
632
599
  if (!permissions) return { allowed: true, path };
633
600
  if (policy.broad) {
@@ -654,7 +621,7 @@ export function evaluateWriteAccess(
654
621
  policy: CompiledSandboxWritePolicy,
655
622
  seams: SandboxSeams = {},
656
623
  ): WriteAccessDecision {
657
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
624
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
658
625
  if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
659
626
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
660
627
  return { allowed: false, path, reason: "outside-writable-root" };
@@ -1582,7 +1549,7 @@ function buildLinuxBroadCommand(
1582
1549
  fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1583
1550
  "--", args.execPath, ...args.execArgs],
1584
1551
  ...(created.length && { notices: [
1585
- `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
1552
+ `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place (remove it before creating the real target): ${created.join(", ")}`,
1586
1553
  ] }),
1587
1554
  };
1588
1555
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -552,50 +552,18 @@ function contains(root: string, target: string): boolean {
552
552
  return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
553
553
  }
554
554
 
555
- /** Per-device answer to "does this volume fold case?" (APFS/HFS+ default). */
556
- const caseFoldingByDevice = new Map<number, boolean>();
557
-
558
- /**
559
- * Whether the volume holding `path` (its nearest existing ancestor) ignores
560
- * case: an existing component whose case-swapped spelling names the same
561
- * inode proves it. macOS only; false whenever it cannot be shown.
562
- */
563
- function caseInsensitiveVolume(path: string): boolean {
564
- if (process.platform !== "darwin") return false;
565
- let current = path;
566
- while (!existsSync(current) && dirname(current) !== current) current = dirname(current);
567
- let device: number;
568
- try { device = statSync(current).dev; } catch { return false; }
569
- const known = caseFoldingByDevice.get(device);
570
- if (known !== undefined) return known;
571
- for (let probe = current; dirname(probe) !== probe; probe = dirname(probe)) {
572
- const name = basename(probe);
573
- const swapped = [...name].map((c) => c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()).join("");
574
- if (swapped === name) continue;
575
- try {
576
- const original = statSync(probe);
577
- if (original.dev !== device) break;
578
- let folded = false;
579
- try {
580
- const other = statSync(join(dirname(probe), swapped));
581
- folded = other.dev === original.dev && other.ino === original.ino;
582
- } catch { /* The swapped name is absent: case-sensitive. */ }
583
- caseFoldingByDevice.set(device, folded);
584
- return folded;
585
- } catch { return false; }
586
- }
587
- return false;
588
- }
589
-
590
555
  /**
591
- * Spell `path` the way the policy spells it when they differ only in case on a
592
- * case-insensitive volume. `canonicalizePath` fixes the case of every existing
593
- * component, but a missing tail (`~/.NPMRC` while `~/.npmrc` is absent) keeps
594
- * the caller's case, and the kernel still treats it as the protected name. The
595
- * longest policy path that matches case-insensitively wins, so the pre-check
596
- * decides as the kernel does (#354).
556
+ * Spell `path` the way the policy spells it when they differ only in case, on
557
+ * macOS. `canonicalizePath` fixes the case of every existing component on a
558
+ * case-insensitive volume, but a missing tail (`~/.NPMRC` while `~/.npmrc` is
559
+ * absent) keeps the caller's case, and the kernel still treats it as the
560
+ * protected name. Seatbelt matches paths case-insensitively on every volume,
561
+ * case-sensitive APFS included, so this folds on darwin without probing the
562
+ * volume: the pre-check decides as the kernel does (#354, #358). The longest
563
+ * policy path that matches case-insensitively wins. Other platforms never fold.
597
564
  */
598
- function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): string {
565
+ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy, seams: SandboxSeams): string {
566
+ if (currentPlatform(seams) !== "darwin") return path;
599
567
  const broad = policy.broad;
600
568
  const candidates = [policy.writableRoot, ...policy.denyWrite, ...(policy.credentialPaths ?? []),
601
569
  ...(policy.runtimeWrite ?? []), ...(policy.compatibilityWrite ?? []),
@@ -608,8 +576,7 @@ function alignCaseToPolicy(path: string, policy: CompiledSandboxWritePolicy): st
608
576
  if (folded.length !== candidate.length || !contains(folded, lower)) continue;
609
577
  if (!best || candidate.length > best.length) best = candidate;
610
578
  }
611
- // Already spelled as the policy spells it: nothing to probe.
612
- if (!best || path.startsWith(best) || !caseInsensitiveVolume(path)) return path;
579
+ if (!best || path.startsWith(best)) return path;
613
580
  return `${best}${path.slice(best.length)}`;
614
581
  }
615
582
 
@@ -627,7 +594,7 @@ export function evaluateReadAccess(
627
594
  policy: CompiledSandboxWritePolicy,
628
595
  seams: SandboxSeams = {},
629
596
  ): ReadAccessDecision {
630
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
597
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
631
598
  const permissions = policy.permissions;
632
599
  if (!permissions) return { allowed: true, path };
633
600
  if (policy.broad) {
@@ -654,7 +621,7 @@ export function evaluateWriteAccess(
654
621
  policy: CompiledSandboxWritePolicy,
655
622
  seams: SandboxSeams = {},
656
623
  ): WriteAccessDecision {
657
- const path = alignCaseToPolicy(canonicalizePath(target, seams), policy);
624
+ const path = alignCaseToPolicy(canonicalizePath(target, seams), policy, seams);
658
625
  if (policy.broad) return evaluateBroadWrite(path, policy, policy.broad, seams);
659
626
  if (!policy.permissions && !contains(policy.writableRoot, path)) {
660
627
  return { allowed: false, path, reason: "outside-writable-root" };
@@ -1582,7 +1549,7 @@ function buildLinuxBroadCommand(
1582
1549
  fileArgs: [...fileArgs, ...(!permissions.network ? ["--unshare-net"] : []),
1583
1550
  "--", args.execPath, ...args.execArgs],
1584
1551
  ...(created.length && { notices: [
1585
- `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place: ${created.join(", ")}`,
1552
+ `Sandbox: created an empty read-only placeholder where a protected symlink points at nothing, and left it in place (remove it before creating the real target): ${created.join(", ")}`,
1586
1553
  ] }),
1587
1554
  };
1588
1555
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,12 +50,12 @@
50
50
  "test": "node --test test/*.test.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "pi-better-background-tasks": "0.6.0",
53
+ "pi-better-background-tasks": "0.6.1",
54
54
  "pi-better-goal": "0.5.0",
55
55
  "pi-better-plan": "0.5.0",
56
- "pi-better-sandbox": "0.7.1",
56
+ "pi-better-sandbox": "0.7.2",
57
57
  "pi-better-ssh": "0.1.1",
58
- "pi-better-subagents": "0.9.0",
58
+ "pi-better-subagents": "0.9.1",
59
59
  "smol-toml": "1.9.0",
60
60
  "yaml": "^2.9.1"
61
61
  },