From 1caeee6d5f777301a1f7dc83fa38c0e3a61ac704 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 4 Mar 2026 13:39:33 -0800 Subject: [PATCH 1/3] feat(pi-extension): blocklist-only bash safety + interactive plan file path Switch from allowlist+blocklist to blocklist-only for bash command gating during planning. Commands are now allowed unless they match a destructive pattern, fixing false positives that blocked web fetching (curl, wget). Preserve tools from other extensions during planning instead of clobbering with a hardcoded tool list. Capture active tools on enter, restore on exit. Add interactive plan file path selection: /plannotator accepts optional path arg or shows input dialog. New /plannotator-set-file command for mid-session path changes. Enables monorepo workflows with per-feature plan files. Closes #221 Co-Authored-By: Claude Opus 4.6 --- apps/pi-extension/README.md | 12 ++--- apps/pi-extension/index.ts | 89 +++++++++++++++++++++++++++++-------- apps/pi-extension/utils.ts | 29 +++--------- 3 files changed, 84 insertions(+), 46 deletions(-) diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index c6f17dc..22fc808 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -45,9 +45,9 @@ Start Pi in plan mode: pi --plan ``` -Or toggle it during a session with `/plannotator` or `Ctrl+Alt+P`. +Or toggle it during a session with `/plannotator` or `Ctrl+Alt+P`. The command accepts an optional file path argument (`/plannotator plans/auth.md`) or prompts you to choose one interactively. -In plan mode the agent is restricted to read-only tools. It explores your codebase, then writes a plan to `PLAN.md` using markdown checklists: +In plan mode the agent is restricted — destructive commands are blocked, writes are limited to the plan file. It explores your codebase, then writes a plan using markdown checklists: ```markdown - [ ] Add validation to the login form @@ -79,7 +79,8 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr | Command | Description | |---------|-------------| -| `/plannotator` | Toggle plan mode on/off | +| `/plannotator [path]` | Toggle plan mode. Accepts optional file path or prompts interactively | +| `/plannotator-set-file ` | Change the plan file path mid-session | | `/plannotator-status` | Show current phase, plan file, and progress | | `/plannotator-review` | Open code review UI for current changes | | `/plannotator-annotate ` | Open markdown file in annotation UI | @@ -102,8 +103,9 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr The extension manages a state machine: **idle** → **planning** → **executing** → **idle**. During **planning**: -- Tools restricted to: `read`, `bash` (read-only commands only), `grep`, `find`, `ls`, `write` (plan file only), `exit_plan_mode` -- `edit` is disabled, bash is gated to a read-only allowlist, writes only allowed to the plan file +- All tools from other extensions remain available +- Bash blocks destructive commands (rm, git push, npm install, etc.) but allows read-only and web fetching (curl, wget) +- Writes and edits restricted to the plan file only During **executing**: - Full tool access: `read`, `bash`, `edit`, `write` diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index c14892c..a50f436 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -10,7 +10,7 @@ * - /plannotator command or Ctrl+Alt+P to toggle * - --plan flag to start in planning mode * - --plan-file flag to customize the plan file path - * - Bash restricted to read-only commands during planning + * - Bash restricted (destructive commands blocked) during planning * - Write restricted to plan file only during planning * - exit_plan_mode tool with browser-based visual approval * - [DONE:n] markers for execution progress tracking @@ -26,7 +26,7 @@ import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@mariozechner/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Key } from "@mariozechner/pi-tui"; -import { isSafeCommand, markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js"; +import { isDestructiveCommand, markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js"; import { startPlanReviewServer, startReviewServer, @@ -51,10 +51,8 @@ try { // HTML not built yet — review feature will be unavailable } -// Tool sets by phase -const PLANNING_TOOLS = ["read", "bash", "grep", "find", "ls", "write", "edit", "exit_plan_mode"]; -const EXECUTION_TOOLS = ["read", "bash", "edit", "write"]; -const NORMAL_TOOLS = ["read", "bash", "edit", "write"]; +/** Extra tools to ensure are available during planning (on top of whatever is already active). */ +const PLANNING_EXTRA_TOOLS = ["grep", "find", "ls", "exit_plan_mode"]; type Phase = "idle" | "planning" | "executing"; @@ -73,6 +71,7 @@ export default function plannotator(pi: ExtensionAPI): void { let phase: Phase = "idle"; let planFilePath = "PLAN.md"; let checklistItems: ChecklistItem[] = []; + let preplanTools: string[] | null = null; // ── Flags ──────────────────────────────────────────────────────────── @@ -126,10 +125,26 @@ export default function plannotator(pi: ExtensionAPI): void { pi.appendEntry("plannotator", { phase, planFilePath }); } + /** Apply tool visibility for the current phase, preserving tools from other extensions. */ + function applyToolsForPhase(): void { + if (phase === "planning") { + const base = preplanTools ?? pi.getActiveTools(); + const toolSet = new Set(base); + for (const t of PLANNING_EXTRA_TOOLS) toolSet.add(t); + pi.setActiveTools([...toolSet]); + } else if (preplanTools) { + // Restore pre-plan tool set (removes exit_plan_mode, etc.) + pi.setActiveTools(preplanTools); + preplanTools = null; + } + // If no preplanTools (e.g. session restore to executing/idle), leave tools as-is + } + function enterPlanning(ctx: ExtensionContext): void { phase = "planning"; checklistItems = []; - pi.setActiveTools(PLANNING_TOOLS); + preplanTools = pi.getActiveTools(); + applyToolsForPhase(); updateStatus(ctx); updateWidget(ctx); persistState(); @@ -139,7 +154,7 @@ export default function plannotator(pi: ExtensionAPI): void { function exitToIdle(ctx: ExtensionContext): void { phase = "idle"; checklistItems = []; - pi.setActiveTools(NORMAL_TOOLS); + applyToolsForPhase(); updateStatus(ctx); updateWidget(ctx); persistState(); @@ -158,7 +173,24 @@ export default function plannotator(pi: ExtensionAPI): void { pi.registerCommand("plannotator", { description: "Toggle plannotator (file-based plan mode)", - handler: async (_args, ctx) => togglePlanMode(ctx), + handler: async (args, ctx) => { + if (phase !== "idle") { + exitToIdle(ctx); + return; + } + + // Accept path as argument: /plannotator plans/auth.md + let targetPath = args?.trim() || undefined; + + // No arg — prompt for file path interactively + if (!targetPath && ctx.hasUI) { + targetPath = await ctx.ui.input("Plan file path", planFilePath); + if (targetPath === undefined) return; // cancelled + } + + if (targetPath) planFilePath = targetPath; + enterPlanning(ctx); + }, }); pi.registerCommand("plannotator-status", { @@ -173,6 +205,27 @@ export default function plannotator(pi: ExtensionAPI): void { }, }); + pi.registerCommand("plannotator-set-file", { + description: "Change the plan file path", + handler: async (args, ctx) => { + let targetPath = args?.trim() || undefined; + + if (!targetPath && ctx.hasUI) { + targetPath = await ctx.ui.input("Plan file path", planFilePath); + if (targetPath === undefined) return; // cancelled + } + + if (!targetPath) { + ctx.ui.notify(`Current plan file: ${planFilePath}`, "info"); + return; + } + + planFilePath = targetPath; + persistState(); + ctx.ui.notify(`Plan file changed to: ${planFilePath}`); + }, + }); + pi.registerCommand("plannotator-review", { description: "Open interactive code review for current changes", handler: async (_args, ctx) => { @@ -266,7 +319,7 @@ export default function plannotator(pi: ExtensionAPI): void { label: "Exit Plan Mode", description: "Submit your plan for user review. " + - "Call this after drafting or revising your plan in PLAN.md. " + + "Call this after drafting or revising your plan file. " + "The user will review the plan in a visual browser UI and can approve, deny with feedback, or annotate it. " + "If denied, use the edit tool to make targeted revisions (not write), then call this again.", parameters: Type.Object({ @@ -319,7 +372,7 @@ export default function plannotator(pi: ExtensionAPI): void { // Non-interactive or no HTML: auto-approve if (!ctx.hasUI || !planHtmlContent) { phase = "executing"; - pi.setActiveTools(EXECUTION_TOOLS); + applyToolsForPhase(); persistState(); return { content: [ @@ -348,7 +401,7 @@ export default function plannotator(pi: ExtensionAPI): void { if (result.approved) { phase = "executing"; - pi.setActiveTools(EXECUTION_TOOLS); + applyToolsForPhase(); updateStatus(ctx); updateWidget(ctx); persistState(); @@ -404,10 +457,10 @@ export default function plannotator(pi: ExtensionAPI): void { if (event.toolName === "bash") { const command = event.input.command as string; - if (!isSafeCommand(command)) { + if (isDestructiveCommand(command)) { return { block: true, - reason: `Plannotator: command blocked (not in read-only allowlist).\nCommand: ${command}`, + reason: `Plannotator: destructive command blocked during planning.\nCommand: ${command}`, }; } } @@ -444,7 +497,7 @@ export default function plannotator(pi: ExtensionAPI): void { content: `[PLANNOTATOR - PLANNING PHASE] You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. The ONLY file you may write to or edit is the plan file: ${planFilePath}. -Available tools: read, bash (read-only commands only), grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), exit_plan_mode +Available tools: read, bash (destructive commands are blocked; curl/wget for web fetching is allowed), grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), exit_plan_mode ## Iterative Planning Workflow @@ -590,7 +643,7 @@ Execute each step in order. After completing a step, include [DONE:n] in your re ); phase = "idle"; checklistItems = []; - pi.setActiveTools(NORMAL_TOOLS); + applyToolsForPhase(); updateStatus(ctx); updateWidget(ctx); persistState(); @@ -657,9 +710,7 @@ Execute each step in order. After completing a step, include [DONE:n] in your re // Apply tool restrictions for current phase if (phase === "planning") { - pi.setActiveTools(PLANNING_TOOLS); - } else if (phase === "executing") { - pi.setActiveTools(EXECUTION_TOOLS); + applyToolsForPhase(); } updateStatus(ctx); diff --git a/apps/pi-extension/utils.ts b/apps/pi-extension/utils.ts index eb570b4..626656e 100644 --- a/apps/pi-extension/utils.ts +++ b/apps/pi-extension/utils.ts @@ -26,29 +26,14 @@ const DESTRUCTIVE_PATTERNS = [ /\b(vim?|nano|emacs|code|subl)\b/i, ]; -const SAFE_PATTERNS = [ - /^\s*cat\b/, /^\s*head\b/, /^\s*tail\b/, /^\s*less\b/, /^\s*more\b/, - /^\s*grep\b/, /^\s*find\b/, /^\s*ls\b/, /^\s*pwd\b/, /^\s*echo\b/, - /^\s*printf\b/, /^\s*wc\b/, /^\s*sort\b/, /^\s*uniq\b/, /^\s*diff\b/, - /^\s*file\b/, /^\s*stat\b/, /^\s*du\b/, /^\s*df\b/, /^\s*tree\b/, - /^\s*which\b/, /^\s*whereis\b/, /^\s*type\b/, /^\s*env\b/, - /^\s*printenv\b/, /^\s*uname\b/, /^\s*whoami\b/, /^\s*id\b/, - /^\s*date\b/, /^\s*cal\b/, /^\s*uptime\b/, /^\s*ps\b/, - /^\s*top\b/, /^\s*htop\b/, /^\s*free\b/, - /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i, - /^\s*git\s+ls-/i, - /^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i, - /^\s*yarn\s+(list|info|why|audit)/i, - /^\s*node\s+--version/i, /^\s*python\s+--version/i, - /^\s*curl\s/i, /^\s*wget\s+-O\s*-/i, - /^\s*jq\b/, /^\s*sed\s+-n/i, /^\s*awk\b/, - /^\s*rg\b/, /^\s*fd\b/, /^\s*bat\b/, /^\s*exa\b/, -]; +export function isDestructiveCommand(command: string): boolean { + // Strip safe fd redirects so `curl ... 2>/dev/null` and `2>&1` pass + const normalized = command + .replace(/\s+\d*>\s*\/dev\/null/g, "") + .replace(/\s+\d*>&\d+/g, "") + .replace(/\s+&>\s*\/dev\/null/g, ""); -export function isSafeCommand(command: string): boolean { - const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command)); - const isSafe = SAFE_PATTERNS.some((p) => p.test(command)); - return !isDestructive && isSafe; + return DESTRUCTIVE_PATTERNS.some((p) => p.test(normalized)); } // ── Checklist Parsing ──────────────────────────────────────────────────── From 5fe8fc8e030050b91a98f22f7fa4e231db9993a4 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 4 Mar 2026 13:46:40 -0800 Subject: [PATCH 2/3] feat(marketing): add Pi to landing page badge and video links Co-Authored-By: Claude Opus 4.6 --- apps/marketing/src/pages/index.astro | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 1bb0909..994706c 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -8,7 +8,7 @@ import Step from '../components/Step.astro';
- For Claude Code & OpenCode + For Claude Code, OpenCode & Pi
@@ -28,7 +28,7 @@ import Step from '../components/Step.astro';

Interactive Plan Review for coding agents. Mark up and refine plans visually, - share for team collaboration. Works with Claude Code and OpenCode. + share for team collaboration. Works with Claude Code, OpenCode, and Pi.

@@ -67,6 +67,15 @@ import Step from '../components/Step.astro'; > Also watch for OpenCode → + + + Also watch for Pi → +
From b572b1c651ef6a93b6cf89e3311bdf75dbc5d316 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 4 Mar 2026 14:28:26 -0800 Subject: [PATCH 3/3] refactor(pi-extension): remove bash regex gating, use prompt-based guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete DESTRUCTIVE_PATTERNS and isDestructiveCommand() from utils.ts. Remove bash interception from tool_call handler. Bash is now unrestricted during planning — the system prompt guides the agent not to run destructive commands. Write/edit file restrictions to the plan file remain enforced. Co-Authored-By: Claude Opus 4.6 --- apps/pi-extension/README.md | 2 +- apps/pi-extension/index.ts | 22 +++++++--------------- apps/pi-extension/utils.ts | 33 +-------------------------------- 3 files changed, 9 insertions(+), 48 deletions(-) diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index 22fc808..b0ab777 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -104,7 +104,7 @@ The extension manages a state machine: **idle** → **planning** → **executing During **planning**: - All tools from other extensions remain available -- Bash blocks destructive commands (rm, git push, npm install, etc.) but allows read-only and web fetching (curl, wget) +- Bash is unrestricted — the agent is guided by the system prompt not to run destructive commands - Writes and edits restricted to the plan file only During **executing**: diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index a50f436..1c8e724 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -10,7 +10,7 @@ * - /plannotator command or Ctrl+Alt+P to toggle * - --plan flag to start in planning mode * - --plan-file flag to customize the plan file path - * - Bash restricted (destructive commands blocked) during planning + * - Bash unrestricted during planning (prompt-guided) * - Write restricted to plan file only during planning * - exit_plan_mode tool with browser-based visual approval * - [DONE:n] markers for execution progress tracking @@ -26,7 +26,7 @@ import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@mariozechner/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Key } from "@mariozechner/pi-tui"; -import { isDestructiveCommand, markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js"; +import { markCompletedSteps, parseChecklist, type ChecklistItem } from "./utils.js"; import { startPlanReviewServer, startReviewServer, @@ -76,7 +76,7 @@ export default function plannotator(pi: ExtensionAPI): void { // ── Flags ──────────────────────────────────────────────────────────── pi.registerFlag("plan", { - description: "Start in plan mode (read-only exploration)", + description: "Start in plan mode (restricted exploration and planning)", type: "boolean", default: false, }); @@ -451,20 +451,10 @@ export default function plannotator(pi: ExtensionAPI): void { // ── Event Handlers ─────────────────────────────────────────────────── - // Gate writes and bash during planning + // Gate writes during planning pi.on("tool_call", async (event, ctx) => { if (phase !== "planning") return; - if (event.toolName === "bash") { - const command = event.input.command as string; - if (isDestructiveCommand(command)) { - return { - block: true, - reason: `Plannotator: destructive command blocked during planning.\nCommand: ${command}`, - }; - } - } - if (event.toolName === "write") { const targetPath = resolve(ctx.cwd, event.input.path as string); const allowedPath = resolvePlanPath(ctx.cwd); @@ -497,7 +487,9 @@ export default function plannotator(pi: ExtensionAPI): void { content: `[PLANNOTATOR - PLANNING PHASE] You are in plan mode. You MUST NOT make any changes to the codebase — no edits, no commits, no installs, no destructive commands. The ONLY file you may write to or edit is the plan file: ${planFilePath}. -Available tools: read, bash (destructive commands are blocked; curl/wget for web fetching is allowed), grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), exit_plan_mode +Available tools: read, bash, grep, find, ls, write (${planFilePath} only), edit (${planFilePath} only), exit_plan_mode + +Do not run destructive bash commands (rm, git push, npm install, etc.) — focus on reading and exploring the codebase. Web fetching (curl, wget) is fine. ## Iterative Planning Workflow diff --git a/apps/pi-extension/utils.ts b/apps/pi-extension/utils.ts index 626656e..f3f6f41 100644 --- a/apps/pi-extension/utils.ts +++ b/apps/pi-extension/utils.ts @@ -1,41 +1,10 @@ /** * Plannotator Pi extension utilities. * - * Inlined versions of bash safety checks and checklist parsing. + * Checklist parsing and progress tracking helpers. * (No access to pi-mono's plan-mode/utils at runtime.) */ -// ── Bash Safety ────────────────────────────────────────────────────────── - -const DESTRUCTIVE_PATTERNS = [ - /\brm\b/i, /\brmdir\b/i, /\bmv\b/i, /\bcp\b/i, /\bmkdir\b/i, - /\btouch\b/i, /\bchmod\b/i, /\bchown\b/i, /\bchgrp\b/i, /\bln\b/i, - /\btee\b/i, /\btruncate\b/i, /\bdd\b/i, /\bshred\b/i, - /(^|[^<])>(?!>)/, />>/, - /\bnpm\s+(install|uninstall|update|ci|link|publish)/i, - /\byarn\s+(add|remove|install|publish)/i, - /\bpnpm\s+(add|remove|install|publish)/i, - /\bpip\s+(install|uninstall)/i, - /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i, - /\bbrew\s+(install|uninstall|upgrade)/i, - /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i, - /\bsudo\b/i, /\bsu\b/i, /\bkill\b/i, /\bpkill\b/i, /\bkillall\b/i, - /\breboot\b/i, /\bshutdown\b/i, - /\bsystemctl\s+(start|stop|restart|enable|disable)/i, - /\bservice\s+\S+\s+(start|stop|restart)/i, - /\b(vim?|nano|emacs|code|subl)\b/i, -]; - -export function isDestructiveCommand(command: string): boolean { - // Strip safe fd redirects so `curl ... 2>/dev/null` and `2>&1` pass - const normalized = command - .replace(/\s+\d*>\s*\/dev\/null/g, "") - .replace(/\s+\d*>&\d+/g, "") - .replace(/\s+&>\s*\/dev\/null/g, ""); - - return DESTRUCTIVE_PATTERNS.some((p) => p.test(normalized)); -} - // ── Checklist Parsing ──────────────────────────────────────────────────── export interface ChecklistItem {