diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 4a6931f3..7d99c999 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -22,6 +22,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | | `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) | | `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes | +| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config | ### Custom Tools diff --git a/packages/coding-agent/examples/extensions/sandbox/.gitignore b/packages/coding-agent/examples/extensions/sandbox/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/packages/coding-agent/examples/extensions/sandbox/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/packages/coding-agent/examples/extensions/sandbox/index.ts b/packages/coding-agent/examples/extensions/sandbox/index.ts new file mode 100644 index 00000000..ba55eaf8 --- /dev/null +++ b/packages/coding-agent/examples/extensions/sandbox/index.ts @@ -0,0 +1,318 @@ +/** + * Sandbox Extension - OS-level sandboxing for bash commands + * + * Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network + * restrictions on bash commands at the OS level (sandbox-exec on macOS, + * bubblewrap on Linux). + * + * Config files (merged, project takes precedence): + * - ~/.pi/agent/sandbox.json (global) + * - /.pi/sandbox.json (project-local) + * + * Example .pi/sandbox.json: + * ```json + * { + * "enabled": true, + * "network": { + * "allowedDomains": ["github.com", "*.github.com"], + * "deniedDomains": [] + * }, + * "filesystem": { + * "denyRead": ["~/.ssh", "~/.aws"], + * "allowWrite": [".", "/tmp"], + * "denyWrite": [".env"] + * } + * } + * ``` + * + * Usage: + * - `pi -e ./sandbox` - sandbox enabled with default/config settings + * - `pi -e ./sandbox --no-sandbox` - disable sandboxing + * - `/sandbox` - show current sandbox configuration + * + * Setup: + * 1. Copy sandbox/ directory to ~/.pi/agent/extensions/ + * 2. Run `npm install` in ~/.pi/agent/extensions/sandbox/ + * + * Linux also requires: bubblewrap, socat, ripgrep + */ + +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { type BashOperations, createBashTool } from "@mariozechner/pi-coding-agent"; + +interface SandboxConfig extends SandboxRuntimeConfig { + enabled?: boolean; +} + +const DEFAULT_CONFIG: SandboxConfig = { + enabled: true, + network: { + allowedDomains: [ + "npmjs.org", + "*.npmjs.org", + "registry.npmjs.org", + "registry.yarnpkg.com", + "pypi.org", + "*.pypi.org", + "github.com", + "*.github.com", + "api.github.com", + "raw.githubusercontent.com", + ], + deniedDomains: [], + }, + filesystem: { + denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"], + allowWrite: [".", "/tmp"], + denyWrite: [".env", ".env.*", "*.pem", "*.key"], + }, +}; + +function loadConfig(cwd: string): SandboxConfig { + const projectConfigPath = join(cwd, ".pi", "sandbox.json"); + const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json"); + + let globalConfig: Partial = {}; + let projectConfig: Partial = {}; + + if (existsSync(globalConfigPath)) { + try { + globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8")); + } catch (e) { + console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`); + } + } + + if (existsSync(projectConfigPath)) { + try { + projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8")); + } catch (e) { + console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`); + } + } + + return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig); +} + +function deepMerge(base: SandboxConfig, overrides: Partial): SandboxConfig { + const result: SandboxConfig = { ...base }; + + if (overrides.enabled !== undefined) result.enabled = overrides.enabled; + if (overrides.network) { + result.network = { ...base.network, ...overrides.network }; + } + if (overrides.filesystem) { + result.filesystem = { ...base.filesystem, ...overrides.filesystem }; + } + + const extOverrides = overrides as { + ignoreViolations?: Record; + enableWeakerNestedSandbox?: boolean; + }; + const extResult = result as { ignoreViolations?: Record; enableWeakerNestedSandbox?: boolean }; + + if (extOverrides.ignoreViolations) { + extResult.ignoreViolations = extOverrides.ignoreViolations; + } + if (extOverrides.enableWeakerNestedSandbox !== undefined) { + extResult.enableWeakerNestedSandbox = extOverrides.enableWeakerNestedSandbox; + } + + return result; +} + +function createSandboxedBashOps(): BashOperations { + return { + async exec(command, cwd, { onData, signal, timeout }) { + if (!existsSync(cwd)) { + throw new Error(`Working directory does not exist: ${cwd}`); + } + + const wrappedCommand = await SandboxManager.wrapWithSandbox(command); + + return new Promise((resolve, reject) => { + const child = spawn("bash", ["-c", wrappedCommand], { + cwd, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + } + }, timeout * 1000); + } + + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + + child.on("error", (err) => { + if (timeoutHandle) clearTimeout(timeoutHandle); + reject(err); + }); + + const onAbort = () => { + if (child.pid) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + } + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + + child.on("close", (code) => { + if (timeoutHandle) clearTimeout(timeoutHandle); + signal?.removeEventListener("abort", onAbort); + + if (signal?.aborted) { + reject(new Error("aborted")); + } else if (timedOut) { + reject(new Error(`timeout:${timeout}`)); + } else { + resolve({ exitCode: code }); + } + }); + }); + }, + }; +} + +export default function (pi: ExtensionAPI) { + pi.registerFlag("no-sandbox", { + description: "Disable OS-level sandboxing for bash commands", + type: "boolean", + default: false, + }); + + const localCwd = process.cwd(); + const localBash = createBashTool(localCwd); + + let sandboxEnabled = false; + let sandboxInitialized = false; + + pi.registerTool({ + ...localBash, + label: "bash (sandboxed)", + async execute(id, params, onUpdate, ctx, signal) { + if (!sandboxEnabled || !sandboxInitialized) { + return localBash.execute(id, params, signal, onUpdate); + } + + const sandboxedBash = createBashTool(localCwd, { + operations: createSandboxedBashOps(), + }); + return sandboxedBash.execute(id, params, signal, onUpdate); + }, + }); + + pi.on("user_bash", () => { + if (!sandboxEnabled || !sandboxInitialized) return; + return { operations: createSandboxedBashOps() }; + }); + + pi.on("session_start", async (_event, ctx) => { + const noSandbox = pi.getFlag("no-sandbox") as boolean; + + if (noSandbox) { + sandboxEnabled = false; + ctx.ui.notify("Sandbox disabled via --no-sandbox", "warning"); + return; + } + + const config = loadConfig(ctx.cwd); + + if (!config.enabled) { + sandboxEnabled = false; + ctx.ui.notify("Sandbox disabled via config", "info"); + return; + } + + const platform = process.platform; + if (platform !== "darwin" && platform !== "linux") { + sandboxEnabled = false; + ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning"); + return; + } + + try { + const configExt = config as unknown as { + ignoreViolations?: Record; + enableWeakerNestedSandbox?: boolean; + }; + + await SandboxManager.initialize({ + network: config.network, + filesystem: config.filesystem, + ignoreViolations: configExt.ignoreViolations, + enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox, + }); + + sandboxEnabled = true; + sandboxInitialized = true; + + const networkCount = config.network?.allowedDomains?.length ?? 0; + const writeCount = config.filesystem?.allowWrite?.length ?? 0; + ctx.ui.setStatus( + "sandbox", + ctx.ui.theme.fg("accent", `🔒 Sandbox: ${networkCount} domains, ${writeCount} write paths`), + ); + ctx.ui.notify("Sandbox initialized", "info"); + } catch (err) { + sandboxEnabled = false; + ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error"); + } + }); + + pi.on("session_shutdown", async () => { + if (sandboxInitialized) { + try { + await SandboxManager.reset(); + } catch { + // Ignore cleanup errors + } + } + }); + + pi.registerCommand("sandbox", { + description: "Show sandbox configuration", + handler: async (_args, ctx) => { + if (!sandboxEnabled) { + ctx.ui.notify("Sandbox is disabled", "info"); + return; + } + + const config = loadConfig(ctx.cwd); + const lines = [ + "Sandbox Configuration:", + "", + "Network:", + ` Allowed: ${config.network?.allowedDomains?.join(", ") || "(none)"}`, + ` Denied: ${config.network?.deniedDomains?.join(", ") || "(none)"}`, + "", + "Filesystem:", + ` Deny Read: ${config.filesystem?.denyRead?.join(", ") || "(none)"}`, + ` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`, + ` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`, + ]; + ctx.ui.notify(lines.join("\n"), "info"); + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json new file mode 100644 index 00000000..83280d42 --- /dev/null +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -0,0 +1,92 @@ +{ + "name": "pi-extension-sandbox", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-extension-sandbox", + "version": "1.0.0", + "dependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.26" + } + }, + "node_modules/@anthropic-ai/sandbox-runtime": { + "version": "0.0.26", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz", + "integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==", + "license": "Apache-2.0", + "dependencies": { + "@pondwader/socks5-server": "^1.0.10", + "@types/lodash-es": "^4.17.12", + "commander": "^12.1.0", + "lodash-es": "^4.17.21", + "shell-quote": "^1.8.3", + "zod": "^3.24.1" + }, + "bin": { + "srt": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@pondwader/socks5-server": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", + "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/lodash-es": { + "version": "4.17.22", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", + "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", + "license": "MIT" + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json new file mode 100644 index 00000000..21e9fc5a --- /dev/null +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -0,0 +1,19 @@ +{ + "name": "pi-extension-sandbox", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "clean": "echo 'nothing to clean'", + "build": "echo 'nothing to build'", + "check": "echo 'nothing to check'" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + }, + "dependencies": { + "@anthropic-ai/sandbox-runtime": "^0.0.26" + } +}