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 }