/ extensions / sandbox / index.ts
index.ts
  1  /**
  2   * Sandbox Extension - OS-level sandboxing for bash commands
  3   *
  4   * Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
  5   * restrictions on bash commands at the OS level (sandbox-exec on macOS,
  6   * bubblewrap on Linux).
  7   *
  8   * Config files (merged, project takes precedence):
  9   * - ~/.pi/agent/extensions/sandbox/sandbox.json (global)
 10   * - <cwd>/.pi/sandbox.json (project-local)
 11   *
 12   * Example .pi/sandbox.json:
 13   * ```json
 14   * {
 15   *   "enabled": true,
 16   *   "network": {
 17   *     "allowedDomains": ["github.com", "*.github.com"],
 18   *     "deniedDomains": []
 19   *   },
 20   *   "filesystem": {
 21   *     "denyRead": ["~/.ssh", "~/.aws"],
 22   *     "allowWrite": [".", "/tmp"],
 23   *     "denyWrite": [".env"]
 24   *   }
 25   * }
 26   * ```
 27   *
 28   * Usage:
 29   * - `pi -e ./sandbox` - sandbox enabled with default/config settings
 30   * - `pi -e ./sandbox --no-sandbox` - disable sandboxing
 31   * - `/sandbox` - interactive menu to toggle on/off (shows current sandbox config above the options)
 32   * - `/sandbox on` - enable sandbox
 33   * - `/sandbox off` - disable sandbox
 34   *
 35   * Setup:
 36   * 1. Copy sandbox/ directory to ~/.pi/agent/extensions/
 37   * 2. Install dependencies
 38   *    - If installed via `pi install ...` from a package root containing this extension, pi will run `npm install` for you
 39   *    - If you copied the folder manually, run `npm install` in ~/.pi/agent/extensions/sandbox/
 40   *
 41   * Linux also requires: bubblewrap, socat, ripgrep
 42   *
 43   * LLM-driven bash tool calls are sandboxed via the `tool_call` hook rather than
 44   * by re-registering the `bash` tool, so this can coexist with renderer-only bash
 45   * overrides such as pi-tool-display.
 46   */
 47  
 48  import { spawn } from "node:child_process";
 49  import { existsSync, readFileSync } from "node:fs";
 50  import { homedir } from "node:os";
 51  import { join } from "node:path";
 52  import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
 53  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
 54  import { isToolCallEventType, type BashOperations } from "@mariozechner/pi-coding-agent";
 55  import type { Component } from "@mariozechner/pi-tui";
 56  import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
 57  
 58  interface SandboxConfig extends SandboxRuntimeConfig {
 59  	enabled?: boolean;
 60  }
 61  
 62  class SandboxMenu implements Component {
 63  	private currentState: "on" | "off";
 64  	private configLines: string[];
 65  	private selectedIndex: number;
 66  	private onDone: (value: "on" | "off" | null) => void;
 67  
 68  	constructor(params: {
 69  		currentState: "on" | "off";
 70  		configLines: string[];
 71  		onDone: (value: "on" | "off" | null) => void;
 72  	}) {
 73  		this.currentState = params.currentState;
 74  		this.configLines = params.configLines;
 75  		this.selectedIndex = params.currentState === "on" ? 0 : 1;
 76  		this.onDone = params.onDone;
 77  	}
 78  
 79  	handleInput(data: string): void {
 80  		if (matchesKey(data, Key.up) || matchesKey(data, Key.left)) {
 81  			this.selectedIndex = this.selectedIndex === 0 ? 1 : 0;
 82  		} else if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || matchesKey(data, Key.tab)) {
 83  			this.selectedIndex = this.selectedIndex === 0 ? 1 : 0;
 84  		} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
 85  			this.onDone(this.selectedIndex === 0 ? "on" : "off");
 86  		} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
 87  			this.onDone(null);
 88  		}
 89  	}
 90  
 91  	render(width: number): string[] {
 92  		const lines: string[] = [];
 93  
 94  		// Config summary (legacy formatting) at the top
 95  		for (const line of this.configLines) {
 96  			if (line.length === 0) {
 97  				lines.push("");
 98  				continue;
 99  			}
100  
101  			lines.push(...wrapTextWithAnsi(line, width));
102  		}
103  
104  		lines.push("");
105  		lines.push(truncateToWidth(`Toggle sandbox (currently ${this.currentState})`, width));
106  
107  		const optionLines = ["on", "off"].map((opt, i) => {
108  			const prefix = i === this.selectedIndex ? " → " : "   ";
109  			return truncateToWidth(prefix + opt, width);
110  		});
111  		lines.push(...optionLines);
112  
113  		return lines;
114  	}
115  
116  	invalidate(): void {
117  		// No cached state
118  	}
119  }
120  
121  const DEFAULT_CONFIG: SandboxConfig = {
122  	enabled: true,
123  	network: {
124  		allowedDomains: [
125  			"npmjs.org",
126  			"*.npmjs.org",
127  			"registry.npmjs.org",
128  			"registry.yarnpkg.com",
129  			"pypi.org",
130  			"*.pypi.org",
131  			"github.com",
132  			"*.github.com",
133  			"api.github.com",
134  			"raw.githubusercontent.com",
135  		],
136  		deniedDomains: [],
137  	},
138  	filesystem: {
139  		denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
140  		allowWrite: [".", "/tmp"],
141  		denyWrite: [".env", ".env.*", "*.pem", "*.key"],
142  	},
143  };
144  
145  function loadConfig(cwd: string): SandboxConfig {
146  	const projectConfigPath = join(cwd, ".pi", "sandbox.json");
147  
148  	const preferredGlobalConfigPath = join(homedir(), ".pi", "agent", "extensions", "sandbox", "sandbox.json");
149  	const legacyGlobalConfigPaths = [
150  		join(homedir(), ".pi", "agent", "extensions", "sandbox.json"),
151  		join(homedir(), ".pi", "agent", "sandbox.json"),
152  	];
153  
154  	const globalConfigPath =
155  		(preferredGlobalConfigPath && existsSync(preferredGlobalConfigPath) && preferredGlobalConfigPath) ||
156  		legacyGlobalConfigPaths.find((p) => existsSync(p));
157  
158  	let globalConfig: Partial<SandboxConfig> = {};
159  	let projectConfig: Partial<SandboxConfig> = {};
160  
161  	if (globalConfigPath) {
162  		try {
163  			globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
164  		} catch (e) {
165  			console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
166  		}
167  	}
168  
169  	if (existsSync(projectConfigPath)) {
170  		try {
171  			projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
172  		} catch (e) {
173  			console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
174  		}
175  	}
176  
177  	return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
178  }
179  
180  function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
181  	const result: SandboxConfig = { ...base };
182  
183  	if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
184  	if (overrides.network) {
185  		result.network = { ...base.network, ...overrides.network };
186  	}
187  	if (overrides.filesystem) {
188  		result.filesystem = { ...base.filesystem, ...overrides.filesystem };
189  	}
190  
191  	const extOverrides = overrides as {
192  		ignoreViolations?: Record<string, string[]>;
193  		enableWeakerNestedSandbox?: boolean;
194  	};
195  	const extResult = result as { ignoreViolations?: Record<string, string[]>; enableWeakerNestedSandbox?: boolean };
196  
197  	if (extOverrides.ignoreViolations) {
198  		extResult.ignoreViolations = extOverrides.ignoreViolations;
199  	}
200  	if (extOverrides.enableWeakerNestedSandbox !== undefined) {
201  		extResult.enableWeakerNestedSandbox = extOverrides.enableWeakerNestedSandbox;
202  	}
203  
204  	return result;
205  }
206  
207  function createSandboxedBashOps(): BashOperations {
208  	return {
209  		async exec(command, cwd, { onData, signal, timeout }) {
210  			if (!existsSync(cwd)) {
211  				throw new Error(`Working directory does not exist: ${cwd}`);
212  			}
213  
214  			const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
215  
216  			return new Promise((resolve, reject) => {
217  				const child = spawn("bash", ["-c", wrappedCommand], {
218  					cwd,
219  					detached: true,
220  					stdio: ["ignore", "pipe", "pipe"],
221  				});
222  
223  				let timedOut = false;
224  				let timeoutHandle: NodeJS.Timeout | undefined;
225  
226  				if (timeout !== undefined && timeout > 0) {
227  					timeoutHandle = setTimeout(() => {
228  						timedOut = true;
229  						if (child.pid) {
230  							try {
231  								process.kill(-child.pid, "SIGKILL");
232  							} catch {
233  								child.kill("SIGKILL");
234  							}
235  						}
236  					}, timeout * 1000);
237  				}
238  
239  				child.stdout?.on("data", onData);
240  				child.stderr?.on("data", onData);
241  
242  				child.on("error", (err) => {
243  					if (timeoutHandle) clearTimeout(timeoutHandle);
244  					reject(err);
245  				});
246  
247  				const onAbort = () => {
248  					if (child.pid) {
249  						try {
250  							process.kill(-child.pid, "SIGKILL");
251  						} catch {
252  							child.kill("SIGKILL");
253  						}
254  					}
255  				};
256  
257  				signal?.addEventListener("abort", onAbort, { once: true });
258  
259  				child.on("close", (code) => {
260  					if (timeoutHandle) clearTimeout(timeoutHandle);
261  					signal?.removeEventListener("abort", onAbort);
262  
263  					if (signal?.aborted) {
264  						reject(new Error("aborted"));
265  					} else if (timedOut) {
266  						reject(new Error(`timeout:${timeout}`));
267  					} else {
268  						resolve({ exitCode: code });
269  					}
270  				});
271  			});
272  		},
273  	};
274  }
275  
276  export default function (pi: ExtensionAPI) {
277  	pi.registerFlag("no-sandbox", {
278  		description: "Disable OS-level sandboxing for bash commands",
279  		type: "boolean",
280  		default: false,
281  	});
282  
283  	let sandboxEnabled = false;
284  	let sandboxInitialized = false;
285  
286  	const isSandboxActive = (): boolean => sandboxEnabled && sandboxInitialized;
287  
288  	const getSandboxConfigLines = (ctx: ExtensionContext): string[] => {
289  		const config = loadConfig(ctx.cwd);
290  
291  		return [
292  			"Sandbox Configuration:",
293  			"",
294  			"Network:",
295  			`  Allowed: ${config.network?.allowedDomains?.join(", ") || "(none)"}`,
296  			`  Denied: ${config.network?.deniedDomains?.join(", ") || "(none)"}`,
297  			"",
298  			"Filesystem:",
299  			`  Deny Read: ${config.filesystem?.denyRead?.join(", ") || "(none)"}`,
300  			`  Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
301  			`  Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
302  		];
303  	};
304  
305  
306  	const enableSandbox = async (ctx: ExtensionContext): Promise<void> => {
307  		const noSandbox = pi.getFlag("no-sandbox") as boolean;
308  		if (noSandbox) {
309  			ctx.ui.notify("Sandbox disabled via --no-sandbox (restart without it to enable)", "warning");
310  			return;
311  		}
312  
313  		if (sandboxEnabled) {
314  			ctx.ui.notify("Sandbox is already enabled", "info");
315  			return;
316  		}
317  
318  		const platform = process.platform;
319  		if (platform !== "darwin" && platform !== "linux") {
320  			ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning");
321  			return;
322  		}
323  
324  		const config = loadConfig(ctx.cwd);
325  		const configExt = config as unknown as {
326  			ignoreViolations?: Record<string, string[]>;
327  			enableWeakerNestedSandbox?: boolean;
328  		};
329  
330  		try {
331  			// If we were previously initialized, reset first so changes in config are applied cleanly
332  			if (sandboxInitialized) {
333  				await SandboxManager.reset();
334  				sandboxInitialized = false;
335  			}
336  
337  			await SandboxManager.initialize({
338  				network: config.network,
339  				filesystem: config.filesystem,
340  				ignoreViolations: configExt.ignoreViolations,
341  				enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox,
342  			});
343  
344  			sandboxInitialized = true;
345  			sandboxEnabled = true;
346  			ctx.ui.setStatus("sandbox", ctx.ui.theme.fg("accent", "sandbox ✓"));
347  			ctx.ui.notify("Sandbox enabled", "info");
348  		} catch (err) {
349  			sandboxEnabled = false;
350  			ctx.ui.setStatus("sandbox", undefined);
351  			ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error");
352  		}
353  	};
354  
355  	const disableSandbox = async (ctx: ExtensionContext): Promise<void> => {
356  		if (!sandboxEnabled) {
357  			ctx.ui.notify("Sandbox is already disabled", "info");
358  			return;
359  		}
360  
361  		sandboxEnabled = false;
362  		ctx.ui.setStatus("sandbox", undefined);
363  
364  		if (sandboxInitialized) {
365  			try {
366  				await SandboxManager.reset();
367  			} catch {
368  				// Ignore cleanup errors
369  			} finally {
370  				sandboxInitialized = false;
371  			}
372  		}
373  
374  		ctx.ui.notify("Sandbox disabled", "warning");
375  	};
376  
377  	const toggleSandbox = async (ctx: ExtensionContext): Promise<void> => {
378  		if (sandboxEnabled) {
379  			await disableSandbox(ctx);
380  			return;
381  		}
382  
383  		await enableSandbox(ctx);
384  	};
385  
386  	pi.on("tool_call", async (event) => {
387  		if (!isSandboxActive() || !isToolCallEventType("bash", event)) return;
388  		event.input.command = await SandboxManager.wrapWithSandbox(event.input.command);
389  	});
390  
391  	pi.on("user_bash", () => {
392  		if (!isSandboxActive()) return;
393  		return { operations: createSandboxedBashOps() };
394  	});
395  
396  	pi.on("session_start", async (_event, ctx) => {
397  		const noSandbox = pi.getFlag("no-sandbox") as boolean;
398  
399  		if (noSandbox) {
400  			sandboxEnabled = false;
401  			ctx.ui.notify("Sandbox disabled via --no-sandbox", "warning");
402  			return;
403  		}
404  
405  		const config = loadConfig(ctx.cwd);
406  
407  		if (!config.enabled) {
408  			sandboxEnabled = false;
409  			ctx.ui.notify("Sandbox disabled via config", "info");
410  			return;
411  		}
412  
413  		const platform = process.platform;
414  		if (platform !== "darwin" && platform !== "linux") {
415  			sandboxEnabled = false;
416  			ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning");
417  			return;
418  		}
419  
420  		try {
421  			const configExt = config as unknown as {
422  				ignoreViolations?: Record<string, string[]>;
423  				enableWeakerNestedSandbox?: boolean;
424  			};
425  
426  			await SandboxManager.initialize({
427  				network: config.network,
428  				filesystem: config.filesystem,
429  				ignoreViolations: configExt.ignoreViolations,
430  				enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox,
431  			});
432  
433  			sandboxEnabled = true;
434  			sandboxInitialized = true;
435  
436  			ctx.ui.setStatus("sandbox", ctx.ui.theme.fg("accent", "sandbox ✓"));
437  		} catch (err) {
438  			sandboxEnabled = false;
439  			ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error");
440  		}
441  	});
442  
443  	pi.on("session_shutdown", async () => {
444  		if (sandboxInitialized) {
445  			try {
446  				await SandboxManager.reset();
447  			} catch {
448  				// Ignore cleanup errors
449  			}
450  		}
451  	});
452  
453  	pi.registerShortcut(Key.alt("s"), {
454  		description: "Toggle sandbox on/off",
455  		handler: async (ctx) => toggleSandbox(ctx),
456  	});
457  
458  	pi.registerCommand("sandbox", {
459  		description: "Toggle OS-level sandboxing for bash commands",
460  		handler: async (args, ctx) => {
461  			const subcommand = args?.trim().toLowerCase();
462  
463  			if (subcommand === "on") {
464  				await enableSandbox(ctx);
465  				return;
466  			}
467  
468  			if (subcommand === "off") {
469  				await disableSandbox(ctx);
470  				return;
471  			}
472  
473  			if (subcommand && subcommand.length > 0) {
474  				ctx.ui.notify("Usage: /sandbox [on|off]", "info");
475  				return;
476  			}
477  
478  			// No args: interactive 2-option menu
479  			if (!ctx.hasUI) {
480  				// No UI available (print/RPC mode). Use explicit on/off subcommands instead
481  				return;
482  			}
483  
484  			const currentState = sandboxEnabled ? "on" : "off";
485  			const choice = await ctx.ui.custom<"on" | "off" | null>((_tui, _theme, _keybindings, done) => {
486  				return new SandboxMenu({
487  					currentState,
488  					configLines: getSandboxConfigLines(ctx),
489  					onDone: done,
490  				});
491  			});
492  
493  			if (!choice) return;
494  
495  			if (choice === "on") {
496  				await enableSandbox(ctx);
497  			} else {
498  				await disableSandbox(ctx);
499  			}
500  		},
501  	});
502  }