Commit 8b2a8fe1c019

Vincent Demeester <vincent@sbr.pm>
2026-02-08 22:21:33
feat(pi): added global sandbox extension with UV intercept integration
Added sandbox extension to ~/.pi/agent/extensions/: - OS-level sandboxing via @anthropic-ai/sandbox-runtime - Network and filesystem restrictions - Integrated UV Python tool interception from disabled uv.ts - Works on macOS (sandbox-exec) and Linux (bubblewrap) Fixed custom footer to properly display extension statuses: - Changed getExtensionStatuses() handling from Object to Map - Updated footer refresh interval to 10s for responsive status display - Removed /save-session status hint from ai-storage extension Configuration via global ~/.pi/agent/sandbox.json and project overrides.
1 parent 46de761
dots/pi/agent/extensions/ai-storage/index.ts
@@ -459,7 +459,7 @@ export default function (pi: ExtensionAPI) {
 
 			await logSessionStart();
 			// Show hint
-			ctx.ui.setStatus("session", ctx.ui.theme.fg("dim", "๐Ÿ’พ /save-session"));
+			// ctx.ui.setStatus("session", ...) - removed to declutter footer
 
 			// Check for pending transcripts and recover them in background
 			await recoverPendingTranscripts(ctx);
dots/pi/agent/extensions/org-todos/dist/index.js
@@ -0,0 +1,365 @@
+// index.ts
+import { execSync } from "node:child_process";
+import { homedir } from "node:os";
+import { join } from "node:path";
+var DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
+function execEmacs(elisp) {
+  try {
+    const escaped = elisp.replace(/'/g, "'\\''");
+    const result = execSync(`emacsclient --eval '${escaped}'`, {
+      encoding: "utf-8",
+      timeout: 1e4,
+      stdio: ["pipe", "pipe", "pipe"]
+    });
+    let jsonStr = result.trim();
+    if (jsonStr.startsWith('"') && jsonStr.endsWith('"')) {
+      jsonStr = jsonStr.slice(1, -1);
+    }
+    jsonStr = jsonStr.replace(/\\"/g, '"');
+    jsonStr = jsonStr.replace(/\\\\/g, "\\");
+    return JSON.parse(jsonStr);
+  } catch (error) {
+    if (error.message?.includes("emacsclient") || error.status === 1) {
+      return {
+        success: false,
+        error: "Emacs daemon not running. Start with: emacs --daemon"
+      };
+    }
+    return {
+      success: false,
+      error: error.message || String(error)
+    };
+  }
+}
+function formatTodo(todo) {
+  const parts = [];
+  const state = todo.todo || "TODO";
+  parts.push(`[${state}]`);
+  if (todo.priority) {
+    parts.push(`[#${todo.priority}]`);
+  }
+  parts.push(todo.heading);
+  if (todo.tags && todo.tags.length > 0) {
+    parts.push(`:${todo.tags.join(":")}:`);
+  }
+  const dates = [];
+  if (todo.scheduled) {
+    dates.push(`SCHEDULED: ${todo.scheduled}`);
+  }
+  if (todo.deadline) {
+    dates.push(`DEADLINE: ${todo.deadline}`);
+  }
+  if (dates.length > 0) {
+    parts.push(`(${dates.join(", ")})`);
+  }
+  return parts.join(" ");
+}
+function org_todos_default(pi) {
+  pi.registerTool({
+    name: "org_todo",
+    label: "Org TODO",
+    description: `Manage org-mode TODOs. Actions:
+- list: List active TODOs (TODO, NEXT, STRT)
+- scheduled: Get today's scheduled items
+- upcoming: Get tasks in next N days (default 7)
+- overdue: Get overdue tasks
+- search: Search TODOs by query
+- get: Get full content of a TODO
+- done: Mark TODO as DONE
+- state: Change TODO state (TODO, NEXT, STRT, WAIT, DONE, CANX)
+- schedule: Set scheduled date
+- deadline: Set deadline date
+- priority: Set priority (1-5)
+- add: Create new TODO
+- append: Append content to TODO`,
+    parameters: {
+      type: "object",
+      properties: {
+        action: {
+          type: "string",
+          enum: [
+            "list",
+            "scheduled",
+            "upcoming",
+            "overdue",
+            "search",
+            "get",
+            "done",
+            "state",
+            "schedule",
+            "deadline",
+            "priority",
+            "add",
+            "append",
+            "sections",
+            "statistics",
+            "archive"
+          ],
+          description: "Action to perform"
+        },
+        heading: {
+          type: "string",
+          description: "TODO heading (for get, done, state, schedule, etc.)"
+        },
+        query: {
+          type: "string",
+          description: "Search query (for search action)"
+        },
+        section: {
+          type: "string",
+          description: "Section name (for add action or by-section filter)"
+        },
+        state: {
+          type: "string",
+          enum: ["TODO", "NEXT", "STRT", "WAIT", "DONE", "CANX"],
+          description: "TODO state (for state action)"
+        },
+        date: {
+          type: "string",
+          description: "Date in YYYY-MM-DD format (for schedule/deadline)"
+        },
+        days: {
+          type: "number",
+          description: "Number of days (for upcoming action, default 7)"
+        },
+        priority: {
+          type: "number",
+          description: "Priority 1-5 (1=highest)"
+        },
+        content: {
+          type: "string",
+          description: "Content to append (org-mode format)"
+        },
+        tags: {
+          type: "array",
+          items: { type: "string" },
+          description: "Tags for new TODO"
+        }
+      },
+      required: ["action"]
+    },
+    execute: async (toolCallId, params, signal, onUpdate, ctx) => {
+      const { action, heading, query, section, state, date, days, priority, content, tags } = params;
+      let elisp;
+      switch (action) {
+        case "list":
+          if (section) {
+            elisp = `(pi/org-todo-by-section "${section}")`;
+          } else {
+            elisp = "(pi/org-todo-list)";
+          }
+          break;
+        case "scheduled":
+          elisp = `(pi/org-todo-scheduled nil "${date || "today"}")`;
+          break;
+        case "upcoming":
+          elisp = `(pi/org-todo-upcoming nil ${days || 7})`;
+          break;
+        case "overdue":
+          elisp = "(pi/org-todo-overdue)";
+          break;
+        case "search":
+          if (!query) {
+            return {
+              content: [{ type: "text", text: "Error: query is required for search action" }]
+            };
+          }
+          elisp = `(pi/org-todo-search "${query.replace(/"/g, "\\\"")}")`;
+          break;
+        case "get":
+          if (!heading) {
+            return {
+              content: [{ type: "text", text: "Error: heading is required for get action" }]
+            };
+          }
+          elisp = `(pi/org-todo-get "${heading.replace(/"/g, "\\\"")}")`;
+          break;
+        case "done":
+          if (!heading) {
+            return {
+              content: [{ type: "text", text: "Error: heading is required for done action" }]
+            };
+          }
+          elisp = `(pi/org-todo-done "${heading.replace(/"/g, "\\\"")}")`;
+          break;
+        case "state":
+          if (!heading || !state) {
+            return {
+              content: [{ type: "text", text: "Error: heading and state are required for state action" }]
+            };
+          }
+          elisp = `(pi/org-todo-state "${heading.replace(/"/g, "\\\"")}" "${state}")`;
+          break;
+        case "schedule":
+          if (!heading || !date) {
+            return {
+              content: [{ type: "text", text: "Error: heading and date are required for schedule action" }]
+            };
+          }
+          elisp = `(pi/org-todo-schedule "${heading.replace(/"/g, "\\\"")}" "${date}")`;
+          break;
+        case "deadline":
+          if (!heading || !date) {
+            return {
+              content: [{ type: "text", text: "Error: heading and date are required for deadline action" }]
+            };
+          }
+          elisp = `(pi/org-todo-deadline "${heading.replace(/"/g, "\\\"")}" "${date}")`;
+          break;
+        case "priority":
+          if (!heading || priority === undefined) {
+            return {
+              content: [{ type: "text", text: "Error: heading and priority are required for priority action" }]
+            };
+          }
+          elisp = `(pi/org-todo-priority "${heading.replace(/"/g, "\\\"")}" ${priority})`;
+          break;
+        case "add":
+          if (!heading || !section) {
+            return {
+              content: [{ type: "text", text: "Error: heading and section are required for add action" }]
+            };
+          }
+          const schedArg = date ? `"${date}"` : "nil";
+          const prioArg = priority !== undefined ? priority : "nil";
+          const tagsArg = tags && tags.length > 0 ? `'(${tags.map((t) => `"${t}"`).join(" ")})` : "nil";
+          elisp = `(pi/org-todo-add "${heading.replace(/"/g, "\\\"")}" "${section.replace(/"/g, "\\\"")}" nil ${schedArg} ${prioArg} ${tagsArg})`;
+          break;
+        case "append":
+          if (!heading || !content) {
+            return {
+              content: [{ type: "text", text: "Error: heading and content are required for append action" }]
+            };
+          }
+          elisp = `(pi/org-todo-append "${heading.replace(/"/g, "\\\"")}" "${content.replace(/"/g, "\\\"").replace(/\n/g, "\\n")}")`;
+          break;
+        case "sections":
+          elisp = "(pi/org-todo-sections)";
+          break;
+        case "statistics":
+          elisp = "(pi/org-todo-statistics)";
+          break;
+        case "archive":
+          elisp = "(pi/org-todo-archive-done)";
+          break;
+        default:
+          return {
+            content: [{ type: "text", text: `Unknown action: ${action}` }]
+          };
+      }
+      const result = execEmacs(elisp);
+      if (!result.success) {
+        return {
+          content: [{ type: "text", text: `Error: ${result.error}` }]
+        };
+      }
+      let text;
+      if (Array.isArray(result.data)) {
+        if (result.data.length === 0) {
+          text = "No TODOs found.";
+        } else {
+          text = result.data.map(formatTodo).join(`
+`);
+        }
+      } else if (typeof result.data === "object") {
+        text = JSON.stringify(result.data, null, 2);
+      } else {
+        text = String(result.data);
+      }
+      return {
+        content: [{ type: "text", text }]
+      };
+    }
+  });
+  pi.registerCommand("todos", {
+    description: "Show today's tasks (scheduled + overdue + NEXT)",
+    handler: async (args, ctx) => {
+      const theme = ctx.ui.theme;
+      const scheduled = execEmacs("(pi/org-todo-scheduled)");
+      const overdue = execEmacs("(pi/org-todo-overdue)");
+      const next = execEmacs('(pi/org-todo-list nil "NEXT")');
+      if (!scheduled.success && !overdue.success && !next.success) {
+        ctx.ui.notify("Failed to fetch TODOs. Is Emacs daemon running?", "error");
+        return;
+      }
+      const lines = [];
+      lines.push(theme.bold("\uD83D\uDCCB Today's Tasks"));
+      lines.push(theme.fg("dim", "โ”€".repeat(50)));
+      if (overdue.success && overdue.data && overdue.data.length > 0) {
+        lines.push("");
+        lines.push(theme.fg("error", `โš ๏ธ  Overdue (${overdue.data.length})`));
+        for (const todo of overdue.data.slice(0, 5)) {
+          lines.push(`  ${theme.fg("error", "โ€ข")} ${formatTodo(todo)}`);
+        }
+        if (overdue.data.length > 5) {
+          lines.push(theme.fg("dim", `  ... and ${overdue.data.length - 5} more`));
+        }
+      }
+      if (scheduled.success && scheduled.data && scheduled.data.length > 0) {
+        lines.push("");
+        lines.push(theme.fg("accent", `\uD83D\uDCC5 Scheduled Today (${scheduled.data.length})`));
+        for (const todo of scheduled.data.slice(0, 5)) {
+          lines.push(`  ${theme.fg("accent", "โ€ข")} ${formatTodo(todo)}`);
+        }
+        if (scheduled.data.length > 5) {
+          lines.push(theme.fg("dim", `  ... and ${scheduled.data.length - 5} more`));
+        }
+      }
+      if (next.success && next.data && next.data.length > 0) {
+        lines.push("");
+        lines.push(theme.fg("success", `โžก๏ธ  Next Actions (${next.data.length})`));
+        for (const todo of next.data.slice(0, 5)) {
+          lines.push(`  ${theme.fg("success", "โ€ข")} ${formatTodo(todo)}`);
+        }
+        if (next.data.length > 5) {
+          lines.push(theme.fg("dim", `  ... and ${next.data.length - 5} more`));
+        }
+      }
+      if (lines.length === 2) {
+        lines.push("");
+        lines.push(theme.fg("dim", "No tasks for today. \uD83C\uDF89"));
+      }
+      ctx.ui.setWidget("todos", lines);
+      setTimeout(() => {
+        ctx.ui.setWidget("todos", undefined);
+      }, 15000);
+    }
+  });
+  pi.registerCommand("todo-search", {
+    description: "Search TODOs. Usage: /todo-search <query>",
+    handler: async (args, ctx) => {
+      const query = (args || "").trim();
+      if (!query) {
+        ctx.ui.notify("Usage: /todo-search <query>", "error");
+        return;
+      }
+      const theme = ctx.ui.theme;
+      const result = execEmacs(`(pi/org-todo-search "${query.replace(/"/g, "\\\"")}" nil t)`);
+      if (!result.success) {
+        ctx.ui.notify(`Search failed: ${result.error}`, "error");
+        return;
+      }
+      if (!result.data || result.data.length === 0) {
+        ctx.ui.notify(`No TODOs found matching "${query}"`, "info");
+        return;
+      }
+      const lines = [];
+      lines.push(theme.bold(`\uD83D\uDD0D Search: "${query}" (${result.data.length} results)`));
+      lines.push(theme.fg("dim", "โ”€".repeat(50)));
+      for (const todo of result.data.slice(0, 10)) {
+        const matchedIn = todo.matched_in === "heading" ? "" : theme.fg("dim", " (in content)");
+        lines.push(`  ${theme.fg("accent", "โ€ข")} ${formatTodo(todo)}${matchedIn}`);
+      }
+      if (result.data.length > 10) {
+        lines.push(theme.fg("dim", `  ... and ${result.data.length - 10} more`));
+      }
+      ctx.ui.setWidget("todo-search", lines);
+      setTimeout(() => {
+        ctx.ui.setWidget("todo-search", undefined);
+      }, 20000);
+    }
+  });
+}
+export {
+  org_todos_default as default
+};
dots/pi/agent/extensions/sandbox/index.ts
@@ -0,0 +1,330 @@
+/**
+ * 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 with spawn hook that combines uv intercept + sandbox
+	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 };
+		},
+		operations: sandboxEnabled && sandboxInitialized ? createSandboxedBashOps() : undefined,
+	});
+
+	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
@@ -0,0 +1,92 @@
+{
+  "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
@@ -0,0 +1,8 @@
+{
+  "name": "sandbox",
+  "version": "1.0.0",
+  "type": "module",
+  "dependencies": {
+    "@anthropic-ai/sandbox-runtime": "^0.0.34"
+  }
+}
dots/pi/agent/extensions/sandbox/README.md
@@ -0,0 +1,201 @@
+# 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
+```
+
+### 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"],
+    "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
+
+### 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/custom-footer.ts
@@ -269,10 +269,11 @@ export default function (pi: ExtensionAPI) {
 		ctx.ui.setFooter((tui, theme, footerData) => {
 			const unsub = footerData.onBranchChange(() => tui.requestRender());
 
-			// Update time every minute
+			// Update time and status every 10 seconds
+			// This also ensures extension statuses appear promptly
 			timeUpdateInterval = setInterval(() => {
 				tui.requestRender();
-			}, 60000); // 60 seconds
+			}, 10000); // 10 seconds
 
 			return {
 				dispose: () => {
@@ -322,9 +323,14 @@ export default function (pi: ExtensionAPI) {
 					
 					// Context usage: 76.2%/200k (auto)
 					const contextText = contextInfo ? theme.fg("dim", contextInfo) : "";
+					
+					// Extension statuses (from setStatus calls)
+					// getExtensionStatuses() returns ReadonlyMap<string, string>
+					const extensionStatuses = footerData.getExtensionStatuses?.() || new Map();
+					const statusTexts = Array.from(extensionStatuses.values()).filter(Boolean);
 
 					// Combine components with separators
-					// Format: 16:10  ๐Ÿ–ฅ๏ธ kyushu  ~/s/home  main  google-vertex/sonnet-4.5  ๐Ÿง  ext  R11M W313k $5.289  76.2%/200k (auto)
+					// Format: 16:10  ๐Ÿ–ฅ๏ธ kyushu  ~/s/home  main  google-vertex/sonnet-4.5  ๐Ÿง  ext  R11M W313k $5.289  76.2%/200k (auto)  [extension statuses]
 					const components = [
 						timeText,
 						`${hostIcon} ${hostText}`,
@@ -334,6 +340,7 @@ export default function (pi: ExtensionAPI) {
 						thinkingText,
 						tokenStats,
 						contextText,
+						...statusTexts, // Add extension statuses at the end
 					].filter(Boolean); // Remove empty strings
 
 					const separator = theme.fg("dim", "  ");
dots/pi/agent/extensions/uv.ts โ†’ dots/pi/agent/extensions/uv.ts.disabled
@@ -26,14 +26,17 @@ 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");
   });
 
-  pi.registerTool(bashTool);
+  // 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}`;
+  });
 }