pi-better-subagents 0.1.20 → 0.1.22

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
@@ -29,7 +29,7 @@ Try it for one run:
29
29
  pi -e npm:pi-better-subagents
30
30
  ```
31
31
 
32
- Linux sandboxing uses `bubblewrap` when available, for example from `sudo apt-get install bubblewrap`.
32
+ Linux sandboxing uses `bubblewrap` when available, for example from `sudo apt-get install bubblewrap`. It is the same shared mechanism [`pi-better-sandbox`](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme) applies to Pi's foreground tools: writes only, reads and network untouched. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox).
33
33
 
34
34
  ## When To Use
35
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,11 +30,15 @@
30
30
  "access": "public"
31
31
  },
32
32
  "scripts": {
33
- "pretest": "node ../../scripts/sync-shared-log-utils.mjs",
34
- "prepack": "node ../../scripts/sync-shared-log-utils.mjs",
33
+ "pretest": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
34
+ "prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/sync-shared-sandbox-core.mjs",
35
35
  "typecheck": "node -e \"console.log('pi-better-subagents: typecheck skipped for legacy mixed TS/MJS package')\"",
36
36
  "test": "node --import tsx --test tests/*.test.mjs",
37
37
  "test:cross-session": "node --import tsx --test --test-name-pattern \"callback session isolation\" tests/extension_health_lifecycle.test.mjs",
38
+ "pretest:macos-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
39
+ "test:macos-sandbox": "bash tests/test_sandbox_applied.sh && bash tests/test_sandbox_deny_outside.sh && bash tests/test_sandbox_wrapper_argv.sh && node --import tsx --test tests/sandbox_profile.test.mjs",
40
+ "pretest:linux-sandbox": "node ../../scripts/sync-shared-sandbox-core.mjs",
41
+ "test:linux-sandbox": "node --import tsx --test tests/linux_bubblewrap.integration.mjs",
38
42
  "verify": "npm run typecheck && npm test"
39
43
  },
40
44
  "files": [
package/sandbox.ts CHANGED
@@ -1,20 +1,23 @@
1
1
  /**
2
- * OS-level write sandbox for subagent children.
2
+ * Subagent policy adapter over the shared OS write-sandbox mechanism.
3
3
  *
4
- * Kernel-enforced confinement: the child may READ anywhere and use the network
5
- * (so web_fetch and the model API keep working), but may only WRITE under a
6
- * single directory plus the system paths pi itself needs to function. Unlike the
7
- * cooperative guardrails layer (which pattern-matches tool inputs), this cannot
8
- * be evaded by a crafted bash command — the write syscall itself is denied.
9
- *
10
- * Backends are selected here so callers retain a platform-neutral support query
11
- * and command-wrapper contract. Linux support adds a backend without changing
12
- * detached spawning policy in the extension.
4
+ * The mechanism itself — backend discovery, canonical containment, profile and
5
+ * mount construction, ordered argv wrapping — lives in `sandbox-core` and is
6
+ * vendored here as `shared-sandbox-core.ts`. This file holds only the subagent
7
+ * shape of that call: the detached child's writable directory is the whole
8
+ * policy, and the wrapped executable is always the pi binary. Detached spawning
9
+ * policy (default-on, explicit request, explicit opt-out) stays in index.ts and
10
+ * is passed through unchanged.
13
11
  */
14
12
 
15
- import { platform } from "node:os";
16
- import { accessSync, constants, realpathSync, statSync, writeFileSync } from "node:fs";
17
- import { delimiter, resolve } from "node:path";
13
+ import {
14
+ buildSandboxCommand as buildSharedSandboxCommand,
15
+ maybeBuildSandboxCommand as maybeBuildSharedSandboxCommand,
16
+ sandboxSupported as sharedSandboxSupported,
17
+ type SandboxCommand,
18
+ type SandboxCommandArgs as SharedSandboxCommandArgs,
19
+ type SandboxRequest,
20
+ } from "./shared-sandbox-core.ts";
18
21
 
19
22
  type SandboxCommandArgs = {
20
23
  profilePath: string;
@@ -24,115 +27,20 @@ type SandboxCommandArgs = {
24
27
  piArgs: string[];
25
28
  };
26
29
 
27
- type SandboxCommand = { file: string; fileArgs: string[] };
28
-
29
- type SandboxBackend = {
30
- buildCommand(args: SandboxCommandArgs): SandboxCommand;
31
- };
32
-
33
- type SandboxRequest = {
34
- sandboxEnabled: boolean;
35
- explicitSandbox: boolean;
36
- };
37
-
38
- /** Quote a path as an SBPL string literal. */
39
- function sbpl(path: string): string {
40
- return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
41
- }
42
-
43
- /** Build the existing macOS sandbox-exec wrapper and SBPL profile. */
44
- function buildMacOSSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
45
- // Match on the real (symlink-resolved) path — sandbox-exec evaluates the
46
- // canonical path, so /tmp/x must be written as /private/tmp/x.
47
- let dir = args.writableDir;
48
- try { dir = realpathSync(dir); } catch { /* not yet created; use as given */ }
49
-
50
- const profile = [
51
- "(version 1)",
52
- "(allow default)", // permissive base: reads, exec, network
53
- "(deny file-write*)", // ...then deny all writes...
54
- `(allow file-write* (subpath ${sbpl(dir)}))`, // ...except here
55
- `(allow file-write* (subpath ${sbpl(`${args.home}/.pi`)}))`, // pi state
56
- '(allow file-write* (subpath "/private/var/folders"))', // macOS temp / our runtime
57
- '(allow file-write* (subpath "/private/tmp"))',
58
- '(allow file-write* (subpath "/dev"))', // /dev/null etc.
59
- "",
60
- ].join("\n");
61
- writeFileSync(args.profilePath, profile);
62
-
63
- return {
64
- file: "/usr/bin/sandbox-exec",
65
- fileArgs: ["-f", args.profilePath, args.piBin, ...args.piArgs],
66
- };
67
- }
68
-
69
- const macOSSandboxBackend: SandboxBackend = {
70
- buildCommand: buildMacOSSandboxCommand,
71
- };
72
-
73
- /** Resolve an executable from PATH without starting it or probing namespaces. */
74
- function executableFromPath(name: string): string | undefined {
75
- const path = process.env.PATH;
76
- if (!path) return undefined;
77
-
78
- for (const entry of path.split(delimiter)) {
79
- const candidate = resolve(entry || ".", name);
80
- try {
81
- if (!statSync(candidate).isFile()) continue;
82
- accessSync(candidate, constants.X_OK);
83
- return candidate;
84
- } catch {
85
- // A PATH entry may disappear or be inaccessible between lookup and use.
86
- }
87
- }
88
- return undefined;
89
- }
90
-
91
- function buildLinuxSandboxCommand(bwrap: string, args: SandboxCommandArgs): SandboxCommand {
92
- // The caller creates the selected work directory before it reaches this
93
- // boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
94
- // widening the writable root.
95
- const dir = realpathSync(args.writableDir);
30
+ /** Map the subagent's single-writable-directory shape onto the shared policy. */
31
+ function sharedArgs(args: SandboxCommandArgs): SharedSandboxCommandArgs {
96
32
  return {
97
- file: bwrap,
98
- fileArgs: [
99
- "--ro-bind", "/", "/",
100
- "--bind", dir, dir,
101
- "--bind", "/tmp", "/tmp",
102
- "--dev", "/dev",
103
- "--",
104
- args.piBin, ...args.piArgs,
105
- ],
33
+ profilePath: args.profilePath,
34
+ // Subagents have no write-deny list: the run directory is the policy.
35
+ policy: { writableRoot: args.writableDir, home: args.home },
36
+ execPath: args.piBin,
37
+ execArgs: args.piArgs,
106
38
  };
107
39
  }
108
40
 
109
- function linuxSandboxBackend(): SandboxBackend | undefined {
110
- const bwrap = executableFromPath("bwrap");
111
- if (!bwrap) return undefined;
112
- return { buildCommand: (args) => buildLinuxSandboxCommand(bwrap, args) };
113
- }
114
-
115
- function selectedSandboxBackend(): SandboxBackend | undefined {
116
- const currentPlatform = platform();
117
- if (currentPlatform === "darwin") return macOSSandboxBackend;
118
- if (currentPlatform === "linux") return linuxSandboxBackend();
119
- return undefined;
120
- }
121
-
122
- function sandboxUnavailableMessage(): string {
123
- const currentPlatform = platform();
124
- if (currentPlatform === "linux") {
125
- return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap or pass sandbox:false.";
126
- }
127
- if (currentPlatform === "darwin") {
128
- return "macOS sandbox requires /usr/bin/sandbox-exec. Pass sandbox:false if it is unavailable.";
129
- }
130
- return `sandbox is unsupported on ${currentPlatform}. Pass sandbox:false on this platform.`;
131
- }
132
-
133
41
  /** True when an OS write-sandbox backend can be applied on this platform. */
134
42
  export function sandboxSupported(): boolean {
135
- return selectedSandboxBackend() !== undefined;
43
+ return sharedSandboxSupported();
136
44
  }
137
45
 
138
46
  /**
@@ -144,14 +52,13 @@ export function maybeBuildSandboxCommand(
144
52
  args: SandboxCommandArgs,
145
53
  request: SandboxRequest,
146
54
  ): SandboxCommand | undefined {
147
- if (!request.sandboxEnabled) return undefined;
148
-
149
- const backend = selectedSandboxBackend();
150
- if (!backend) {
151
- if (request.explicitSandbox) throw new Error(sandboxUnavailableMessage());
152
- return undefined;
153
- }
154
- return backend.buildCommand(args);
55
+ // `sandbox:false` is this surface's opt-out, and the only one its operator
56
+ // has: a subagent has no slash commands. A caller that states its own remedy
57
+ // keeps it.
58
+ return maybeBuildSharedSandboxCommand(sharedArgs(args), {
59
+ ...request,
60
+ remedy: request.remedy ?? "Pass sandbox:false to run this subagent unconfined.",
61
+ });
155
62
  }
156
63
 
157
64
  /**
@@ -160,5 +67,5 @@ export function maybeBuildSandboxCommand(
160
67
  * bypass the request-policy helper above.
161
68
  */
162
69
  export function buildSandboxCommand(args: SandboxCommandArgs): SandboxCommand {
163
- return (selectedSandboxBackend() ?? macOSSandboxBackend).buildCommand(args);
70
+ return buildSharedSandboxCommand(sharedArgs(args));
164
71
  }
@@ -909,13 +909,13 @@ function createOverlayComponent(
909
909
  width,
910
910
  deps.truncate,
911
911
  fg,
912
- { minRows: detailRows },
912
+ { minRows: detailRows, bottomFooter: false, logTailRows },
913
913
  );
914
914
  } else {
915
915
  transcriptDetail = null;
916
916
  transcriptComponent = null;
917
917
  contentLines = mode === "detail"
918
- ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows })
918
+ ? buildDetailLines(detail, width, deps.truncate, fg, { expandedSections, logTailRows, minRows: detailRows, bottomFooter: false })
919
919
  : buildListLines(overlayState, width, deps.truncate, fg);
920
920
  }
921
921
  if (mode !== "detail") return contentLines;
@@ -994,9 +994,11 @@ function buildTranscriptDetailLines(
994
994
  width: number,
995
995
  truncate: (s: string, width: number) => string,
996
996
  fg: (color: string, value: string) => string,
997
- options: { minRows?: number } = {},
997
+ options: { minRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
998
998
  ): string[] {
999
999
  const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
1000
+ const tailRows = options.logTailRows ?? DEFAULT_LOG_TAIL_ROWS;
1001
+ const shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
1000
1002
  const lines: string[] = [
1001
1003
  fg("accent", rule(detail.title, width)),
1002
1004
  dim(` ← main · ${actions}`, fg),
@@ -1005,10 +1007,11 @@ function buildTranscriptDetailLines(
1005
1007
  ];
1006
1008
  if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
1007
1009
  for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
1008
- lines.push("", dim(section("transcript", width), fg));
1010
+ lines.push("", dim(section(`transcript · latest ${tailRows} rows`, width), fg));
1009
1011
  if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
1010
- lines.push(...(transcriptLines.length ? transcriptLines : [" (no transcript yet)"]));
1012
+ lines.push(...shownTranscriptLines);
1011
1013
  lines.push("");
1014
+ if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
1012
1015
  const footerLines = [dim(` ← main · ${actions}`, fg), dim(rule("", width), fg)];
1013
1016
  padBeforeFooter(lines, footerLines.length, options.minRows);
1014
1017
  lines.push(...footerLines);
@@ -1092,10 +1095,11 @@ function buildDetailLines(
1092
1095
  width: number,
1093
1096
  truncate: (s: string, width: number) => string,
1094
1097
  fg: (color: string, value: string) => string,
1095
- options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number } = {},
1098
+ options: { expandedSections?: Set<string>; logTailRows?: number; minRows?: number; bottomFooter?: boolean } = {},
1096
1099
  ): string[] {
1097
1100
  if (!detail) {
1098
1101
  const lines = [rule("Work unavailable", width), dim(" ← back · Esc close", fg), ""];
1102
+ if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
1099
1103
  const footerLines = [dim(rule("", width), fg)];
1100
1104
  padBeforeFooter(lines, footerLines.length, options.minRows);
1101
1105
  lines.push(...footerLines);
@@ -1149,14 +1153,17 @@ function buildDetailLines(
1149
1153
  for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
1150
1154
  }
1151
1155
  } else {
1152
- const evidenceLabel = /log/i.test(detail.evidence.label) ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
1156
+ const tailEvidence = isTailEvidence(detail);
1157
+ const evidenceLabel = tailEvidence ? `${detail.evidence.label} · latest ${tailRows} rows` : detail.evidence.label;
1153
1158
  lines.push(dim(section(evidenceLabel, width), fg));
1154
1159
  const wrapped = /log/i.test(detail.evidence.label)
1155
1160
  ? wrapLogText(body, width - 6)
1156
- : body.split(/\r?\n/);
1157
- for (const raw of wrapped) lines.push(raw ? ` ${raw}` : " ");
1161
+ : wrapEvidenceText(body, width - 6);
1162
+ const shown = tailEvidence ? wrapped.slice(-tailRows) : wrapped;
1163
+ for (const raw of shown) lines.push(raw ? ` ${raw}` : " ");
1158
1164
  }
1159
1165
  lines.push("");
1166
+ if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
1160
1167
  const footerLines = [dim(` ← back · ${actions}`, fg), dim(rule("", width), fg)];
1161
1168
  padBeforeFooter(lines, footerLines.length, options.minRows);
1162
1169
  lines.push(...footerLines);
@@ -1182,7 +1189,11 @@ function applyDefaultExpandedSections(detail: BackgroundWorkDetail | null, expan
1182
1189
  }
1183
1190
 
1184
1191
  function isFoldableEvidence(detail: BackgroundWorkDetail): boolean {
1185
- return !/log/i.test(detail.evidence.label);
1192
+ return !isTailEvidence(detail);
1193
+ }
1194
+
1195
+ function isTailEvidence(detail: BackgroundWorkDetail): boolean {
1196
+ return /log|transcript/i.test(detail.evidence.label);
1186
1197
  }
1187
1198
 
1188
1199
  function sectionHeader(label: string, width: number): string {
@@ -0,0 +1,462 @@
1
+ // Generated from packages/sandbox-core/index.ts. Do not edit directly.
2
+ /**
3
+ * OS-level write sandbox mechanism shared by Pi extensions.
4
+ *
5
+ * Kernel-enforced confinement: the sandboxed process may READ anywhere and use
6
+ * the network (so web_fetch and the model API keep working), but may only WRITE
7
+ * under a single canonical root plus the system paths pi itself needs. Unlike a
8
+ * cooperative guardrails layer (which pattern-matches tool inputs), this cannot
9
+ * be evaded by a crafted bash command — the write syscall itself is denied.
10
+ *
11
+ * This module owns the mechanism only: backend discovery, canonical path
12
+ * containment, write-deny compilation, macOS SBPL profile construction, Linux
13
+ * Bubblewrap mount construction, ordered executable/argv wrapping, and support
14
+ * diagnostics. It owns no Pi tool, TUI, background-task, or subagent lifecycle
15
+ * policy — callers decide when a sandbox is requested and what it may write.
16
+ *
17
+ * Every platform/filesystem dependency is reachable through the optional
18
+ * `SandboxSeams` argument so callers can plan deterministically in tests.
19
+ */
20
+
21
+ import { platform as osPlatform } from "node:os";
22
+ import {
23
+ accessSync,
24
+ closeSync,
25
+ constants,
26
+ existsSync,
27
+ mkdirSync,
28
+ openSync,
29
+ realpathSync,
30
+ statSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
34
+
35
+ /** Identifies which kernel mechanism a plan will use. */
36
+ export type SandboxBackendId = "macos-seatbelt" | "linux-bubblewrap";
37
+
38
+ /**
39
+ * What a sandboxed process may write. `writableRoot` and `denyWrite` entries may
40
+ * be relative or contain symlinks; they are canonicalized before use.
41
+ */
42
+ export type SandboxWritePolicy = {
43
+ /** The single directory subtree the sandboxed process may write under. */
44
+ writableRoot: string;
45
+ /**
46
+ * Concrete paths that stay non-writable even inside `writableRoot`. A
47
+ * directory entry denies its whole subtree; a file entry denies that file.
48
+ *
49
+ * An entry need not exist. The Linux backend needs a mount point, so it
50
+ * materializes an absent entry as an empty file (see `materializeDenyPath`);
51
+ * an entry that has to be a *directory* must therefore already exist when the
52
+ * command is built. Callers denying their own state directory create it
53
+ * first, which they do anyway to write into it.
54
+ */
55
+ denyWrite?: readonly string[];
56
+ /** Home directory whose `~/.pi` state stays writable on macOS. */
57
+ home: string;
58
+ };
59
+
60
+ /** The executable and argv to run inside the sandbox, preserved verbatim. */
61
+ export type SandboxTarget = {
62
+ execPath: string;
63
+ execArgs: readonly string[];
64
+ };
65
+
66
+ export type SandboxCommandArgs = SandboxTarget & {
67
+ /** Where the macOS backend writes its generated SBPL profile. */
68
+ profilePath: string;
69
+ policy: SandboxWritePolicy;
70
+ };
71
+
72
+ /** The wrapper command to spawn: the backend executable and its full argv. */
73
+ export type SandboxCommand = { file: string; fileArgs: string[] };
74
+
75
+ /** The caller's default-on / explicit-request / opt-out decision. */
76
+ export type SandboxRequest = {
77
+ sandboxEnabled: boolean;
78
+ explicitSandbox: boolean;
79
+ /**
80
+ * What this caller's operator can actually do about a missing backend,
81
+ * appended when an explicit request has to be refused. Surfaces differ: a
82
+ * subagent tool takes `sandbox:false`, a foreground session takes
83
+ * `/sandbox off`, so the remedy cannot be stated here.
84
+ */
85
+ remedy?: string;
86
+ };
87
+
88
+ /** Injectable platform and filesystem dependencies. Defaults hit the real OS. */
89
+ export type SandboxSeams = {
90
+ /** Defaults to `os.platform()`. */
91
+ platform?: () => string;
92
+ /** Defaults to a PATH scan that stats and access-checks without executing. */
93
+ lookupExecutable?: (name: string) => string | undefined;
94
+ /** Defaults to `fs.realpathSync`. Must throw when the path does not exist. */
95
+ canonicalize?: (path: string) => string;
96
+ /** Defaults to `fs.writeFileSync`. */
97
+ writeProfile?: (path: string, contents: string) => void;
98
+ /**
99
+ * Defaults to creating an empty placeholder file for an absent denied path
100
+ * (see `materializeDenyPath`). Returns whether the path exists afterwards.
101
+ * Injected by tests that plan Linux argv for paths that do not exist on the
102
+ * host running them.
103
+ */
104
+ materializeDenyPath?: (path: string) => boolean;
105
+ };
106
+
107
+ /** A policy with every path canonicalized, deduplicated, and ordered. */
108
+ export type CompiledSandboxWritePolicy = {
109
+ readonly writableRoot: string;
110
+ readonly denyWrite: readonly string[];
111
+ readonly home: string;
112
+ };
113
+
114
+ /** Why a write target is or is not permitted by a compiled policy. */
115
+ export type WriteAccessDecision =
116
+ | { allowed: true; path: string }
117
+ | {
118
+ allowed: false;
119
+ path: string;
120
+ reason: "outside-writable-root" | "write-denied";
121
+ /** The compiled deny entry that matched, for `write-denied` only. */
122
+ deniedBy?: string;
123
+ };
124
+
125
+ /** What the current platform can enforce, and why it cannot when it cannot. */
126
+ export type SandboxSupport =
127
+ | { supported: true; platform: string; backend: SandboxBackendId; executable: string }
128
+ | {
129
+ supported: false;
130
+ platform: string;
131
+ backend: undefined;
132
+ executable: undefined;
133
+ reason: string;
134
+ };
135
+
136
+ type SandboxBackend = {
137
+ id: SandboxBackendId;
138
+ executable: string;
139
+ buildCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand;
140
+ };
141
+
142
+ const MACOS_SANDBOX_EXEC = "/usr/bin/sandbox-exec";
143
+
144
+ function currentPlatform(seams: SandboxSeams): string {
145
+ return (seams.platform ?? osPlatform)();
146
+ }
147
+
148
+ /**
149
+ * Resolve `path` to an absolute canonical path. Symlinks are resolved on the
150
+ * longest existing ancestor so a target that does not exist yet still
151
+ * canonicalizes through its real parent chain.
152
+ */
153
+ export function canonicalizePath(path: string, seams: SandboxSeams = {}): string {
154
+ const canonicalize = seams.canonicalize ?? realpathSync;
155
+ const absolute = resolve(path);
156
+ try {
157
+ return canonicalize(absolute);
158
+ } catch {
159
+ // Not created yet (or unreadable): canonicalize the parent instead.
160
+ }
161
+ const parent = dirname(absolute);
162
+ if (parent === absolute) return absolute;
163
+ return join(canonicalizePath(parent, seams), basename(absolute));
164
+ }
165
+
166
+ function compile(
167
+ policy: SandboxWritePolicy,
168
+ seams: SandboxSeams,
169
+ strictRoot: boolean,
170
+ ): CompiledSandboxWritePolicy {
171
+ // The Linux backend has always required the writable root to exist before it
172
+ // bind-mounts it; the macOS backend has always tolerated a not-yet-created
173
+ // one. Keep both behaviors rather than unifying them here.
174
+ const writableRoot = strictRoot
175
+ ? (seams.canonicalize ?? realpathSync)(policy.writableRoot)
176
+ : canonicalizePath(policy.writableRoot, seams);
177
+
178
+ const denyWrite = [
179
+ ...new Set((policy.denyWrite ?? []).map((entry) => canonicalizePath(entry, seams))),
180
+ ].sort();
181
+
182
+ return { writableRoot, denyWrite, home: policy.home };
183
+ }
184
+
185
+ /**
186
+ * Canonicalize a write policy once so containment checks and backend rules
187
+ * agree on exactly which paths they are talking about.
188
+ */
189
+ export function compileWritePolicy(
190
+ policy: SandboxWritePolicy,
191
+ seams: SandboxSeams = {},
192
+ ): CompiledSandboxWritePolicy {
193
+ return compile(policy, seams, false);
194
+ }
195
+
196
+ function contains(root: string, target: string): boolean {
197
+ if (target === root) return true;
198
+ return target.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
199
+ }
200
+
201
+ /**
202
+ * Decide whether an in-process write to `target` is permitted by a compiled
203
+ * policy. This is the same containment rule the kernel backends enforce, for
204
+ * callers that mutate files directly instead of spawning a child.
205
+ */
206
+ export function evaluateWriteAccess(
207
+ target: string,
208
+ policy: CompiledSandboxWritePolicy,
209
+ seams: SandboxSeams = {},
210
+ ): WriteAccessDecision {
211
+ const path = canonicalizePath(target, seams);
212
+ if (!contains(policy.writableRoot, path)) {
213
+ return { allowed: false, path, reason: "outside-writable-root" };
214
+ }
215
+ for (const denied of policy.denyWrite) {
216
+ if (contains(denied, path)) {
217
+ return { allowed: false, path, reason: "write-denied", deniedBy: denied };
218
+ }
219
+ }
220
+ return { allowed: true, path };
221
+ }
222
+
223
+ /** Quote a path as an SBPL string literal. */
224
+ function sbpl(path: string): string {
225
+ return `"${path.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
226
+ }
227
+
228
+ /** Build the macOS sandbox-exec wrapper and its SBPL profile. */
229
+ function buildMacOSSandboxCommand(args: SandboxCommandArgs, seams: SandboxSeams): SandboxCommand {
230
+ // Match on the real (symlink-resolved) path — sandbox-exec evaluates the
231
+ // canonical path, so /tmp/x must be written as /private/tmp/x.
232
+ const policy = compile(args.policy, seams, false);
233
+
234
+ const profile = [
235
+ "(version 1)",
236
+ "(allow default)", // permissive base: reads, exec, network
237
+ "(deny file-write*)", // ...then deny all writes...
238
+ `(allow file-write* (subpath ${sbpl(policy.writableRoot)}))`, // ...except here
239
+ `(allow file-write* (subpath ${sbpl(`${policy.home}/.pi`)}))`, // pi state
240
+ '(allow file-write* (subpath "/private/var/folders"))', // macOS temp / our runtime
241
+ '(allow file-write* (subpath "/private/tmp"))',
242
+ '(allow file-write* (subpath "/dev"))', // /dev/null etc.
243
+ // Deny rules come last: SBPL applies the last matching rule, so these
244
+ // carve holes back out of the allowances above.
245
+ ...policy.denyWrite.map((path) => `(deny file-write* (subpath ${sbpl(path)}))`),
246
+ "",
247
+ ].join("\n");
248
+ (seams.writeProfile ?? writeFileSync)(args.profilePath, profile);
249
+
250
+ return {
251
+ file: MACOS_SANDBOX_EXEC,
252
+ fileArgs: ["-f", args.profilePath, args.execPath, ...args.execArgs],
253
+ };
254
+ }
255
+
256
+ const macOSSandboxBackend: SandboxBackend = {
257
+ id: "macos-seatbelt",
258
+ executable: MACOS_SANDBOX_EXEC,
259
+ buildCommand: buildMacOSSandboxCommand,
260
+ };
261
+
262
+ /** Resolve an executable from PATH without starting it or probing namespaces. */
263
+ export function executableFromPath(name: string): string | undefined {
264
+ const path = process.env.PATH;
265
+ if (!path) return undefined;
266
+
267
+ for (const entry of path.split(delimiter)) {
268
+ const candidate = resolve(entry || ".", name);
269
+ try {
270
+ if (!statSync(candidate).isFile()) continue;
271
+ accessSync(candidate, constants.X_OK);
272
+ return candidate;
273
+ } catch {
274
+ // A PATH entry may disappear or be inaccessible between lookup and use.
275
+ }
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ /**
281
+ * Give an absent denied path something the kernel can hold out.
282
+ *
283
+ * A mount needs a mount point. `--ro-bind-try` skips a source that does not
284
+ * exist, so before this a denied path that had not been created yet was not
285
+ * denied at all: inside the sandbox `echo secret > .env.local` simply created
286
+ * it. `.env` and `.env.local` are absent in most projects, which made the
287
+ * packaged defaults hold on macOS — SBPL denies by resolved path, existing or
288
+ * not — and not on Linux.
289
+ *
290
+ * Nothing bubblewrap offers closes that without a mount point, and every
291
+ * bubblewrap operation that would create one (`--dir`, `--file`, `--tmpfs`)
292
+ * creates it through the read-write bind of the project, which is to say on the
293
+ * real filesystem anyway. So the placeholder is created here, deliberately and
294
+ * visibly, rather than as a side effect of a mount operation.
295
+ *
296
+ * An empty regular file is the least destructive placeholder: a later
297
+ * `cp .env.example .env` overwrites it, where an empty *directory* at that path
298
+ * would fail. Callers whose denied entry has to be a directory create it before
299
+ * building the command, and an entry that already exists — file or directory —
300
+ * is bound as it is. The file is created with O_EXCL, so anything that appears
301
+ * in the meantime is bound rather than clobbered, and it is left in place
302
+ * afterwards because a resumed task re-runs the launch vector it captured and
303
+ * its `--ro-bind` sources have to still be there.
304
+ *
305
+ * Returns false when the placeholder could not be created. That is not a hole:
306
+ * the confined process runs as this same user, so a path this process cannot
307
+ * create is a path that process cannot create either.
308
+ */
309
+ function materializeDenyPath(path: string): boolean {
310
+ if (existsSync(path)) return true;
311
+ try {
312
+ mkdirSync(dirname(path), { recursive: true });
313
+ closeSync(openSync(path, "wx"));
314
+ return true;
315
+ } catch {
316
+ // Re-check rather than trust the errno. EEXIST from the O_EXCL create
317
+ // means the path appeared in between, which is the outcome we wanted and
318
+ // not ours to overwrite; EEXIST from the mkdir means a parent is a
319
+ // regular file, and nothing can exist under it. Only the first leaves a
320
+ // source a bind can use.
321
+ return existsSync(path);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Whether a denied path lies in a region this backend binds read-write.
327
+ *
328
+ * Everywhere else is already covered by the read-only bind of `/`, so a
329
+ * placeholder there would deny nothing that is not denied already — and would
330
+ * scatter empty files across the host for the sake of it. Materializing is
331
+ * confined to the two regions that are genuinely writable inside the sandbox:
332
+ * the writable root, and the `/tmp` rebind that pi's own tooling needs.
333
+ */
334
+ function writableInsideLinuxSandbox(path: string, writableRoot: string): boolean {
335
+ return contains(writableRoot, path) || contains("/tmp", path);
336
+ }
337
+
338
+ function buildLinuxSandboxCommand(
339
+ bwrap: string,
340
+ args: SandboxCommandArgs,
341
+ seams: SandboxSeams,
342
+ ): SandboxCommand {
343
+ // The caller creates the selected work directory before it reaches this
344
+ // boundary. Canonicalizing it before bind-mounting keeps symlink aliases from
345
+ // widening the writable root.
346
+ const policy = compile(args.policy, seams, true);
347
+ const materialize = seams.materializeDenyPath ?? materializeDenyPath;
348
+ const denyBinds = policy.denyWrite.flatMap((path) => {
349
+ const mountable = writableInsideLinuxSandbox(path, policy.writableRoot) && materialize(path);
350
+ return [mountable ? "--ro-bind" : "--ro-bind-try", path, path];
351
+ });
352
+ return {
353
+ file: bwrap,
354
+ fileArgs: [
355
+ "--ro-bind", "/", "/",
356
+ "--bind", policy.writableRoot, policy.writableRoot,
357
+ "--bind", "/tmp", "/tmp",
358
+ "--dev", "/dev",
359
+ // Layered last so a denied path wins over every writable bind above.
360
+ // A denied path need not exist yet, so one inside a writable region
361
+ // is materialized first; `-try` remains for the paths that are
362
+ // read-only regardless and for the ones that could not be created,
363
+ // which are paths the confined process cannot create either.
364
+ ...denyBinds,
365
+ "--",
366
+ args.execPath, ...args.execArgs,
367
+ ],
368
+ };
369
+ }
370
+
371
+ function linuxSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
372
+ const bwrap = (seams.lookupExecutable ?? executableFromPath)("bwrap");
373
+ if (!bwrap) return undefined;
374
+ return {
375
+ id: "linux-bubblewrap",
376
+ executable: bwrap,
377
+ buildCommand: (args, buildSeams) => buildLinuxSandboxCommand(bwrap, args, buildSeams),
378
+ };
379
+ }
380
+
381
+ function selectedSandboxBackend(seams: SandboxSeams): SandboxBackend | undefined {
382
+ const platform = currentPlatform(seams);
383
+ if (platform === "darwin") return macOSSandboxBackend;
384
+ if (platform === "linux") return linuxSandboxBackend(seams);
385
+ return undefined;
386
+ }
387
+
388
+ /**
389
+ * Why no backend applies here. The requirement only: what a caller can do
390
+ * instead is a property of that caller's surface, not of the platform, and is
391
+ * supplied through `SandboxRequest.remedy`.
392
+ */
393
+ function unavailableMessage(platform: string): string {
394
+ if (platform === "linux") {
395
+ return "Linux sandbox requires executable bubblewrap (bwrap) on PATH. Install bubblewrap to enable it.";
396
+ }
397
+ if (platform === "darwin") {
398
+ return "macOS sandbox requires /usr/bin/sandbox-exec, which is missing here.";
399
+ }
400
+ return `sandbox is unsupported on ${platform}.`;
401
+ }
402
+
403
+ /** Report which backend this platform would select, and why it would not. */
404
+ export function describeSandboxSupport(seams: SandboxSeams = {}): SandboxSupport {
405
+ const platform = currentPlatform(seams);
406
+ const backend = selectedSandboxBackend(seams);
407
+ if (!backend) {
408
+ return {
409
+ supported: false,
410
+ platform,
411
+ backend: undefined,
412
+ executable: undefined,
413
+ reason: unavailableMessage(platform),
414
+ };
415
+ }
416
+ return { supported: true, platform, backend: backend.id, executable: backend.executable };
417
+ }
418
+
419
+ /** The message explaining why no backend is available on this platform. */
420
+ export function sandboxUnavailableMessage(seams: SandboxSeams = {}): string {
421
+ return unavailableMessage(currentPlatform(seams));
422
+ }
423
+
424
+ /** True when an OS write-sandbox backend can be applied on this platform. */
425
+ export function sandboxSupported(seams: SandboxSeams = {}): boolean {
426
+ return selectedSandboxBackend(seams) !== undefined;
427
+ }
428
+
429
+ /**
430
+ * Resolve the caller's default-on, explicit-request, and opt-out policy before
431
+ * spawning. A selected backend always returns its wrapper; callers never retry
432
+ * the child directly when that wrapper exits or cannot initialize.
433
+ */
434
+ export function maybeBuildSandboxCommand(
435
+ args: SandboxCommandArgs,
436
+ request: SandboxRequest,
437
+ seams: SandboxSeams = {},
438
+ ): SandboxCommand | undefined {
439
+ if (!request.sandboxEnabled) return undefined;
440
+
441
+ const backend = selectedSandboxBackend(seams);
442
+ if (!backend) {
443
+ if (request.explicitSandbox) {
444
+ const reason = sandboxUnavailableMessage(seams);
445
+ throw new Error(request.remedy ? `${reason} ${request.remedy}` : reason);
446
+ }
447
+ return undefined;
448
+ }
449
+ return backend.buildCommand(args, seams);
450
+ }
451
+
452
+ /**
453
+ * Return the selected backend's executable and ordered argv wrapper around the
454
+ * target. The fallback preserves the pre-existing direct-call result for callers
455
+ * that bypass the request-policy helper above.
456
+ */
457
+ export function buildSandboxCommand(
458
+ args: SandboxCommandArgs,
459
+ seams: SandboxSeams = {},
460
+ ): SandboxCommand {
461
+ return (selectedSandboxBackend(seams) ?? macOSSandboxBackend).buildCommand(args, seams);
462
+ }