Commit 1be16d327ce0
Changed files (6)
.pi
dots
pi
agent
extensions
.pi/sandbox.json
@@ -1,4 +0,0 @@
-{
- "enabled": false,
- "comment": "Sandbox disabled for homelab - use --no-sandbox flag or remove this file to enable"
-}
dots/pi/agent/extensions/sandbox/index.ts
@@ -1,344 +0,0 @@
-/**
- * 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)
- * - <cwd>/.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` - sandbox enabled with default/config settings
- * - `pi --no-sandbox` - disable sandboxing
- * - `/sandbox` - show current sandbox configuration
- *
- * Linux requires: bubblewrap, socat, ripgrep
- */
-
-import { spawn } from "node:child_process";
-import { existsSync, readFileSync } from "node:fs";
-import { homedir } from "node:os";
-import { join, dirname } from "node:path";
-import { fileURLToPath } from "node:url";
-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";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-const uvInterceptPath = join(__dirname, "..", "intercepted-commands");
-
-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<SandboxConfig> = {};
- let projectConfig: Partial<SandboxConfig> = {};
-
- 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>): 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<string, string[]>;
- enableWeakerNestedSandbox?: boolean;
- };
- const extResult = result as { ignoreViolations?: Record<string, string[]>; 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();
- let sandboxEnabled = false;
- let sandboxInitialized = false;
- let uvInterceptEnabled = false;
-
- // Check if uv intercept directory exists
- if (existsSync(uvInterceptPath)) {
- uvInterceptEnabled = true;
- }
-
- // Create bash tool that dynamically uses sandboxed operations based on current state
- const bashTool = createBashTool(localCwd, {
- spawnHook: ({ command, cwd, env }) => {
- let modifiedCommand = command;
-
- // Add UV intercept to PATH if enabled
- if (uvInterceptEnabled) {
- modifiedCommand = `export PATH="${uvInterceptPath}:$PATH"\n${modifiedCommand}`;
- }
-
- return { command: modifiedCommand, cwd, env };
- },
- });
-
- // Override the bash tool to dynamically use sandbox operations
- const originalExecute = bashTool.execute;
- bashTool.execute = async function (toolCallId, params, signal, onUpdate, ctx) {
- // If sandbox is enabled, use sandboxed operations
- if (sandboxEnabled && sandboxInitialized) {
- const sandboxedTool = createBashTool(localCwd, {
- spawnHook: bashTool.spawnHook,
- operations: createSandboxedBashOps(),
- });
- return sandboxedTool.execute(toolCallId, params, signal, onUpdate, ctx);
- }
- // Otherwise use original (non-sandboxed)
- return originalExecute.call(bashTool, toolCallId, params, signal, onUpdate, ctx);
- };
-
- pi.registerTool(bashTool);
-
- // For user bash commands (! and !!), provide sandboxed operations
- 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<string, string[]>;
- 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`),
- );
-
- const messages = ["Sandbox initialized"];
- if (uvInterceptEnabled) {
- messages.push("UV interceptor active (pip/poetry/pipenv โ uv)");
- }
- ctx.ui.notify(messages.join("\n"), "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");
- },
- });
-}
dots/pi/agent/extensions/sandbox/package-lock.json
@@ -1,92 +0,0 @@
-{
- "name": "sandbox",
- "version": "1.0.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "sandbox",
- "version": "1.0.0",
- "dependencies": {
- "@anthropic-ai/sandbox-runtime": "^0.0.34"
- }
- },
- "node_modules/@anthropic-ai/sandbox-runtime": {
- "version": "0.0.34",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.34.tgz",
- "integrity": "sha512-kdzOfa1X7gB1bmkLsdMQYAE+YpvE6LO7ZjYu4HhCvxyUQl0cvU+B806QW7yp5c/m6swZuiboogtHKAfXRRTRYA==",
- "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.23",
- "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.23",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
- "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
- "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"
- }
- }
- }
-}
dots/pi/agent/extensions/sandbox/package.json
@@ -1,8 +0,0 @@
-{
- "name": "sandbox",
- "version": "1.0.0",
- "type": "module",
- "dependencies": {
- "@anthropic-ai/sandbox-runtime": "^0.0.34"
- }
-}
dots/pi/agent/extensions/sandbox/README.md
@@ -1,224 +0,0 @@
-# Sandbox Extension
-
-OS-level sandboxing for bash commands using [@anthropic-ai/sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime).
-
-## Features
-
-- **Network restrictions**: Limit which domains bash commands can access
-- **Filesystem restrictions**: Control what paths can be read/written
-- **OS-level enforcement**: Uses `sandbox-exec` (macOS) or `bubblewrap` (Linux)
-- **Configuration**: Global and project-local configs with merging
-
-## Requirements
-
-### Linux (NixOS)
-
-The following packages are required on Linux:
-
-```nix
-# In your NixOS configuration or home-manager:
-home.packages = with pkgs; [
- bubblewrap
- socat
- ripgrep
-];
-```
-
-Or install manually:
-```bash
-nix-shell -p bubblewrap socat ripgrep
-```
-
-**Additional setup:**
-
-The sandbox runtime needs a debug directory:
-```bash
-mkdir -p ~/.claude/debug
-```
-
-This directory is used by `@anthropic-ai/sandbox-runtime` for logging and temporary files.
-
-### macOS
-
-Uses built-in `sandbox-exec`, no additional packages required.
-
-## Configuration
-
-Configuration files are merged with project-local taking precedence:
-
-1. `~/.pi/agent/sandbox.json` (global defaults)
-2. `<project>/.pi/sandbox.json` (project overrides)
-
-### Example Configuration
-
-```json
-{
- "enabled": true,
- "network": {
- "allowedDomains": [
- "github.com",
- "*.github.com",
- "npmjs.org",
- "*.npmjs.org"
- ],
- "deniedDomains": []
- },
- "filesystem": {
- "denyRead": ["~/.ssh", "~/.aws", "~/.gnupg"],
- "allowWrite": [".", "/tmp", "~/.claude/debug"],
- "denyWrite": [".env", ".env.*", "*.pem", "*.key"]
- }
-}
-```
-
-### Configuration Options
-
-- `enabled` (boolean): Enable/disable sandbox globally
-- `network.allowedDomains` (string[]): Domains that bash commands can access (supports wildcards)
-- `network.deniedDomains` (string[]): Domains to explicitly block
-- `filesystem.denyRead` (string[]): Paths that cannot be read
-- `filesystem.allowWrite` (string[]): Paths that can be written to
-- `filesystem.denyWrite` (string[]): Paths that cannot be written (even in allowed write dirs)
-
-## Usage
-
-### Enable Sandbox (Default)
-
-```bash
-# Sandbox enabled with config settings
-pi
-
-# Check sandbox status
-/sandbox
-```
-
-### Disable Sandbox
-
-```bash
-# Disable via flag
-pi --no-sandbox
-
-# Or disable in project config
-echo '{"enabled": false}' > .pi/sandbox.json
-```
-
-### Commands
-
-- `/sandbox` - Show current sandbox configuration
-
-## How It Works
-
-The sandbox extension:
-
-1. Intercepts all `bash` tool calls
-2. Wraps commands with OS-level sandboxing
-3. Enforces network and filesystem restrictions
-4. Shows status in the footer
-
-**Important Limitations:**
-
-โ ๏ธ **Only `bash` commands are sandboxed.** The `read`, `write`, and `edit` tools bypass the sandbox entirely because they access files directly without going through bash.
-
-- `bash: cat ~/.ssh/config` โ โ
Sandboxed (blocked)
-- `read ~/.ssh/config` โ โ Not sandboxed (succeeds)
-
-This means the LLM can still read/write files using the built-in tools. The sandbox primarily protects against:
-- Network access from bash commands
-- Bash scripts accessing sensitive files
-- Accidental destructive bash operations
-
-For full file access control, you would need to override the `read`, `write`, and `edit` tools or use `tool_call` event interception.
-
-### Example
-
-```bash
-# This will work (allowed domain)
-curl https://api.github.com/users/octocat
-
-# This will be blocked (not in allowedDomains)
-curl https://malicious-site.com
-
-# This will work (allowed write path)
-echo "test" > /tmp/file.txt
-
-# This will be blocked (protected path)
-cat ~/.ssh/id_rsa
-```
-
-## Project-Local Overrides
-
-For projects that need to bypass sandboxing (like homelab infrastructure), create `.pi/sandbox.json`:
-
-```json
-{
- "enabled": false,
- "comment": "Sandbox disabled for this project"
-}
-```
-
-Or use the `--no-sandbox` flag when working in that project.
-
-## Security Notes
-
-โ ๏ธ **Important:**
-
-- Sandboxing is **not a complete security solution**
-- It provides defense-in-depth against accidental mistakes
-- Malicious code can potentially bypass sandbox restrictions
-- Always review code before execution
-- Use for reducing attack surface, not as primary security
-
-### What It Protects Against
-
-โ
Accidental exposure of SSH keys or credentials
-โ
Unintended network requests to unknown domains
-โ
Accidental writes to sensitive files
-โ
Reading protected configuration files
-
-### What It Doesn't Protect Against
-
-โ Malicious code specifically designed to bypass sandbox
-โ Social engineering or prompt injection
-โ Vulnerabilities in the sandbox runtime itself
-โ Actions taken before sandbox initialization
-
-## Troubleshooting
-
-### "Sandbox initialization failed"
-
-On Linux, ensure `bubblewrap`, `socat`, and `ripgrep` are installed:
-
-```bash
-# NixOS
-nix-shell -p bubblewrap socat ripgrep
-
-# Verify installation
-which bwrap socat rg
-```
-
-### Commands Failing Unexpectedly
-
-Check if the command needs network or filesystem access that's blocked:
-
-```bash
-# Show current config
-/sandbox
-
-# Temporarily disable
-pi --no-sandbox
-```
-
-Add required domains/paths to your config file.
-
-### Performance Impact
-
-The sandbox has minimal performance overhead:
-- Network: DNS resolution and connection setup only
-- Filesystem: Additional syscall checks
-- Typically <100ms overhead per command
-
-## See Also
-
-- [@anthropic-ai/sandbox-runtime](https://www.npmjs.com/package/@anthropic-ai/sandbox-runtime)
-- [Pi Extensions Documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
-- [bubblewrap](https://github.com/containers/bubblewrap)
dots/pi/agent/extensions/uv.ts.disabled โ dots/pi/agent/extensions/uv.ts
@@ -26,17 +26,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const interceptedCommandsPath = join(__dirname, "intercepted-commands");
export default function (pi: ExtensionAPI) {
+ const cwd = process.cwd();
+ const bashTool = createBashTool(cwd, {
+ commandPrefix: `export PATH="${interceptedCommandsPath}:$PATH"`,
+ });
+
pi.on("session_start", (_event, ctx) => {
ctx.ui.notify("UV interceptor active (pip/poetry/pipenv/virtualenv/conda โ uv)", "info");
});
- // Don't override bash tool - instead, use tool_call event to intercept and add PATH
- // This allows other extensions (like sandbox) to also modify bash behavior
- pi.on("tool_call", async (event, ctx) => {
- if (event.toolName !== "bash") return;
-
- // Prepend PATH modification to the command
- const originalCommand = event.input.command;
- event.input.command = `export PATH="${interceptedCommandsPath}:$PATH"\n${originalCommand}`;
- });
+ pi.registerTool(bashTool);
}