pi-better-sandbox 0.4.0 → 0.5.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/README.md CHANGED
@@ -41,52 +41,51 @@ 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 private scratch through `TMPDIR`, `TMP`, and `TEMP`.
61
+ Outside Read and Read/write also retain explicit runtime write exceptions for
62
+ `/tmp` (canonical `/private/tmp` on macOS), the current user's macOS temporary
63
+ directory, and that user's Security.framework MDS cache. MDS access lets CLI
64
+ Keychain retrieval initialize and refresh its cache; it does not restrict the
65
+ Keychain API to reads. Other users' caches and arbitrary outside `*.lock` files
66
+ are not writable. Credential-file and Pi control-path protections still win.
67
+ Outside Off does not expose these host runtime directories.
68
+
69
+ The currently admitted model-tool implementations are `read`, `write`, `edit`,
70
+ and `bash`. User-entered `!` and `!!` commands use the same shell policy. Other
71
+ model-callable tools require a verified execution adapter and are refused while
72
+ that actor's sandbox is enabled; enabling network alone does not admit them.
73
+ Subagent launch output identifies requested tools that are unavailable. Main
74
+ remains Off by default, so its ordinary orchestration tools remain available
75
+ unless the user enables Main confinement.
76
+
77
+ Pi and installed runtime extensions remain trusted code, including their
78
+ initialization, provider hooks, and internal `pi.exec` calls. The tool gate is
79
+ not a sandbox around malicious runtime extensions. Loaded runtime code,
80
+ configuration, and policy/control files are protected from task writes, even
81
+ under broader file grants. Task access to `~/.pi` does not receive a blanket
82
+ write allowance or lock-file exception.
83
+
84
+ Local confinement cannot govern a remote host's filesystem. Dedicated SSH,
85
+ MCP, scripting, background, and nested-agent tools currently lack admission
86
+ adapters and fail closed under an enabled actor profile. SSH through confined
87
+ `bash` receives local file, credential, and network restrictions; remote effects
88
+ remain outside the local filesystem policy.
90
89
 
91
90
  Overriding `write` and `edit` changes nothing you can see: the parameter
92
91
  schemas, prompt guidance, call rendering, write previews, edit diffs, result
package/index.ts CHANGED
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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
  },
@@ -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. */
@@ -79,6 +82,8 @@ export type SandboxCommandArgs = SandboxTarget & {
79
82
  /** Where the macOS backend writes its generated SBPL profile. */
80
83
  profilePath: string;
81
84
  policy: SandboxWritePolicy;
85
+ /** Fixed internal helper only: expose its executable in a hidden Linux root. */
86
+ internalHelperExecutable?: boolean;
82
87
  };
83
88
 
84
89
  /** The wrapper command to spawn: the backend executable and its full argv. */
@@ -114,6 +119,8 @@ export type SandboxSeams = {
114
119
  * host running them.
115
120
  */
116
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;
117
124
  };
118
125
 
119
126
  /** A policy with every path canonicalized, deduplicated, and ordered. */
@@ -124,6 +131,7 @@ export type CompiledSandboxWritePolicy = {
124
131
  readonly permissions?: SandboxPermissions;
125
132
  readonly credentialPaths?: readonly string[];
126
133
  readonly runtimeWrite?: readonly string[];
134
+ readonly compatibilityWrite?: readonly string[];
127
135
  };
128
136
 
129
137
  /** Why a write target is or is not permitted by a compiled policy. */
@@ -174,6 +182,46 @@ const RUNTIME_ROOTS = ["/usr", "/bin", "/sbin", "/lib", "/lib64", "/System/Libra
174
182
  const TEMP_ROOTS = ["/private/var/folders", "/private/tmp", "/tmp", "/dev"];
175
183
  const READ_RUNTIME_ROOTS = [...RUNTIME_ROOTS, "/dev"];
176
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
+
177
225
  /** Only known on-disk credentials: keychains, services and inherited env tokens are out of scope. */
178
226
  export function credentialFilePaths(home: string, seams: SandboxSeams = {}): string[] {
179
227
  const paths = CREDENTIAL_LOCATIONS.map((name) => join(home, name));
@@ -233,12 +281,17 @@ function compile(
233
281
  ...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
234
282
  ].sort();
235
283
 
284
+ const compatibilityWrite = compatibilityPaths(policy, seams);
236
285
  return {
237
286
  writableRoot, denyWrite, home: policy.home,
238
287
  ...(policy.permissions && {
239
288
  permissions: { ...policy.permissions },
240
289
  credentialPaths: credentialFilePaths(policy.home, seams),
241
- 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
+ ])],
242
295
  }),
243
296
  };
244
297
  }
@@ -277,8 +330,9 @@ export function evaluateReadAccess(
277
330
  const permissions = policy.permissions;
278
331
  if (!permissions) return { allowed: true, path };
279
332
  const mode = isCredential(path, policy) ? permissions.storedCredentials
280
- : policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
333
+ : policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
281
334
  : contains(policy.writableRoot, path) ? permissions.projectFiles
335
+ : policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
282
336
  : path === sep || runtimeRoots(seams).some((root) => contains(root, path)) ? "read"
283
337
  : permissions.outsideProject;
284
338
  return mode === "off" ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
@@ -305,8 +359,9 @@ export function evaluateWriteAccess(
305
359
  }
306
360
  if (policy.permissions) {
307
361
  const mode = isCredential(path, policy) ? policy.permissions.storedCredentials
308
- : policy.runtimeWrite?.some((root) => contains(root, path)) ? "read-write"
362
+ : policy.runtimeWrite?.some((root) => !policy.compatibilityWrite?.includes(root) && contains(root, path)) ? "read-write"
309
363
  : contains(policy.writableRoot, path) ? policy.permissions.projectFiles
364
+ : policy.compatibilityWrite?.some((root) => contains(root, path)) ? "read-write"
310
365
  : contains(canonicalizePath("/dev", seams), path) ? "read-write"
311
366
  : policy.permissions.outsideProject;
312
367
  if (mode !== "read-write") return { allowed: false, path, reason: "permission-denied" };
@@ -342,6 +397,7 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
342
397
  ...policy.denyWrite,
343
398
  ...(permissions.projectFiles !== "read-write" ? [policy.writableRoot] : []),
344
399
  ...(permissions.storedCredentials !== "read-write" ? policy.credentialPaths ?? [] : []),
400
+ ...(policy.compatibilityWrite ?? []),
345
401
  ];
346
402
  const rules = ["(version 1)", "(allow default)", "(deny file-write*)"];
347
403
  if (permissions.outsideProject === "off") {
@@ -365,13 +421,15 @@ function buildPermissionProfile(policy: CompiledSandboxWritePolicy, seams: Sandb
365
421
  };
366
422
  // Last matching SBPL rule wins. Credential rules override project and outside;
367
423
  // explicit denyWrite entries always override every write allowance.
424
+ for (const path of policy.compatibilityWrite ?? []) scoped(path, "read-write");
368
425
  scoped(policy.writableRoot, permissions.projectFiles);
369
- 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");
370
427
  for (const path of policy.credentialPaths ?? []) scoped(path, permissions.storedCredentials);
371
428
  for (const path of policy.denyWrite) rules.push(`(deny file-write* (subpath ${sbpl(path)}))`);
372
429
  // Protect the directory entries, not their contents: unrelated children can
373
430
  // still be created, while renaming a parent cannot move a denied subtree.
374
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)}))`);
375
433
  if (!permissions.network) rules.push("(deny network*)");
376
434
  return [...rules, ""].join("\n");
377
435
  }
@@ -410,7 +468,21 @@ const macOSSandboxBackend: SandboxBackend = {
410
468
  buildCommand: buildMacOSSandboxCommand,
411
469
  };
412
470
 
413
- /** Resolve an executable from PATH without starting it or probing namespaces. */
471
+ /** Resolve only a root-owned system executable; never inspect task PATH. */
472
+ function systemSandboxExecutable(name: string): string | undefined {
473
+ // Never resolve the host-side confinement launcher through task-influenced PATH.
474
+ for (const directory of ["/usr/bin", "/bin"]) {
475
+ const candidate = join(directory, name);
476
+ try {
477
+ const info = statSync(candidate);
478
+ if (!info.isFile() || info.uid !== 0 || (info.mode & 0o022) !== 0) continue;
479
+ accessSync(candidate, constants.X_OK);
480
+ return candidate;
481
+ } catch { /* Try the next system location. */ }
482
+ }
483
+ return undefined;
484
+ }
485
+
414
486
  export function executableFromPath(name: string): string | undefined {
415
487
  const path = process.env.PATH;
416
488
  if (!path) return undefined;
@@ -592,33 +664,103 @@ function buildLinuxPermissionCommand(
592
664
  if (existsSync(root)) mounts.push("--ro-bind", root, root);
593
665
  }
594
666
  mounts.push("--tmpfs", "/tmp");
667
+ if (args.internalHelperExecutable) {
668
+ const executable = canonicalizePath(args.execPath, seams);
669
+ if (!RUNTIME_ROOTS.some((root) => contains(canonicalizePath(root, seams), executable))) {
670
+ mounts.push("--ro-bind", executable, executable);
671
+ }
672
+ }
595
673
  } else {
596
- 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");
597
675
  }
598
676
  mounts.push("--dev", "/dev");
599
- if (permissions.projectFiles !== "off") {
600
- mounts.push(writableProject ? "--bind" : "--ro-bind", project, project);
601
- }
677
+ const scopedMounts: { option: "--bind" | "--ro-bind"; path: string }[] = [];
678
+ const writableAncestors = new Set<string>();
679
+ const readOnlyGuards = new Set<string>();
602
680
  if (permissions.outsideProject === "off" && permissions.storedCredentials === "read") {
603
681
  for (const path of credentials) {
604
- if (existsSync(path) && !contains(project, path)) mounts.push("--ro-bind", path, path);
682
+ if (existsSync(path) && !contains(project, path)) readOnlyGuards.add(path);
605
683
  }
606
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
+ }
607
728
  for (const path of policy.runtimeWrite ?? []) {
608
- if ([...credentials, ...policy.denyWrite].some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path))) {
729
+ if (compatibility.includes(path)) continue;
730
+ if (credentials.some((protectedPath) => contains(path, protectedPath) || contains(protectedPath, path)) ||
731
+ policy.denyWrite.some((protectedPath) => contains(protectedPath, path))) {
609
732
  throw new Error("Runtime directory overlaps protected credentials or control paths.");
610
733
  }
611
- mounts.push("--bind", path, path);
734
+ scopedMounts.push({ option: "--bind", path });
612
735
  }
613
736
  const protectedPaths = [...policy.denyWrite,
614
737
  ...(permissions.storedCredentials !== "read-write" ? overlappingCredentials : [])];
615
- if (writableProject) {
738
+ for (const writableRoot of [...(writableProject ? [project] : []),
739
+ ...(policy.runtimeWrite ?? []).filter((path) => !compatibility.includes(path))]) {
616
740
  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)) {
619
- mounts.push("--bind", parent, parent);
741
+ const leaves = protectedPaths.filter((path) => contains(writableRoot, path) && materialize(path));
742
+ for (const parent of protectedAncestors(leaves).filter((path) => contains(writableRoot, path) && path !== writableRoot)) {
743
+ writableAncestors.add(parent);
620
744
  }
621
- 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);
622
764
  }
623
765
  return {
624
766
  file: bwrap,
@@ -628,7 +770,7 @@ function buildLinuxPermissionCommand(
628
770
  }
629
771
 
630
772
  function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
631
- const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
773
+ const bwrap = (seams.lookupExecutable ?? systemSandboxExecutable)("bwrap");
632
774
  if (!bwrap) return undefined;
633
775
  return {
634
776
  id: "linux-bubblewrap",
@@ -651,7 +793,7 @@ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined
651
793
  */
652
794
  function unavailableMessage(platform: string): string {
653
795
  if (platform === "linux") {
654
- return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap to enable it.";
796
+ return "Linux sandbox requires executable bubblewrap (bwrap) in /usr/bin or /bin. Install bubblewrap to enable it.";
655
797
  }
656
798
  if (platform === "darwin") {
657
799
  return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
@@ -0,0 +1,246 @@
1
+ // Generated from packages/task-sandbox/files.ts. Do not edit directly.
2
+ /** Kernel-confined operations for Pi's built-in file tools. */
3
+ import { spawn } from "node:child_process";
4
+ import { randomUUID } from "node:crypto";
5
+ import { constants } from "node:fs";
6
+ import { access, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
7
+ import type { ReadOperations, WriteOperations, EditOperations } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ canonicalizePath, compileWritePolicy, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
10
+ type SandboxWritePolicy,
11
+ } from "./shared-sandbox-core.ts";
12
+
13
+ export interface TaskFileController {
14
+ requireLaunchPlan(): { confined: false } | { confined: true; policy: SandboxWritePolicy; profilePath: string };
15
+ }
16
+
17
+ // The SDK truncates display output after reading. This separate operation cap
18
+ // bounds worker memory and pipe traffic, and rejects rather than truncates files.
19
+ const MAX_FILE_BYTES = 8 * 1024 * 1024;
20
+ const MAX_REQUEST_BYTES = 12 * 1024 * 1024;
21
+ const MAX_RESPONSE_BYTES = 12 * 1024 * 1024;
22
+
23
+ // Fixed program: tool-supplied path/content are JSON on stdin, never argv/code.
24
+ const FILE_WORKER = String.raw`
25
+ const fs = require('node:fs/promises');
26
+ const { constants } = require('node:fs');
27
+ const MAX = 8 * 1024 * 1024;
28
+ let input = Buffer.alloc(0);
29
+ process.stdin.on('data', chunk => {
30
+ if (input.length + chunk.length > 12 * 1024 * 1024) {
31
+ process.stdout.write(JSON.stringify({error:'File operation request exceeds 12 MiB',code:'EFBIG'}));
32
+ process.exit(1);
33
+ }
34
+ input = Buffer.concat([input, chunk]);
35
+ });
36
+ process.stdin.on('end', async () => {
37
+ try {
38
+ const { operation, path, content } = JSON.parse(input.toString('utf8'));
39
+ if (typeof path !== 'string') throw new Error('Invalid file path');
40
+ let data;
41
+ switch (operation) {
42
+ case 'read':
43
+ case 'mime': {
44
+ const handle = await fs.open(path, 'r');
45
+ try {
46
+ if (operation === 'mime') {
47
+ const sample = Buffer.alloc(4100);
48
+ const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
49
+ data = sample.subarray(0, bytesRead).toString('base64');
50
+ } else {
51
+ const chunks = [];
52
+ let size = 0;
53
+ for (;;) {
54
+ const chunk = Buffer.alloc(Math.min(65536, MAX + 1 - size));
55
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
56
+ if (!bytesRead) break;
57
+ size += bytesRead;
58
+ if (size > MAX) throw Object.assign(new Error('File exceeds 8 MiB operation limit'), {code:'EFBIG'});
59
+ chunks.push(chunk.subarray(0, bytesRead));
60
+ }
61
+ data = Buffer.concat(chunks, size).toString('base64');
62
+ }
63
+ } finally { await handle.close(); }
64
+ break;
65
+ }
66
+ case 'access-read': await fs.access(path, constants.R_OK); break;
67
+ case 'access-edit': await fs.access(path, constants.R_OK | constants.W_OK); break;
68
+ case 'existing-directory':
69
+ if (!(await fs.stat(path)).isDirectory()) throw new Error('Not an existing directory');
70
+ break;
71
+ case 'mkdir': await fs.mkdir(path, {recursive:true}); break;
72
+ case 'write':
73
+ if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > MAX)
74
+ throw Object.assign(new Error('File exceeds 8 MiB operation limit'), {code:'EFBIG'});
75
+ await fs.writeFile(path, content, 'utf8'); break;
76
+ default: throw new Error('Unknown file operation');
77
+ }
78
+ process.stdout.write(JSON.stringify({ok:true, data}));
79
+ } catch (error) {
80
+ process.stdout.write(JSON.stringify({error: String(error.message || error), code: error.code}));
81
+ process.exitCode = 1;
82
+ }
83
+ });`;
84
+
85
+ type Operation = "read" | "mime" | "access-read" | "access-edit" | "mkdir" | "existing-directory" | "write";
86
+
87
+ function mimeType(data: Buffer): string | null {
88
+ const ascii = (at: number, text: string) => data.toString("ascii", at, at + text.length) === text;
89
+ if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
90
+ return data[3] === 0xf7 ? null : "image/jpeg";
91
+ }
92
+ if (data.length >= 16 && data.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex")) &&
93
+ data.readUInt32BE(8) === 13 && ascii(12, "IHDR")) {
94
+ for (let offset = 8; offset + 8 <= data.length;) {
95
+ if (ascii(offset + 4, "acTL")) return null;
96
+ if (ascii(offset + 4, "IDAT")) break;
97
+ const next = offset + 12 + data.readUInt32BE(offset);
98
+ if (next <= offset || next > data.length) break;
99
+ offset = next;
100
+ }
101
+ return "image/png";
102
+ }
103
+ if (ascii(0, "GIF87a") || ascii(0, "GIF89a")) return "image/gif";
104
+ if (ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
105
+ if (ascii(0, "BM") && data.length >= 30) {
106
+ const size = data.readUInt32LE(2);
107
+ const offset = data.readUInt32LE(10);
108
+ const dib = data.readUInt32LE(14);
109
+ const planes = dib === 12 ? data.readUInt16LE(22) : data.readUInt16LE(26);
110
+ const bits = dib === 12 ? data.readUInt16LE(24) : data.readUInt16LE(28);
111
+ if ((size === 0 || size >= 26) && offset >= 14 + dib && (size === 0 || offset < size) &&
112
+ (dib === 12 || (dib >= 40 && dib <= 124)) && planes === 1 && [1, 4, 8, 16, 24, 32].includes(bits)) {
113
+ return "image/bmp";
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ export function createTaskFileOperations(controller: TaskFileController): {
120
+ read: ReadOperations; write: WriteOperations; edit: EditOperations;
121
+ } {
122
+ async function run(operation: Operation, path: string, content?: string): Promise<Buffer | void> {
123
+ const plan = controller.requireLaunchPlan();
124
+ if (!plan.confined) {
125
+ switch (operation) {
126
+ case "read": return readFile(path);
127
+ case "mime": {
128
+ const handle = await open(path, "r");
129
+ try {
130
+ const sample = Buffer.alloc(4100);
131
+ const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
132
+ return sample.subarray(0, bytesRead);
133
+ } finally { await handle.close(); }
134
+ }
135
+ case "access-read": return access(path, constants.R_OK);
136
+ case "access-edit": return access(path, constants.R_OK | constants.W_OK);
137
+ case "existing-directory":
138
+ if (!(await stat(path)).isDirectory()) throw new Error("Not an existing directory");
139
+ return;
140
+ case "mkdir": await mkdir(path, { recursive: true }); return;
141
+ case "write": return writeFile(path, content!, "utf8");
142
+ }
143
+ }
144
+
145
+ const policy = compileWritePolicy(plan.policy);
146
+ const writing = operation === "write" || operation === "mkdir" || operation === "access-edit";
147
+ const decision = writing ? evaluateWriteAccess(path, policy) : evaluateReadAccess(path, policy);
148
+ if (!decision.allowed && operation !== "existing-directory") {
149
+ if (operation === "mkdir" && decision.reason === "permission-denied") {
150
+ // SDK write prepares the parent even when it already exists.
151
+ // Confirm that no-op inside confinement; never grant mkdir.
152
+ try { await run("existing-directory", path); return; } catch { /* Preserve the original refusal. */ }
153
+ }
154
+ const detail = "deniedBy" in decision ? ` (protected by ${decision.deniedBy})` : "";
155
+ throw new Error(`Task sandbox refused to ${writing ? "write" : "read"} ${decision.path}: ${decision.reason}${detail}.`);
156
+ }
157
+ if (operation === "access-edit") {
158
+ const readable = evaluateReadAccess(path, policy);
159
+ if (!readable.allowed) throw new Error(`Task sandbox refused to read ${readable.path}: ${readable.reason}.`);
160
+ }
161
+ if (content !== undefined && Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) {
162
+ throw new Error("File exceeds 8 MiB operation limit");
163
+ }
164
+ const payload = Buffer.from(JSON.stringify({ operation, path, content }), "utf8");
165
+ if (payload.length > MAX_REQUEST_BYTES) throw new Error("File operation request exceeds 12 MiB");
166
+
167
+ // Internal helper privilege only: never alter file/credential rules.
168
+ const helperPolicy: SandboxWritePolicy = {
169
+ ...plan.policy,
170
+ permissions: {
171
+ projectFiles: "read-write", outsideProject: "read", storedCredentials: "read",
172
+ ...plan.policy.permissions,
173
+ commands: true, network: false,
174
+ },
175
+ };
176
+ const profilePath = `${plan.profilePath}.${randomUUID()}.files.sb`;
177
+ let command;
178
+ try {
179
+ command = maybeBuildSandboxCommand({
180
+ execPath: canonicalizePath(process.execPath), execArgs: ["-e", FILE_WORKER], internalHelperExecutable: true,
181
+ profilePath, policy: helperPolicy,
182
+ }, { sandboxEnabled: true, explicitSandbox: true });
183
+ } catch (error) {
184
+ void unlink(profilePath).catch(() => {});
185
+ throw error;
186
+ }
187
+ if (!command) throw new Error("Task file sandbox backend unavailable");
188
+
189
+ return new Promise<Buffer | void>((resolve, reject) => {
190
+ const child = spawn(command.file, command.fileArgs, {
191
+ stdio: ["pipe", "pipe", "pipe"],
192
+ env: { PATH: process.env.PATH ?? "", HOME: plan.policy.home },
193
+ });
194
+ const chunks: Buffer[] = [];
195
+ let size = 0;
196
+ let stderr = "";
197
+ let finished = false;
198
+ const timer = setTimeout(() => child.kill(), 30_000);
199
+ child.stdout.on("data", (chunk: Buffer) => {
200
+ size += chunk.length;
201
+ if (size > MAX_RESPONSE_BYTES) child.kill();
202
+ else chunks.push(chunk);
203
+ });
204
+ child.stderr.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString()).slice(-2048); });
205
+ child.stdin.on("error", () => {}); // An early sandbox failure may close stdin.
206
+ child.on("error", (error) => {
207
+ if (!finished) { finished = true; clearTimeout(timer); void unlink(profilePath).catch(() => {}); reject(error); }
208
+ });
209
+ child.on("close", (code) => {
210
+ void unlink(profilePath).catch(() => {});
211
+ if (finished) return;
212
+ finished = true;
213
+ clearTimeout(timer);
214
+ if (size > MAX_RESPONSE_BYTES) return reject(new Error("File operation response exceeds 12 MiB"));
215
+ let result: { ok?: boolean; data?: string; error?: string; code?: string };
216
+ try { result = JSON.parse(Buffer.concat(chunks).toString("utf8")); }
217
+ catch { return reject(new Error(`Task file sandbox failed (${code}): ${stderr || "no worker response"}`)); }
218
+ if (!result.ok) {
219
+ const error = new Error(result.error ?? `Task file sandbox failed (${code})`) as NodeJS.ErrnoException;
220
+ error.code = result.code;
221
+ return reject(error);
222
+ }
223
+ if (code !== 0) return reject(new Error(`Task file sandbox exited ${code}: ${stderr}`));
224
+ resolve(result.data === undefined ? undefined : Buffer.from(result.data, "base64"));
225
+ });
226
+ child.stdin.end(payload);
227
+ });
228
+ }
229
+
230
+ return {
231
+ read: {
232
+ readFile: (path) => run("read", path) as Promise<Buffer>,
233
+ access: (path) => run("access-read", path) as Promise<void>,
234
+ detectImageMimeType: async (path) => mimeType(await run("mime", path) as Buffer),
235
+ },
236
+ write: {
237
+ mkdir: (path) => run("mkdir", path) as Promise<void>,
238
+ writeFile: (path, content) => run("write", path, content) as Promise<void>,
239
+ },
240
+ edit: {
241
+ readFile: (path) => run("read", path) as Promise<Buffer>,
242
+ access: (path) => run("access-edit", path) as Promise<void>,
243
+ writeFile: (path, content) => run("write", path, content) as Promise<void>,
244
+ },
245
+ };
246
+ }
@@ -0,0 +1,216 @@
1
+ // Generated from packages/task-sandbox/index.ts. Do not edit directly.
2
+ /** The task boundary: trusted Pi owns the runtime; every admitted task tool uses this executor. */
3
+ import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
4
+ import type { BashOperations, ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefinition,
6
+ createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
7
+ import { pathToFileURL } from "node:url";
8
+ import { spawn } from "node:child_process";
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
+ import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
14
+
15
+ export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash"] as const);
16
+
17
+ /** Loaded code and its installed dependencies must remain task-read-only. */
18
+ export function runtimeCodeRoot(path: string): string {
19
+ const canonical = canonicalizePath(path);
20
+ let current = dirname(canonical);
21
+ while (dirname(current) !== current) {
22
+ if (basename(current) === "node_modules") return current;
23
+ current = dirname(current);
24
+ }
25
+ return dirname(canonical);
26
+ }
27
+
28
+ export function writableRuntimeAlias(path: string, root: string, permissions: {
29
+ projectFiles: string; outsideProject: string; storedCredentials: string;
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];
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ export function harnessRuntimeDirectories(): string[] {
72
+ const names = ["pi-better-subagents", "pi-better-background-tasks"];
73
+ const pool = process.env.VITEST_POOL_ID;
74
+ if (pool && /^\d+$/.test(pool)) names.push(`pi-better-background-tasks-vitest-${pool}`);
75
+ return names.map((name) => canonicalizePath(join(tmpdir(), name)));
76
+ }
77
+
78
+ export function ensureHarnessRuntimeDirectories(): string[] {
79
+ const directories = harnessRuntimeDirectories();
80
+ for (const path of directories) mkdirSync(path, { recursive: true, mode: 0o700 });
81
+ return directories;
82
+ }
83
+
84
+ export function createTaskScratch(): { path: string; anchor: string } {
85
+ const path = canonicalizePath(mkdtempSync(join(tmpdir(), "pi-task-scratch-")));
86
+ const anchor = join(path, ".sandbox-anchor");
87
+ writeFileSync(anchor, "", { flag: "wx", mode: 0o400 });
88
+ // Denying the anchor also prevents renaming/replacing its parent directory.
89
+ return { path, anchor };
90
+ }
91
+
92
+ export function createTaskBashOperations(
93
+ controller: TaskFileController,
94
+ shellPath: () => string | undefined = () => undefined,
95
+ ): BashOperations {
96
+ return {
97
+ async exec(command, cwd, options) {
98
+ const configuredShell = shellPath();
99
+ const local = createLocalBashOperations(configuredShell ? { shellPath: configuredShell } : {});
100
+ const plan = controller.requireLaunchPlan();
101
+ if (!plan.confined) return local.exec(command, cwd, options);
102
+ if (plan.policy.permissions?.commands === false) throw new Error("Sandbox: Run commands & applications is Off.");
103
+ // An explicit path avoids SDK fallback spawning PATH-resolved `which`.
104
+ const shell = getShellConfig(configuredShell ?? "/bin/bash");
105
+ const sdkUtils = join(PiCodingAgent.getPackageDir(), "dist", "utils");
106
+ const { waitForChildProcess } = await import(pathToFileURL(join(sdkUtils, "child-process.js")).href);
107
+ const { trackDetachedChildPid, untrackDetachedChildPid } = await import(pathToFileURL(join(sdkUtils, "shell.js")).href);
108
+ if (shell.commandTransport === "stdin") throw new Error("Sandbox: this shell cannot be confined by the available backend.");
109
+ const scratch = plan.policy.runtimeWrite?.[0];
110
+ const taskEnv = { ...process.env, ...options.env,
111
+ ...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
112
+ const wrapped = maybeBuildSandboxCommand({
113
+ policy: plan.policy, profilePath: plan.profilePath,
114
+ execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
115
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
116
+ .map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
117
+ }, { sandboxEnabled: true, explicitSandbox: true });
118
+ if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
119
+ if (options.signal?.aborted) throw new Error("aborted");
120
+ if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
121
+ throw new Error("Invalid timeout: must be a positive, supported number of seconds");
122
+ }
123
+ // No shell or caller-controlled loader environment runs before the boundary.
124
+ return new Promise((resolve, reject) => {
125
+ const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
126
+ env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
127
+ let timedOut = false;
128
+ const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
129
+ const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
130
+ if (child.pid) trackDetachedChildPid(child.pid);
131
+ const cleanup = () => {
132
+ if (child.pid) untrackDetachedChildPid(child.pid);
133
+ if (timer) clearTimeout(timer);
134
+ options.signal?.removeEventListener("abort", kill);
135
+ };
136
+ child.stdout.on("data", options.onData);
137
+ child.stderr.on("data", options.onData);
138
+ options.signal?.addEventListener("abort", kill, { once: true });
139
+ if (options.signal?.aborted) kill();
140
+ void waitForChildProcess(child).then((exitCode: number | null) => {
141
+ cleanup();
142
+ if (options.signal?.aborted) reject(new Error("aborted"));
143
+ else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
144
+ else resolve({ exitCode });
145
+ }, (error: unknown) => { cleanup(); reject(error); });
146
+ });
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Only definitions installed here are admitted as builtins. A distinct schema
153
+ * identity detects later replacement, including overrides using SDK factories.
154
+ * Other tool implementations need an explicit, host-owned admission function;
155
+ * knowing a tool's name or enabling network access never makes it confined.
156
+ */
157
+ export function installTaskTools(pi: ExtensionAPI, options: {
158
+ controller: TaskFileController;
159
+ cwd: string;
160
+ shellPath?: () => string | undefined;
161
+ trustedSources: readonly string[];
162
+ admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
163
+ }) {
164
+ const { controller } = options;
165
+ const sourceKey = (path: string) => path.startsWith("<") ? path : canonicalizePath(path);
166
+ const trustedSources = new Set(options.trustedSources.map(sourceKey));
167
+ const trustedSource = (path: string | undefined) => path !== undefined && trustedSources.has(sourceKey(path));
168
+ const files = createTaskFileOperations(controller);
169
+ const bash = createTaskBashOperations(controller, options.shellPath);
170
+ const schemas = new Map<string, unknown>();
171
+ let currentCwd: string | undefined;
172
+ const register = (cwd: string) => {
173
+ if (cwd === currentCwd) return;
174
+ currentCwd = cwd;
175
+ const own = <T extends { name: string; parameters: object }>(definition: T): T => {
176
+ const parameters = { ...definition.parameters };
177
+ schemas.set(definition.name, parameters);
178
+ return { ...definition, parameters };
179
+ };
180
+ pi.registerTool(own(createReadToolDefinition(cwd, { operations: files.read })));
181
+ pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
182
+ pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
183
+ pi.registerTool(own(createBashToolDefinition(cwd, { operations: bash })));
184
+ };
185
+ register(options.cwd);
186
+ pi.on("user_bash", () => ({ operations: bash }));
187
+ pi.on("tool_call", (event) => {
188
+ try {
189
+ const plan = controller.requireLaunchPlan();
190
+ if (!plan.confined) return;
191
+ const tool = pi.getAllTools().find((candidate) => candidate.name === event.toolName);
192
+ if (schemas.has(event.toolName)) {
193
+ if (!tool || tool.parameters !== schemas.get(event.toolName) || !trustedSource(tool.sourceInfo?.path)) {
194
+ return { block: true, reason: `Sandbox: ${event.toolName} was replaced by an unverified implementation.` };
195
+ }
196
+ if (event.toolName === "bash" && plan.policy.permissions?.commands === false) {
197
+ return { block: true, reason: "Sandbox: Run commands & applications is Off." };
198
+ }
199
+ return;
200
+ }
201
+ if (options.admitExtensionTool?.(event.toolName, event.input, tool?.sourceInfo?.path)) return;
202
+ return { block: true, reason: `Sandbox: ${event.toolName} has no verified task execution adapter. Use a guarded file tool or confined bash command.` };
203
+ } catch (error) {
204
+ return { block: true, reason: error instanceof Error ? error.message : String(error) };
205
+ }
206
+ });
207
+ return { register, bash, assertInstalled(names: readonly string[]) {
208
+ const inventory = pi.getAllTools();
209
+ for (const name of names) {
210
+ const tool = inventory.find((candidate) => candidate.name === name);
211
+ if (!schemas.has(name) || tool?.parameters !== schemas.get(name) || !trustedSource(tool?.sourceInfo?.path)) {
212
+ throw new Error(`Sandbox: ${name} is missing or was replaced by an unverified implementation.`);
213
+ }
214
+ }
215
+ } };
216
+ }
package/state.ts CHANGED
@@ -11,11 +11,13 @@
11
11
  * default and clears the previous override.
12
12
  */
13
13
 
14
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
14
+ import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
15
15
  import { createHash } from "node:crypto";
16
16
  import { mkdtempSync, rmSync } from "node:fs";
17
17
  import { homedir, tmpdir } from "node:os";
18
- import { join } from "node:path";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { createTaskScratch, ensureHarnessRuntimeDirectories, harnessRuntimeDirectories, runtimeCodeRoot, writableRuntimeAlias } from "./shared-task-sandbox.ts";
19
21
 
20
22
  import { parseSandboxPermissions, type SandboxPermissionSettings, type SandboxPermissionProfile } from "./permissions.ts";
21
23
  import {
@@ -119,6 +121,8 @@ export class ForegroundSandboxController {
119
121
  #profileDir: string | undefined;
120
122
  #permissions: SandboxPermissionSettings | undefined;
121
123
  #policyProblem: string | undefined;
124
+ #runtimePaths: readonly string[] = [];
125
+ #taskScratch: ReturnType<typeof createTaskScratch> | undefined;
122
126
 
123
127
  constructor(seams: ForegroundSandboxSeams = {}) {
124
128
  this.#seams = seams;
@@ -210,13 +214,21 @@ export class ForegroundSandboxController {
210
214
  return this.status();
211
215
  }
212
216
 
217
+ /** Host-discovered runtime code, never task-supplied permission grants. */
218
+ protectRuntimePaths(paths: readonly string[]): void {
219
+ this.#runtimePaths = Object.freeze(paths.map((path) => canonicalizePath(path, this.#seams)));
220
+ }
221
+
213
222
  /** The current effective status, recomputed from live runtime evidence. */
214
223
  status(): ForegroundSandboxStatus {
215
224
  const support = describeSandboxSupport(this.#seams);
216
225
  const base = {
217
226
  projectRoot: this.#projectRoot,
218
227
  denyWrite: this.#permissions
219
- ? Object.freeze([...this.#denyWrite, join(getAgentDir(), "extensions")])
228
+ ? Object.freeze([...this.#denyWrite, getAgentDir(), ...harnessRuntimeDirectories(),
229
+ runtimeCodeRoot(join(getPackageDir(), "dist", "index.js")),
230
+ dirname(fileURLToPath(import.meta.url)),
231
+ ...(this.#projectRoot ? [join(this.#projectRoot, ".pi")] : []), ...this.#runtimePaths])
220
232
  : this.#denyWrite,
221
233
  platform: support.platform,
222
234
  readPolicy: this.isUserEnabled() && this.#permissions && (this.#permissions.main.projectFiles === "off" || this.#permissions.main.outsideProject === "off" || this.#permissions.main.storedCredentials === "off") ? "restricted" as const : "unrestricted" as const,
@@ -262,14 +274,28 @@ export class ForegroundSandboxController {
262
274
  }
263
275
 
264
276
  if (this.#unsafeRootReason !== undefined) {
265
- return Object.freeze({
266
- ...base,
267
- state: "failed",
268
- writableRoot: undefined,
277
+ return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
269
278
  backend: support.supported ? support.backend : undefined,
270
279
  executable: support.supported ? support.executable : undefined,
271
- reason: this.#unsafeRootReason,
272
- });
280
+ reason: this.#unsafeRootReason });
281
+ }
282
+ const projectRoot = this.#projectRoot;
283
+ const permissions = this.#permissions?.main;
284
+ let runtimeAlias: string | undefined;
285
+ try {
286
+ runtimeAlias = permissions && [getAgentDir(), tmpdir(), getPackageDir()]
287
+ .map((path) => writableRuntimeAlias(path, projectRoot, permissions, true)).find(Boolean);
288
+ } catch (error) {
289
+ return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
290
+ backend: support.supported ? support.backend : undefined,
291
+ executable: support.supported ? support.executable : undefined,
292
+ reason: `Runtime compatibility discovery failed: ${error instanceof Error ? error.message : String(error)}` });
293
+ }
294
+ if (runtimeAlias) {
295
+ return Object.freeze({ ...base, state: "failed", writableRoot: undefined,
296
+ backend: support.supported ? support.backend : undefined,
297
+ executable: support.supported ? support.executable : undefined,
298
+ reason: `A Pi runtime directory uses a task-writable symlink (${runtimeAlias}). Restart Pi with canonical runtime, PI_CODING_AGENT_DIR, and TMPDIR paths before enabling confinement.` });
273
299
  }
274
300
 
275
301
  if (!support.supported) {
@@ -315,9 +341,13 @@ export class ForegroundSandboxController {
315
341
  // published status: this is the mechanism protecting itself, not a rule
316
342
  // the operator wrote or can remove.
317
343
  const profileDir = this.#profileDirectory();
344
+ ensureHarnessRuntimeDirectories();
345
+ this.#taskScratch ??= createTaskScratch();
318
346
  const policy: SandboxWritePolicy = {
319
347
  writableRoot: status.writableRoot,
320
- denyWrite: Object.freeze([...status.denyWrite, profileDir]),
348
+ denyWrite: Object.freeze([...status.denyWrite, profileDir, this.#taskScratch.anchor]),
349
+ runtimeCompatibility: true,
350
+ runtimeWrite: Object.freeze([this.#taskScratch.path]),
321
351
  home: (this.#seams.home ?? homedir)(),
322
352
  ...(status.permissions ? { permissions: status.permissions } : {}),
323
353
  };
@@ -326,6 +356,10 @@ export class ForegroundSandboxController {
326
356
 
327
357
  /** Drop the generated profiles this session created. */
328
358
  dispose(): void {
359
+ if (this.#taskScratch) {
360
+ rmSync(this.#taskScratch.path, { recursive: true, force: true });
361
+ this.#taskScratch = undefined;
362
+ }
329
363
  if (this.#profileDir === undefined) return;
330
364
  if (this.#seams.createProfileDir === undefined) {
331
365
  rmSync(this.#profileDir, { recursive: true, force: true });