pi-better-subagents 0.4.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.4.0",
3
+ "version": "0.4.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",
@@ -18,6 +18,7 @@
18
18
  * `SandboxSeams` argument so callers can plan deterministically in tests.
19
19
  */
20
20
 
21
+ import { spawnSync } from "node:child_process";
21
22
  import { platform as osPlatform } from "node:os";
22
23
  import {
23
24
  accessSync,
@@ -67,6 +68,8 @@ export type SandboxWritePolicy = {
67
68
  permissions?: SandboxPermissions;
68
69
  /** Trusted per-launch runtime state, never supplied by model tool arguments. */
69
70
  runtimeWrite?: readonly string[];
71
+ /** Bounded historical default runtime directories; inactive without a capability profile. */
72
+ runtimeCompatibility?: boolean;
70
73
  };
71
74
 
72
75
  /** The executable and argv to run inside the sandbox, preserved verbatim. */
@@ -116,6 +119,8 @@ export type SandboxSeams = {
116
119
  * host running them.
117
120
  */
118
121
  materializeDenyPath?: (path: string) => boolean;
122
+ /** Defaults to /usr/bin/getconf with a minimal environment (macOS only). */
123
+ getconf?: (name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR") => string | undefined;
119
124
  };
120
125
 
121
126
  /** A policy with every path canonicalized, deduplicated, and ordered. */
@@ -126,6 +131,7 @@ export type CompiledSandboxWritePolicy = {
126
131
  readonly permissions?: SandboxPermissions;
127
132
  readonly credentialPaths?: readonly string[];
128
133
  readonly runtimeWrite?: readonly string[];
134
+ readonly compatibilityWrite?: readonly string[];
129
135
  };
130
136
 
131
137
  /** Why a write target is or is not permitted by a compiled policy. */
@@ -176,6 +182,46 @@ const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Libra
176
182
  const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
177
183
  const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
178
184
 
185
+ function systemGetconf(name: "DARWIN_USER_TEMP_DIR" | "DARWIN_USER_CACHE_DIR"): string | undefined {
186
+ const result = spawnSync("/usr/bin/getconf", [name], {
187
+ encoding: "utf8", env: { PATH: "/usr/bin:/bin", LANG: "C" }, timeout: 3000, maxBuffer: 4096,
188
+ });
189
+ return result.status === 0 ? result.stdout.trim() : undefined;
190
+ }
191
+
192
+ function compatibilityPaths(policy: SandboxWritePolicy, seams: SandboxSeams): string[] {
193
+ if (!policy.permissions || !policy.runtimeCompatibility || policy.permissions.outsideProject === "off") return [];
194
+ const platform = currentPlatform(seams);
195
+ if (platform === "linux") return [canonicalizePath("/tmp", seams)];
196
+ if (platform !== "darwin") return [];
197
+ const home = canonicalizePath(policy.home, seams);
198
+ const project = canonicalizePath(policy.writableRoot, seams);
199
+ const paths = [canonicalizePath("/tmp", seams)];
200
+ for (const [key, leaf] of [["DARWIN_USER_TEMP_DIR", ""], ["DARWIN_USER_CACHE_DIR", "mds"]] as const) {
201
+ const raw = (seams.getconf ?? systemGetconf)(key);
202
+ if (!raw || !raw.startsWith("/") || raw.includes("\0") ||
203
+ raw.split("/").some((part) => part === "." || part === "..")) {
204
+ throw new Error(`Invalid ${key} runtime directory`);
205
+ }
206
+ // getconf returns /var/folders on macOS; /var is a system alias for /private/var.
207
+ const normalized = resolve(raw.replace(/^\/var\/folders\//, "/private/var/folders/"));
208
+ const expected = /^\/private\/var\/folders\/[^/]+\/[^/]+\/[TC]$/.test(normalized) &&
209
+ normalized.endsWith(key === "DARWIN_USER_TEMP_DIR" ? "/T" : "/C");
210
+ const canonical = canonicalizePath(normalized, seams);
211
+ if (!expected || canonical !== normalized ||
212
+ (contains(home, canonical) || contains(canonical, home) || contains(project, canonical))) {
213
+ throw new Error(`Unsafe ${key} runtime directory`);
214
+ }
215
+ paths.push(leaf ? join(canonical, leaf) : canonical);
216
+ }
217
+ // An existing MDS symlink must not redirect the narrow write allowance.
218
+ const mds = paths[2];
219
+ if (!mds || canonicalizePath(mds, seams) !== mds || contains(mds, project) || contains(project, mds)) {
220
+ throw new Error("Unsafe MDS runtime directory");
221
+ }
222
+ return [...new Set(paths)];
223
+ }
224
+
179
225
  /** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
180
226
  export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
181
227
  const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
@@ -235,12 +281,17 @@ function compile(
235
281
  ...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
236
282
  ].sort();
237
283
 
284
+ const compatibilityWrite = compatibilityPaths(policy, seams);
238
285
  return {
239
286
  writableRoot, denyWrite, home: policy.home,
240
287
  ...(policy.permissions && {
241
288
  permissions: { ...policy.permissions },
242
289
  credentialPaths: credentialFilePaths(policy.home, seams),
243
- runtimeWrite: (policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
290
+ compatibilityWrite,
291
+ runtimeWrite: [...new Set([
292
+ ...(policy.runtimeWrite ?? []).map((path) => canonicalizePath(path, seams)),
293
+ ...compatibilityWrite,
294
+ ])],
244
295
  }),
245
296
  };
246
297
  }
@@ -279,8 +330,9 @@ export function evaluateReadAccess(
279
330
  const permissions = policy.permissions;
280
331
  if (!permissions) return { allowed: true, path };
281
332
  const mode = isCredential(path, policy) ? permissions.storedCredentials
282
- : policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
333
+ : policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
283
334
  : contains(policy.writableRoot, path) ? permissions.projectFiles
335
+ : policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
284
336
  : path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
285
337
  : permissions.outsideProject;
286
338
  return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
@@ -307,8 +359,9 @@ export function evaluateWriteAccess(
307
359
  }
308
360
  if (policy.permissions) {
309
361
  const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
310
- : policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
362
+ : policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
311
363
  : contains(policy.writableRoot, path) ? policy.permissions.projectFiles
364
+ : policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
312
365
  : contains(canonicalizePath("/dev", seams), path) ? "read-write"
313
366
  : policy.permissions.outsideProject;
314
367
  if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
@@ -344,6 +397,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
344
397
  ...policy.denyWrite,
345
398
  ...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
346
399
  ...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
400
+ ...(policy.compatibilityWrite ?? []),
347
401
  ];
348
402
  const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
349
403
  if (permissions.outsideProject === "off") {
@@ -367,13 +421,15 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
367
421
  };
368
422
  // Last matching SBPL rule wins. Credential rules override project and outside;
369
423
  // explicit denyWrite entries always override every write allowance.
424
+ for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
370
425
  scoped(policy.writableRoot, permissions.projectFiles);
371
- for (const path of policy.runtimeWrite ?? []) scoped(path, "read-write");
426
+ for (const path of (policy.runtimeWrite ?? []).filter((path) => !policy.compatibilityWrite?.includes(path))) scoped(path, "read-write");
372
427
  for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
373
428
  for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
374
429
  // Protect the directory entries, not their contents: unrelated children can
375
430
  // still be created, while renaming a parent cannot move a denied subtree.
376
431
  for (const path of protectedAncestors(protectedPaths)) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
432
+ for (const path of policy.compatibilityWrite ?? []) rules.push(`(deny file-write-unlink (literal ${sbpl(path)}))`);
377
433
  if (!permissions.network) rules.push("(deny network*)");
378
434
  return [...rules, ""].join("\n");
379
435
  }
@@ -615,33 +671,96 @@ function buildLinuxPermissionCommand(
615
671
  }
616
672
  }
617
673
  } else {
618
- mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
674
+ mounts.push(permissions.outsideProject === "read" && !policy.compatibilityWrite?.includes(canonicalizePath("/tmp", seams)) ? "--ro-bind" : "--bind", "/tmp", "/tmp");
619
675
  }
620
676
  mounts.push("--dev", "/dev");
621
- if (permissions.projectFiles !== "off") {
622
- mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
623
- }
677
+ const scopedMounts: { option: "--bind" | "--ro-bind"; path: string }[] = [];
678
+ const writableAncestors = new Set<string>();
679
+ const readOnlyGuards = new Set<string>();
624
680
  if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
625
681
  for (const path of credentials) {
626
- if (existsSync(path) && !contains(project, path)) mounts.push("--ro-bind", path, path);
682
+ if (existsSync(path) && !contains(project, path)) readOnlyGuards.add(path);
627
683
  }
628
684
  }
685
+ // A broad /tmp bind is already present for visible outside roots. Cover
686
+ // existing leaves and absent leaves via a read-only existing ancestor; do
687
+ // not create credential or control files in host runtime directories.
688
+ const compatibility = policy.compatibilityWrite ?? [];
689
+ if (compatibility.length) {
690
+ const tmp = canonicalizePath("/tmp", seams);
691
+ const protectedInTmp = [...policy.denyWrite, ...(permissions.storedCredentials !== "read-write" ? credentials : [])]
692
+ .filter((path) => contains(tmp, path) && !contains(project, path));
693
+ if (permissions.storedCredentials === "off" && protectedInTmp.some((path) =>
694
+ credentials.some((credential) => credential === path))) {
695
+ throw new Error("Linux bubblewrap cannot hide credentials under a writable runtime directory.");
696
+ }
697
+ if (policy.denyWrite.some((path) => contains(path, tmp))) {
698
+ throw new Error("Runtime directory overlaps a write-denied control path.");
699
+ }
700
+ const guards = new Set<string>();
701
+ for (const path of protectedInTmp) {
702
+ let guard = path;
703
+ while (!existsSync(guard) && contains(tmp, guard) && guard !== tmp) guard = dirname(guard);
704
+ if (guard === tmp || !contains(tmp, guard)) {
705
+ throw new Error("Cannot protect an absent path directly under writable runtime directory.");
706
+ }
707
+ guards.add(guard);
708
+ }
709
+ for (const guard of [...guards].sort((a, b) => a.length - b.length)) {
710
+ if ([...guards].some((parent) => parent !== guard && contains(parent, guard))) continue;
711
+ for (const parent of protectedAncestors([guard]).filter((parent) => contains(tmp, parent) && parent !== tmp)) {
712
+ writableAncestors.add(parent);
713
+ }
714
+ readOnlyGuards.add(guard);
715
+ }
716
+ // A writable /tmp can rename any directory above the captured project
717
+ // root, then substitute a symlink before the next launch. Anchor each
718
+ // ancestor even when there are no explicit control or credential guards.
719
+ if (permissions.projectFiles !== "off" && contains(tmp, project)) {
720
+ for (const parent of protectedAncestors([project]).filter((path) => contains(tmp, path) && path !== tmp)) {
721
+ writableAncestors.add(parent);
722
+ }
723
+ }
724
+ }
725
+ if (permissions.projectFiles !== "off") {
726
+ scopedMounts.push({ option: writableProject ? "--bind" : "--ro-bind", path: project });
727
+ }
629
728
  for (const path of policy.runtimeWrite ?? []) {
729
+ if (compatibility.includes(path)) continue;
630
730
  if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
631
731
  policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
632
732
  throw new Error("Runtime directory overlaps protected credentials or control paths.");
633
733
  }
634
- mounts.push("--bind", path, path);
734
+ scopedMounts.push({ option: "--bind", path });
635
735
  }
636
736
  const protectedPaths = [...policy.denyWrite,
637
737
  ...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
638
- for (const writableRoot of [...(writableProject ? [project] : []), ...(policy.runtimeWrite ?? [])]) {
738
+ for (const writableRoot of [...(writableProject ? [project] : []),
739
+ ...(policy.runtimeWrite ?? []).filter((path) => !compatibility.includes(path))]) {
639
740
  const materialize = seams.materializeDenyPath ?? materializeDenyPath;
640
741
  const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
641
742
  for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
642
- mounts.push("--bind", parent, parent);
743
+ writableAncestors.add(parent);
643
744
  }
644
- for (const path of leaves) mounts.push("--ro-bind", path, path);
745
+ for (const path of leaves) readOnlyGuards.add(path);
746
+ }
747
+ // A nearest-existing guard can be an ancestor of an unrelated writable
748
+ // project (e.g. absent ~/.npmrc with a project under ~/work). Mount that
749
+ // broad guard in path order, keeping its intervening anchors read-only;
750
+ // only the disjoint project subtree may be rebound writable afterwards.
751
+ const ancestorGuards = [...readOnlyGuards].filter((guard) =>
752
+ scopedMounts.some(({ option, path }) => option === "--bind" && guard !== path && contains(guard, path)));
753
+ for (const guard of ancestorGuards) {
754
+ scopedMounts.push({ option: "--ro-bind", path: guard });
755
+ readOnlyGuards.delete(guard);
756
+ }
757
+ for (const path of writableAncestors) scopedMounts.push({
758
+ option: ancestorGuards.some((guard) => contains(guard, path)) ? "--ro-bind" : "--bind", path,
759
+ });
760
+ scopedMounts.sort((a, b) => a.path.length - b.path.length);
761
+ for (const { option, path } of scopedMounts) mounts.push(option, path, path);
762
+ for (const path of [...readOnlyGuards].sort((a, b) => a.length - b.length)) {
763
+ mounts.push("--ro-bind", path, path);
645
764
  }
646
765
  return {
647
766
  file: bwrap,
@@ -6,10 +6,10 @@ const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefin
6
6
  createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { spawn } from "node:child_process";
9
- import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { basename, dirname, join, resolve, sep } from "node:path";
12
- import { canonicalizePath, maybeBuildSandboxCommand } from "./shared-sandbox-core.ts";
9
+ import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readlinkSync, writeFileSync } from "node:fs";
10
+ import { homedir, tmpdir } from "node:os";
11
+ import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
12
+ import { canonicalizePath, compileWritePolicy, maybeBuildSandboxCommand, type SandboxPermissions } from "./shared-sandbox-core.ts";
13
13
  import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
14
14
 
15
15
  export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash"] as const);
@@ -27,16 +27,43 @@ export function runtimeCodeRoot(path: string): string {
27
27
 
28
28
  export function writableRuntimeAlias(path: string, root: string, permissions: {
29
29
  projectFiles: string; outsideProject: string; storedCredentials: string;
30
- }): string | undefined {
31
- for (let current = resolve(path); dirname(current) !== current; current = dirname(current)) {
32
- try {
33
- if (!lstatSync(current).isSymbolicLink()) continue;
34
- const entry = join(canonicalizePath(dirname(current)), basename(current));
35
- accessSync(dirname(entry), constants.W_OK);
36
- const inProject = entry === root || entry.startsWith(root + sep);
37
- const access = inProject ? permissions.projectFiles : permissions.outsideProject;
38
- if (access === "read-write" || permissions.storedCredentials === "read-write") return entry;
39
- } catch { /* Nonexistent or OS-protected entries cannot be replaced by the task. */ }
30
+ }, runtimeCompatibility = false): string | undefined {
31
+ const compatibility = runtimeCompatibility ? compileWritePolicy({ writableRoot: root, home: homedir(),
32
+ permissions: { ...permissions, commands: true, network: true } as SandboxPermissions,
33
+ runtimeCompatibility: true,
34
+ }).compatibilityWrite ?? [] : [];
35
+ const absolute = resolve(path);
36
+ let current = parse(absolute).root;
37
+ let pending = absolute.slice(current.length).split(sep);
38
+ let links = 0;
39
+ while (pending.length) {
40
+ const component = pending.shift()!;
41
+ if (!component || component === ".") continue;
42
+ if (component === "..") { current = dirname(current); continue; }
43
+ const entry = join(current, component);
44
+ let stat;
45
+ try { stat = lstatSync(entry); }
46
+ catch (error) {
47
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
48
+ throw error;
49
+ }
50
+ if (!stat.isSymbolicLink()) { current = entry; continue; }
51
+ if (++links > 40) throw new Error("Too many symlinks in Pi runtime path");
52
+ let replaceable = false;
53
+ try { accessSync(current, constants.W_OK); replaceable = true; }
54
+ catch { /* An OS-protected entry is immutable; its target still needs inspection. */ }
55
+ const inProject = entry === root || entry.startsWith(root + sep);
56
+ const access = inProject ? permissions.projectFiles
57
+ : compatibility.some((directory) => entry === directory || entry.startsWith(directory + sep)) ? "read-write"
58
+ : permissions.outsideProject;
59
+ if (replaceable && (access === "read-write" || permissions.storedCredentials === "read-write")) return entry;
60
+ // Resolve targets component-by-component: realpath would erase the
61
+ // intermediate links whose directory entries need protection.
62
+ const target = readlinkSync(entry);
63
+ if (isAbsolute(target)) {
64
+ current = parse(target).root;
65
+ pending = [...target.slice(current.length).split(sep), ...pending];
66
+ } else pending = [...target.split(sep), ...pending];
40
67
  }
41
68
  return undefined;
42
69
  }
package/task-guard.ts CHANGED
@@ -12,7 +12,7 @@ export default function taskGuard(pi: ExtensionAPI, input: unknown, fatal: (erro
12
12
  const plan = Object.freeze({
13
13
  confined: true as const, profilePath: policy.profilePath,
14
14
  policy: Object.freeze({ writableRoot: policy.root, home: policy.home, permissions: policy.permissions, denyWrite: policy.denyWrite,
15
- runtimeWrite: Object.freeze([policy.scratch]) }),
15
+ runtimeCompatibility: true, runtimeWrite: Object.freeze([policy.scratch]) }),
16
16
  });
17
17
  const controller = Object.freeze({ requireLaunchPlan: () => plan });
18
18
  let shellPath: string | undefined;
package/task-policy.ts CHANGED
@@ -60,7 +60,7 @@ export function prepareTaskRuntime(options: {
60
60
  if (root === dirname(root) || root === canonicalizePath(home)) throw new Error("Task sandbox requires a project directory, not the filesystem or home root.");
61
61
  const permissions = options.permissions ?? DEFAULT_TASK_PERMISSIONS;
62
62
  const alias = [PiCodingAgent.getAgentDir(), PiCodingAgent.getPackageDir(), tmpdir()]
63
- .map((path) => writableRuntimeAlias(path, root, permissions)).find(Boolean);
63
+ .map((path) => writableRuntimeAlias(path, root, permissions, true)).find(Boolean);
64
64
  if (alias) throw new Error(`Task sandbox runtime path uses a task-writable symlink (${alias}); restart Pi with canonical runtime paths.`);
65
65
  const runtimeDirectories = ensureHarnessRuntimeDirectories();
66
66
  const agentDir = canonicalizePath(PiCodingAgent.getAgentDir());
@@ -82,7 +82,7 @@ export function prepareTaskRuntime(options: {
82
82
  ])],
83
83
  tools: options.tools,
84
84
  });
85
- compileWritePolicy({ writableRoot: root, home, permissions: policy.permissions, denyWrite: policy.denyWrite });
85
+ compileWritePolicy({ writableRoot: root, home, permissions: policy.permissions, denyWrite: policy.denyWrite, runtimeCompatibility: true });
86
86
  const policyPath = join(controlDir, "task-policy.json");
87
87
  writeFileSync(policyPath, JSON.stringify(policy), { mode: 0o600 });
88
88
  return {