pi-crew 0.11.35 → 0.11.38

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.
@@ -0,0 +1,224 @@
1
+ /**
2
+ * v0.11.36 OS-boundary sandbox (Linux opt-in, bubblewrap).
3
+ *
4
+ * WHAT IT IS: an optional bwrap (bubblewrap) wrapper around headless child-pi
5
+ * workers that closes the HOST-FILE hole the env layer cannot — a sandboxed
6
+ * worker sees ONLY: the project cwd (read-write — it must edit files), its
7
+ * scoped inference agent dir, explicitly handed-in temp/scratch paths, and
8
+ * the OS runtime (/usr, /etc, /dev, /proc as read-only mounts + tmpfs /tmp,
9
+ * /run). Everything else on the host — ~/.pi/auth.json, ~/.ssh, dotfiles,
10
+ * browser profiles — is simply NOT MOUNTED into the namespace. Fail-closed:
11
+ * a path we forgot to bind yields ENOENT inside the sandbox, never silent
12
+ * host access.
13
+ *
14
+ * WHAT IT IS NOT (honest boundary, pass 1):
15
+ * - Egress isolation: `PI_CREW_SANDBOX_NET=off` unshares the network stack
16
+ * entirely (nothing reachable — including the loopback inference broker),
17
+ * which is why net=off is REJECTED while the inference broker is active.
18
+ * The default `share` profile keeps host networking: a worker with a
19
+ * broker token can still open outbound connections to the internet.
20
+ * - TUI surface panes and the rpc transport are NOT sandboxed (pass 1): when
21
+ * the sandbox is enabled both branches are skipped so every worker takes
22
+ * the sandboxed headless path (fail-closed, never fail-open).
23
+ *
24
+ * FAIL-CLOSED CONTRACT: every misconfiguration throws BEFORE any spawn —
25
+ * unsupported value, non-Linux platform, missing bwrap binary, or the
26
+ * net=off + broker contradiction. A sandbox that cannot start must never
27
+ * silently degrade to an unsandboxed spawn.
28
+ */
29
+ import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:child_process";
30
+ import * as fs from "node:fs";
31
+ import * as path from "node:path";
32
+
33
+ export const SANDBOX_ENV = "PI_CREW_SANDBOX";
34
+ export const SANDBOX_NET_ENV = "PI_CREW_SANDBOX_NET";
35
+
36
+ export interface SandboxConfig {
37
+ enabled: boolean;
38
+ mode: "off" | "bwrap";
39
+ /** "share" keeps host networking (inference broker reachable); "off" unshares the net stack entirely. */
40
+ net: "share" | "off";
41
+ }
42
+
43
+ /** Resolve + VALIDATE the sandbox knobs. Throws fail-closed on any misconfiguration (never silently unsandboxed). */
44
+ export function resolveSandboxConfig(env: NodeJS.ProcessEnv, opts: { platform: NodeJS.Platform; brokerActive: boolean }): SandboxConfig {
45
+ const raw = (env[SANDBOX_ENV] ?? "").trim();
46
+ if (raw === "" || raw === "0") return { enabled: false, mode: "off", net: "share" };
47
+ if (raw !== "bwrap") {
48
+ throw new Error(
49
+ `${SANDBOX_ENV}: unsupported value '${raw}' (supported: 'bwrap') — fail-closed: refusing to spawn with an unrecognized sandbox mode`,
50
+ );
51
+ }
52
+ const netRaw = (env[SANDBOX_NET_ENV] ?? "share").trim().toLowerCase();
53
+ if (netRaw !== "share" && netRaw !== "off") {
54
+ throw new Error(
55
+ `${SANDBOX_NET_ENV}: unsupported value '${netRaw}' (supported: 'share' | 'off') — fail-closed: refusing to spawn with an unrecognized network profile`,
56
+ );
57
+ }
58
+ if (netRaw === "off" && opts.brokerActive) {
59
+ throw new Error(
60
+ `${SANDBOX_NET_ENV}=off is incompatible with the inference broker: a new network namespace has no loopback route to the 127.0.0.1 broker, so broker-token workers would be keyless AND netless. Either keep ${SANDBOX_NET_ENV}=share or unset ${SANDBOX_ENV}/PI_CREW_INFERENCE_BROKER.`,
61
+ );
62
+ }
63
+ return { enabled: true, mode: "bwrap", net: netRaw };
64
+ }
65
+
66
+ /** Binary/platform gate. Throws when a bwrap sandbox cannot be honored (fail-closed). */
67
+ export function assertSandboxAvailable(
68
+ config: SandboxConfig,
69
+ deps: {
70
+ platform: NodeJS.Platform;
71
+ spawnSync: (cmd: string, args: string[], opts?: SpawnSyncOptions) => SpawnSyncReturns<string> | { error: unknown; status: null };
72
+ } = {
73
+ platform: process.platform,
74
+ spawnSync: (cmd, args, opts) => spawnSync(cmd, args, opts) as SpawnSyncReturns<string>,
75
+ },
76
+ ): void {
77
+ if (!config.enabled) return;
78
+ if (deps.platform !== "linux") {
79
+ throw new Error(
80
+ `${SANDBOX_ENV}=bwrap is Linux-only (bubblewrap user namespaces); current platform is ${deps.platform} — fail-closed: refusing to spawn unsandboxed`,
81
+ );
82
+ }
83
+ const probe = deps.spawnSync("bwrap", ["--version"], { timeout: 5000 });
84
+ if (probe.error || probe.status !== 0) {
85
+ throw new Error(
86
+ `${SANDBOX_ENV}=bwrap requires the 'bwrap' binary (bubblewrap) on PATH — not runnable here (fail-closed: refusing to spawn unsandboxed). Install bubblewrap or unset ${SANDBOX_ENV}.`,
87
+ );
88
+ }
89
+ }
90
+
91
+ export interface SandboxWrapInput {
92
+ /** Project dir — mounted read-write (the worker's whole job is editing it). */
93
+ cwd: string;
94
+ /** "share" keeps host networking (inference broker reachable); "off" unshares the net stack entirely. */
95
+ net: "share" | "off";
96
+ /** Scoped inference agent dir (models.json only) — mounted read-write (pi may write session state under it). */
97
+ agentDir?: string;
98
+ /** Parent-side paths the worker must READ (task.md tempDir, scratchpad snapshot/restore, steering file). */
99
+ roBindPaths?: string[];
100
+ /** Parent-side paths the worker must reach read-write (inter-pi broker socket — connect() needs write permission). */
101
+ rwBindPaths?: string[];
102
+ /** Package-root detector for resource walks (injectable to keep applySandboxToSpawnSpec pure in tests). */
103
+ isPackageRoot?: (dir: string) => boolean;
104
+ }
105
+
106
+ /** Toolchain mounts a wrapped spec needs (deduped):
107
+ * - the runtime bin dir (nvm/homebrew node lives outside /usr),
108
+ * - for the entry script (.js arg 0): its dir + every ancestor `node_modules`
109
+ * (ESM/CJS resolution walks them for the SDK's external deps),
110
+ * - for every `--extension`/`--skill` value: the value + ancestor dirs up to
111
+ * and including the nearest package root (extensions import siblings
112
+ * across their package: prompt-runtime.ts reaches ../config, ../state …).
113
+ */
114
+ function toolchainBinds(spec: { command: string; args: string[] }, isPackageRoot: (dir: string) => boolean): string[] {
115
+ const binds = new Set<string>();
116
+ if (path.isAbsolute(spec.command)) binds.add(path.dirname(spec.command));
117
+ const entry = spec.args[0];
118
+ if (entry && path.isAbsolute(entry) && entry.endsWith(".js")) {
119
+ binds.add(path.dirname(entry));
120
+ for (let dir = path.dirname(path.dirname(entry)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
121
+ if (path.basename(dir) === "node_modules") binds.add(dir);
122
+ }
123
+ }
124
+ // --extension / --skill resources: bind the value + ancestors up to the
125
+ // nearest package root (extensions import across their package tree).
126
+ for (let i = 0; i < spec.args.length - 1; i++) {
127
+ const flag = spec.args[i];
128
+ if (flag !== "--extension" && flag !== "--skill") continue;
129
+ const value = spec.args[i + 1];
130
+ if (!value || !path.isAbsolute(value)) continue;
131
+ binds.add(value);
132
+ for (let dir = path.dirname(value); dir !== path.dirname(dir); dir = path.dirname(dir)) {
133
+ binds.add(dir);
134
+ if (isPackageRoot(dir)) break;
135
+ }
136
+ }
137
+ return [...binds];
138
+ }
139
+
140
+ /** Default package-root probe: a directory carrying package.json (extensions import across their package tree). */
141
+ function defaultIsPackageRoot(dir: string): boolean {
142
+ try {
143
+ return fs.statSync(path.join(dir, "package.json")).isFile();
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Wrap a spawn spec in a bwrap invocation. Unit-testable on every platform
151
+ * (inject isPackageRoot to avoid IO); ordering matters: --tmpfs /tmp precedes
152
+ * any bind under /tmp so parent temp paths surface at their REAL paths.
153
+ */
154
+ export function applySandboxToSpawnSpec(
155
+ spec: { command: string; args: string[] },
156
+ wrap: SandboxWrapInput,
157
+ ): { command: string; args: string[]; envOverrides: Record<string, string> } {
158
+ const args: string[] = [
159
+ "--unshare-all",
160
+ // net=share: re-share host networking AFTER --unshare-all (the
161
+ // inference broker lives on host loopback). net=off leaves the new
162
+ // netns fully isolated — nothing reachable, broker included.
163
+ ...(wrap.net === "share" ? ["--share-net"] : []),
164
+ "--die-with-parent",
165
+ "--new-session",
166
+ "--ro-bind",
167
+ "/usr",
168
+ "/usr",
169
+ "--ro-bind-try",
170
+ "/etc",
171
+ "/etc",
172
+ "--ro-bind-try",
173
+ "/bin",
174
+ "/bin",
175
+ "--ro-bind-try",
176
+ "/lib",
177
+ "/lib",
178
+ "--ro-bind-try",
179
+ "/lib64",
180
+ "/lib64",
181
+ "--ro-bind-try",
182
+ "/sbin",
183
+ "/sbin",
184
+ // TOOLCHAIN: the wrapped command is usually process.execPath — which on
185
+ // developer machines lives OUTSIDE /usr (nvm: ~/.nvm/.../bin/node,
186
+ // homebrew: /opt/homebrew/...). Without mounting its directory the
187
+ // sandbox cannot even exec the runtime (bwrap: execvp ENOENT — found by
188
+ // the real-smoke test on this very machine). The node bin dir is
189
+ // toolchain, not user data — same class as /usr.
190
+ //
191
+ // The pi bundle entry (spec.args[0] when an absolute .js file) brings its
192
+ // OWN tree: the bundle dir has sibling chunks/runtime files, and ESM/CJS
193
+ // resolution walks every ancestor `node_modules` for the SDK's external
194
+ // deps (jiti/yaml/...). Bind the entry DIR + each ancestor named
195
+ // node_modules, READ-ONLY — dependency trees are toolchain, not user data.
196
+ ...toolchainBinds(spec, wrap.isPackageRoot ?? defaultIsPackageRoot).flatMap((p) => ["--ro-bind-try", p, p]),
197
+ "--proc",
198
+ "/proc",
199
+ "--dev",
200
+ "/dev",
201
+ "--tmpfs",
202
+ "/tmp",
203
+ "--tmpfs",
204
+ "/run",
205
+ // Project dir read-write at its real path (mount order guarantees this
206
+ // wins over any ancestor tmpfs).
207
+ "--bind",
208
+ wrap.cwd,
209
+ wrap.cwd,
210
+ ];
211
+ if (wrap.agentDir) {
212
+ args.push("--bind", wrap.agentDir, wrap.agentDir);
213
+ }
214
+ for (const p of wrap.roBindPaths ?? []) {
215
+ args.push("--ro-bind-try", p, p);
216
+ }
217
+ for (const p of wrap.rwBindPaths ?? []) {
218
+ args.push("--bind-try", p, p);
219
+ }
220
+ args.push("--chdir", wrap.cwd, "--", spec.command, ...spec.args);
221
+ // HOME/TMPDIR point at the sandbox tmpfs: the worker's writes land in
222
+ // scratch space, and ~/.pi (auth.json!) simply does not exist inside.
223
+ return { command: "bwrap", args, envOverrides: { HOME: "/tmp", TMPDIR: "/tmp" } };
224
+ }