Commit 8b2a8fe1c019
Changed files (8)
dots
pi
agent
extensions
ai-storage
org-todos
dist
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/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}`;
+ });
}