pi-better-harness 0.4.0 → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -79,6 +79,8 @@ export type SandboxCommandArgs = SandboxTarget & {
79
79
  /** Where the macOS backend writes its generated SBPL profile. */
80
80
  profilePath: string;
81
81
  policy: SandboxWritePolicy;
82
+ /** Fixed internal helper only: expose its executable in a hidden Linux root. */
83
+ internalHelperExecutable?: boolean;
82
84
  };
83
85
 
84
86
  /** The wrapper command to spawn: the backend executable and its full argv. */
@@ -410,7 +412,21 @@ const macOSSandboxBackend: SandboxBackend = {
410
412
  buildCommand: buildMacOSSandboxCommand,
411
413
  };
412
414
 
413
- /** Resolve an executable from PATH without starting it or probing namespaces. */
415
+ /** Resolve only a root-owned system executable; never inspect task PATH. */
416
+ function systemSandboxExecutable(name: string): string | undefined {
417
+ // Never resolve the host-side confinement launcher through task-influenced PATH.
418
+ for (const directory of ["/usr/bin", "/bin"]) {
419
+ const candidate = join(directory, name);
420
+ try {
421
+ const info = statSync(candidate);
422
+ if (!info.isFile() || info.uid !== 0 || (info.mode & 0o022) !== 0) continue;
423
+ accessSync(candidate, constants.X_OK);
424
+ return candidate;
425
+ } catch { /* Try the next system location. */ }
426
+ }
427
+ return undefined;
428
+ }
429
+
414
430
  export function executableFromPath(name: string): string | undefined {
415
431
  const path = process.env.PATH;
416
432
  if (!path) return undefined;
@@ -592,6 +608,12 @@ function buildLinuxPermissionCommand(
592
608
  if (existsSync(root)) mounts.push("--ro-bind", root, root);
593
609
  }
594
610
  mounts.push("--tmpfs", "/tmp");
611
+ if (args.internalHelperExecutable) {
612
+ const executable = canonicalizePath(args.execPath, seams);
613
+ if (!RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), executable))) {
614
+ mounts.push("--ro-bind", executable, executable);
615
+ }
616
+ }
595
617
  } else {
596
618
  mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
597
619
  }
@@ -605,17 +627,18 @@ function buildLinuxPermissionCommand(
605
627
  }
606
628
  }
607
629
  for (const path of policy.runtimeWrite ?? []) {
608
- if ([...credentials, ...policy.denyWrite].some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path))) {
630
+ if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
631
+ policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
609
632
  throw new Error("Runtime directory overlaps protected credentials or control paths.");
610
633
  }
611
634
  mounts.push("--bind", path, path);
612
635
  }
613
636
  const protectedPaths = [...policy.denyWrite,
614
637
  ...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
615
- if (writableProject) {
638
+ for (const writableRoot of [...(writableProject ? [project] : []), ...(policy.runtimeWrite ?? [])]) {
616
639
  const materialize = seams.materializeDenyPath ?? materializeDenyPath;
617
- const leaves = protectedPaths.filter((path) => contains(project, path) && materialize(path));
618
- for (const parent of protectedAncestors(leaves).filter((path) => contains(project, path) && path !== project)) {
640
+ const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
641
+ for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
619
642
  mounts.push("--bind", parent, parent);
620
643
  }
621
644
  for (const path of leaves) mounts.push("--ro-bind", path, path);
@@ -628,7 +651,7 @@ function buildLinuxPermissionCommand(
628
651
  }
629
652
 
630
653
  function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
631
- const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
654
+ const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
632
655
  if (!bwrap) return undefined;
633
656
  return {
634
657
  id: "linux-bubblewrap",
@@ -651,7 +674,7 @@ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined
651
674
  */
652
675
  function unavailableMessage(platform: string): string {
653
676
  if (platform === "linux") {
654
- return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap to enable it.";
677
+ return "Linux sandbox requires executable bubblewrap (bwrap) in /usr/bin or /bin. Install bubblewrap to enable it.";
655
678
  }
656
679
  if (platform === "darwin") {
657
680
  return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
@@ -41,52 +41,47 @@ OS vault services such as Keychain and Secret Service, and tokens inherited in
41
41
  environment variables, are excluded. Read / write may be needed by a CLI that
42
42
  refreshes a token or updates its credential database.
43
43
 
44
- For shell commands the denial is done by the kernel, not by inspecting command
45
- text: macOS uses Seatbelt (`sandbox-exec`) and Linux uses Bubblewrap (`bwrap`).
46
- A crafted command cannot talk its way past it, because the write syscall itself
47
- is refused.
48
-
49
- `write` and `edit` never start a child process — they change files inside Pi's
50
- own process — so there is no child to wrap. They are confined by a containment
51
- check on the canonical target instead, run inside Pi's own file-mutation queue,
52
- immediately before the filesystem call it guards. A refused mutation leaves
53
- nothing behind on disk.
44
+ File and shell operations use the kernel: macOS uses Seatbelt (`sandbox-exec`)
45
+ and Linux uses Bubblewrap (`bwrap`). `read`, `write`, and `edit` keep Pi's normal
46
+ tool behavior and mutation queues, while a fixed worker performs filesystem
47
+ syscalls under the selected policy. Canonical checks explain denials; kernel
48
+ enforcement also protects against a symlink changing between checking and use.
49
+ The confined file worker rejects files over 8 MiB instead of silently truncating
50
+ them; larger-file processing can use a confined command when commands are On.
54
51
 
55
52
  ## What is confined, and what is not
56
53
 
57
- Selected file and network rules apply to confined commands. Main's model
58
- connection remains outside the tool sandbox. A detached subagent's OS sandbox
59
- surrounds its whole runtime: until provider networking is isolated, launching a
60
- subagent with Network access Off is refused with an explanation. Run commands &
61
- applications Off prevents new shell, application, background-task, and subagent
62
- launches; in-process file tools can still operate according to their file rules.
63
-
64
- Permissions cover the integrated first-party execution paths:
65
-
66
- - Pi's built-in `bash` tool.
67
- - User-entered `!` and `!!` commands.
68
- - Pi's built-in `read`, `write`, and `edit` tools.
69
- - Local [`pi-better-background-tasks`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-background-tasks#readme)
70
- spawns and watches, which capture this policy at launch.
71
- - [`pi-better-subagents`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-subagents#readme)
72
- children, through the same shared mechanism.
73
-
74
- **Not** confined:
75
-
76
- - Pi's own process.
77
- - `pi.exec` calls made by extensions.
78
- - Unrelated third-party extension code.
79
- - Remote filesystem operations: local permissions cannot constrain the remote
80
- host. With Main sandbox enabled, the dedicated `remote_bash` and structured
81
- SSH background launchers are blocked because their local SSH client does not
82
- yet use the confinement wrapper. Run `ssh` through confined `bash` to apply
83
- local file, credential, and network permissions to the SSH client.
84
- - Another first-party surface's control plane. Each surface denies its own —
85
- the files naming what it will run next — but not every other surface's, so
86
- confinement is per surface rather than global.
87
-
88
- This is a tool-execution sandbox. It limits accidental damage from commands the
89
- model or you run through Pi's shell; it is not a boundary around Pi itself.
54
+ Pi is the trusted runtime. It can lock configuration/authentication files,
55
+ connect to its provider, and persist sessions. Main and Subagents permissions
56
+ apply to task operations. Subagents can start with task Network access or Run
57
+ commands & applications Off. The fixed file worker remains available according
58
+ to the file permissions even when task commands are Off.
59
+
60
+ The task executor provides a private scratch directory through `TMPDIR`, `TMP`,
61
+ and `TEMP`. Only that directory is writable in addition to the selected project
62
+ and explicit file grants; a protected anchor prevents replacing its root with a
63
+ symlink. It is separate from Pi's runtime control files.
64
+
65
+ The currently admitted model-tool implementations are `read`, `write`, `edit`,
66
+ and `bash`. User-entered `!` and `!!` commands use the same shell policy. Other
67
+ model-callable tools require a verified execution adapter and are refused while
68
+ that actor's sandbox is enabled; enabling network alone does not admit them.
69
+ Subagent launch output identifies requested tools that are unavailable. Main
70
+ remains Off by default, so its ordinary orchestration tools remain available
71
+ unless the user enables Main confinement.
72
+
73
+ Pi and installed runtime extensions remain trusted code, including their
74
+ initialization, provider hooks, and internal `pi.exec` calls. The tool gate is
75
+ not a sandbox around malicious runtime extensions. Loaded runtime code,
76
+ configuration, and policy/control files are protected from task writes, even
77
+ under broader file grants. Task access to `~/.pi` does not receive a blanket
78
+ write allowance or lock-file exception.
79
+
80
+ Local confinement cannot govern a remote host's filesystem. Dedicated SSH,
81
+ MCP, scripting, background, and nested-agent tools currently lack admission
82
+ adapters and fail closed under an enabled actor profile. SSH through confined
83
+ `bash` receives local file, credential, and network restrictions; remote effects
84
+ remain outside the local filesystem policy.
90
85
 
91
86
  Overriding `write` and `edit` changes nothing you can see: the parameter
92
87
  schemas, prompt guidance, call rendering, write previews, edit diffs, result
@@ -7,19 +7,15 @@
7
7
  * with one writable root — the canonical directory Pi was launched from — and
8
8
  * the packaged write-denied paths carved back out of it. Selected file,
9
9
  * credential-file, command, and network permissions apply to protected tools.
10
- * In-process file tools use the same canonical policy as spawned commands.
10
+ * File-tool syscalls and shell commands run under the same kernel policy.
11
11
  *
12
12
  * This is a tool-execution sandbox. Pi's own process, `pi.exec` calls, and
13
13
  * unrelated third-party extension code are not confined by it.
14
14
  */
15
15
 
16
- import { homedir } from "node:os";
17
- import { resolve } from "node:path";
16
+ import { dirname, isAbsolute, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
18
  import {
19
- createBashToolDefinition,
20
- createReadToolDefinition,
21
- createEditToolDefinition,
22
- createWriteToolDefinition,
23
19
  SettingsManager,
24
20
  type ExtensionAPI,
25
21
  type ExtensionContext,
@@ -36,16 +32,12 @@ import {
36
32
  FOREGROUND_SANDBOX_POLICY_REQUEST_CHANNEL,
37
33
  publishForegroundSandboxPolicy,
38
34
  } from "./events.ts";
39
- import {
40
- createSandboxedEditOperations,
41
- createSandboxedWriteOperations,
42
- createForegroundReadGuard,
43
- } from "./files.ts";
35
+ import { installTaskTools, runtimeCodeRoot } from "./shared-task-sandbox.ts";
44
36
  import { writeSandboxDefault } from "./preferences.ts";
45
37
  import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
46
38
  import { defaultSandboxPermissions } from "./permissions.ts";
47
39
  import { openPermissionsPage } from "./permissions-page.ts";
48
- import { createSandboxedBashOperations } from "./shell.ts";
40
+
49
41
  import { footerTone, formatFooterStatus } from "./status.ts";
50
42
  import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
51
43
 
@@ -57,47 +49,12 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
57
49
  // Pi's shell setting is only readable once a session directory is known, so
58
50
  // it is resolved lazily and re-read on every session start.
59
51
  let shellPath: string | undefined;
60
- const operations = createSandboxedBashOperations(controller, { shellPath: () => shellPath });
61
-
62
- // Overriding the built-in bash tool by name. Only `operations` changes:
63
- // Pi's own definition still owns the schema, streaming, timeout,
64
- // cancellation, truncation, session environment, result details, and both
65
- // renderers, so every bash contract stays the built-in one.
66
- pi.registerTool(createBashToolDefinition(process.cwd(), { operations }));
67
-
68
- // The same backend for user-entered ! and !! commands.
69
- pi.on("user_bash", () => ({ operations }));
70
-
71
- // Overriding the built-in write and edit tools the same way: only their
72
- // file operations change, so Pi's own definitions keep the parameter
73
- // schemas, prompt guidance, call rendering, write previews, edit diffs,
74
- // result details, file-mutation queue, and cancellation checks. The guarded
75
- // operations run inside that queue, which is where the enforcement belongs.
76
- const writeOperations = createSandboxedWriteOperations(controller);
77
- const editOperations = createSandboxedEditOperations(controller);
78
- const assertReadable = createForegroundReadGuard(controller);
79
-
80
- // `cwd` is what these tools resolve a relative `path` against, so it has to
81
- // be the directory Pi itself resolves against. Registration is re-run when
82
- // a session reports a different cwd (`pi --cwd ...`), which Pi supports and
83
- // refreshes in the same session.
84
- let fileToolCwd: string | undefined;
85
- const registerFileTools = (cwd: string): void => {
86
- if (fileToolCwd === cwd) return;
87
- fileToolCwd = cwd;
88
- pi.registerTool(createWriteToolDefinition(cwd, { operations: writeOperations }));
89
- pi.registerTool(createEditToolDefinition(cwd, { operations: editOperations }));
90
- const read = createReadToolDefinition(cwd);
91
- pi.registerTool({
92
- ...read,
93
- execute: (id, params, signal, update, ctx) => {
94
- const path = params.path.replace(/^@/, "");
95
- const expanded = path === "~" ? homedir() : path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : resolve(cwd, path);
96
- return read.execute(id, { ...params, path: assertReadable(expanded) }, signal, update, ctx);
97
- },
98
- });
99
- };
100
- registerFileTools(process.cwd());
52
+ const ownEntry = fileURLToPath(import.meta.url);
53
+ const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
54
+ trustedSources: [ownEntry,
55
+ join(dirname(ownEntry), "../../extensions/sandbox/index.ts"),
56
+ join(dirname(ownEntry), "../pi-better-harness/extensions/sandbox/index.ts")],
57
+ });
101
58
 
102
59
  pi.on("tool_call", (event) => {
103
60
  const status = controller.status();
@@ -142,7 +99,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
142
99
 
143
100
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
144
101
  shellPath = resolveShellPath(ctx.cwd);
145
- registerFileTools(ctx.cwd);
102
+ boundary.register(ctx.cwd);
146
103
  paintFooter = (status) => {
147
104
  ctx.ui.setStatus(
148
105
  FOOTER_KEY,
@@ -167,6 +124,9 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
167
124
  return;
168
125
  }
169
126
  controller.beginSession(ctx.cwd, settings.main.enabled);
127
+ controller.protectRuntimePaths((pi.getAllTools?.() ?? [])
128
+ .map((tool) => tool.sourceInfo?.path).filter((path): path is string => typeof path === "string" && isAbsolute(path))
129
+ .map(runtimeCodeRoot));
170
130
  controller.setPermissionSettings(settings);
171
131
  controller.applyDefault(settings.main.enabled);
172
132
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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",
@@ -31,9 +31,9 @@
31
31
  "access": "public"
32
32
  },
33
33
  "scripts": {
34
- "pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs",
35
- "pretest": "node ../../scripts/sync-shared-sandbox-core.mjs",
36
- "prepack": "node ../../scripts/sync-shared-sandbox-core.mjs",
34
+ "pretypecheck": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
35
+ "pretest": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
36
+ "prepack": "node ../../scripts/sync-shared-sandbox-core.mjs && node ../../scripts/sync-task-sandbox.mjs",
37
37
  "typecheck": "tsc --noEmit",
38
38
  "test": "node --import tsx --test test/*.test.ts",
39
39
  "verify": "npm run typecheck && npm test"
@@ -44,7 +44,7 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "peerDependencies": {
47
- "@earendil-works/pi-coding-agent": "*",
47
+ "@earendil-works/pi-coding-agent": ">=0.82.1",
48
48
  "@earendil-works/pi-tui": "*",
49
49
  "typebox": "*"
50
50
  },
@@ -79,6 +79,8 @@ export type SandboxCommandArgs = SandboxTarget & {
79
79
  /** Where the macOS backend writes its generated SBPL profile. */
80
80
  profilePath: string;
81
81
  policy: SandboxWritePolicy;
82
+ /** Fixed internal helper only: expose its executable in a hidden Linux root. */
83
+ internalHelperExecutable?: boolean;
82
84
  };
83
85
 
84
86
  /** The wrapper command to spawn: the backend executable and its full argv. */
@@ -410,7 +412,21 @@ const macOSSandboxBackend: SandboxBackend = {
410
412
  buildCommand: buildMacOSSandboxCommand,
411
413
  };
412
414
 
413
- /** Resolve an executable from PATH without starting it or probing namespaces. */
415
+ /** Resolve only a root-owned system executable; never inspect task PATH. */
416
+ function systemSandboxExecutable(name: string): string | undefined {
417
+ // Never resolve the host-side confinement launcher through task-influenced PATH.
418
+ for (const directory of ["/usr/bin", "/bin"]) {
419
+ const candidate = join(directory, name);
420
+ try {
421
+ const info = statSync(candidate);
422
+ if (!info.isFile() || info.uid !== 0 || (info.mode & 0o022) !== 0) continue;
423
+ accessSync(candidate, constants.X_OK);
424
+ return candidate;
425
+ } catch { /* Try the next system location. */ }
426
+ }
427
+ return undefined;
428
+ }
429
+
414
430
  export function executableFromPath(name: string): string | undefined {
415
431
  const path = process.env.PATH;
416
432
  if (!path) return undefined;
@@ -592,6 +608,12 @@ function buildLinuxPermissionCommand(
592
608
  if (existsSync(root)) mounts.push("--ro-bind", root, root);
593
609
  }
594
610
  mounts.push("--tmpfs", "/tmp");
611
+ if (args.internalHelperExecutable) {
612
+ const executable = canonicalizePath(args.execPath, seams);
613
+ if (!RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), executable))) {
614
+ mounts.push("--ro-bind", executable, executable);
615
+ }
616
+ }
595
617
  } else {
596
618
  mounts.push(permissions.outsideProject === "read" ? "--ro-bind" : "--bind", "/tmp", "/tmp");
597
619
  }
@@ -605,17 +627,18 @@ function buildLinuxPermissionCommand(
605
627
  }
606
628
  }
607
629
  for (const path of policy.runtimeWrite ?? []) {
608
- if ([...credentials, ...policy.denyWrite].some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path))) {
630
+ if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
631
+ policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
609
632
  throw new Error("Runtime directory overlaps protected credentials or control paths.");
610
633
  }
611
634
  mounts.push("--bind", path, path);
612
635
  }
613
636
  const protectedPaths = [...policy.denyWrite,
614
637
  ...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
615
- if (writableProject) {
638
+ for (const writableRoot of [...(writableProject ? [project] : []), ...(policy.runtimeWrite ?? [])]) {
616
639
  const materialize = seams.materializeDenyPath ?? materializeDenyPath;
617
- const leaves = protectedPaths.filter((path) => contains(project, path) && materialize(path));
618
- for (const parent of protectedAncestors(leaves).filter((path) => contains(project, path) && path !== project)) {
640
+ const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
641
+ for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
619
642
  mounts.push("--bind", parent, parent);
620
643
  }
621
644
  for (const path of leaves) mounts.push("--ro-bind", path, path);
@@ -628,7 +651,7 @@ function buildLinuxPermissionCommand(
628
651
  }
629
652
 
630
653
  function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
631
- const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
654
+ const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
632
655
  if (!bwrap) return undefined;
633
656
  return {
634
657
  id: "linux-bubblewrap",
@@ -651,7 +674,7 @@ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined
651
674
  */
652
675
  function unavailableMessage(platform: string): string {
653
676
  if (platform === "linux") {
654
- return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap to enable it.";
677
+ return "Linux sandbox requires executable bubblewrap (bwrap) in /usr/bin or /bin. Install bubblewrap to enable it.";
655
678
  }
656
679
  if (platform === "darwin") {
657
680
  return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";