/** * Permission Gate Extension * * Blocks dangerous actions while leaving normal work free. * * Three modes (cycle with /gate or ctrl+alt+g): * - ask (default) — free by default; risky bash commands prompt * Yes/No; protected-path writes are hard-blocked. * - strict — risky bash commands are denied outright, no * prompt; protected-path writes hard-blocked. For unattended runs. * - bash-only — same bash gating as ask, but the write/edit * gate is disabled: writes/edits anywhere are allowed. * * Setting the initial mode (lowest → highest precedence): * 1. built-in default: ask * 2. global settings: ~/.pi/agent/settings.json → * { "permission-gate": { "defaultMode": "strict" } } * 3. project settings: .pi/settings.json → same key (per-project default) * 4. session-persisted mode (restored on resume/fork) * 5. CLI flag: pi --gate ask|strict|bash-only * * Bash analysis is shell-aware (see analyze.ts): commands are tokenized * with shell-quote, split on && || ;, and checked per segment with * command/argument position respected. Risky is severity-ranked: * high: rm -r, sudo, pipe-into-shell, disk tools, power ops, infra * teardown, destructive git (reset --hard, push --force, …) * medium: plain rm, sed -i, kill, mv/cp -f, chmod -R, truncate, … * Routine git (add/commit/push/pull/…) is never flagged. * * Behavior details: * - Subagents (PI_SUBAGENT_DEPTH >= 1, set by pi-subagents) have no one * to ask, so only catastrophic (high-severity) operations are * hard-blocked there. Routine-but-risky commands pass to avoid * deadlocking spawned agents. * - Non-interactive main sessions (print mode) also have no one to ask, * so `ask` mode blocks ALL risky commands outright there (same as * strict). * - Risky bash commands prompt with three choices: Allow once (re-prompts * if the same command runs again), Allow similar (session — medium * commands also approve their root, so similar variants pass for the * session; high-severity approvals are exact-command only), and Abort. * Aborting revokes a prior approval. Approval memory is in-memory only * and matches after whitespace normalization. The footer shows ·N next * to the mode while the current turn has approvals (resets each * message). * - Mode persists per session via a custom session entry. * - Footer status shows the active mode: "🛡 ask" | "🛡 strict" | "🛡 bash-only" * * Write/edit gate: protected paths are hard-blocked in all modes. */ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { isToolCallEventType } from "@earendil-works/pi-coding-agent"; import { analyzeBashCommand, commandRoots } from "./analyze"; type GateMode = "ask" | "strict" | "bash-only"; const MODE_ENTRY_TYPE = "permission-gate-mode"; function isValidMode(m: unknown): m is GateMode { return m === "ask" || m === "strict" || m === "bash-only"; } /** * Read an optional default mode from a settings.json file: * { "permission-gate": { "defaultMode": "ask" | "strict" | "bash-only" } } * Returns undefined when the file is missing, malformed, or has no valid * setting. Checked for the global agent settings and the project's * .pi/settings.json (project wins). */ function readSettingsGateMode(path: string): GateMode | undefined { try { const raw = JSON.parse(readFileSync(path, "utf8")) as { "permission-gate"?: { defaultMode?: unknown }; }; const m = raw["permission-gate"]?.defaultMode; return isValidMode(m) ? m : undefined; } catch { return undefined; } } // Subagent detection: pi-subagents injects PI_SUBAGENT_DEPTH into spawned // processes (0/unset in the main session, >= 1 in subagents). const subagentDepth = Number(process.env.PI_SUBAGENT_DEPTH ?? "0"); const isSubagent = Number.isFinite(subagentDepth) && subagentDepth >= 1; // ── Protected paths (writes always blocked, in all modes) ─────────────────── // Matched as substrings against the tool's path argument. const PROTECTED_PATH_PARTS: string[] = [ ".env", // .env, .env.local, .env.production, ... ".git/", // VCS internals "node_modules/", // dependency dirs ".ssh/", // keys ".aws/credentials", ".pi/agent/auth.json", // pi credentials "id_rsa", "id_ed25519", "id_ecdsa", "credentials.json", "secrets", ]; export default function (pi: ExtensionAPI) { let mode: GateMode = "ask"; // Dialog previews cap long commands: the confirm dialog can't scroll, so // show the first lines/chars only. The full command is always in the block // reason (viewable with ctrl+o in the transcript). const PREVIEW_LINES = 8; const PREVIEW_CHARS = 400; const previewCommand = (command: string) => { let out = command; const lines = out.split("\n"); if (lines.length > PREVIEW_LINES) { out = lines.slice(0, PREVIEW_LINES).join("\n") + `\n… (+${lines.length - PREVIEW_LINES} more lines)`; } if (out.length > PREVIEW_CHARS) { out = out.slice(0, PREVIEW_CHARS) + "…"; } return out; }; // ── Status indicator ──────────────────────────────────────────────────────── // Footer: "🛡 ask" | "🛡 strict" | "🛡 bash-only" — with "·N" appended // while the current turn has granted N approvals (resets on the next // user message). // Footer colors encode the gate mode so the state is legible peripherally: // ask = muted (normal), strict = error red (writes refused), bash-only = accent // (write gate deliberately off). Live approvals get a warning-colored badge, // which also stops the status text inheriting the previous extension's color // in merged footers (e.g. pi-powerline-footer strips trailing ANSI resets). const updateStatus = (ctx: ExtensionContext) => { if (!ctx.hasUI) return; const label = ctx.ui.theme.fg(modeColor(), `🛡 ${mode}`); const badge = approvalsThisTurn > 0 ? ` ·${approvalsThisTurn}` : ""; ctx.ui.setStatus("permission-gate", `${label}${badge ? ctx.ui.theme.fg("warning", badge) : ""}`); }; function modeColor(): "muted" | "error" | "accent" { return mode === "strict" ? "error" : mode === "bash-only" ? "accent" : "muted"; } const setMode = (next: GateMode, ctx: ExtensionContext, persist: boolean) => { mode = next; if (persist) pi.appendEntry(MODE_ENTRY_TYPE, { mode }); if (ctx.hasUI) { updateStatus(ctx); ctx.ui.notify(`Permission gate: ${mode}`, "info"); } }; // ── Mode switching ────────────────────────────────────────────────────────── const MODE_CYCLE: GateMode[] = ["ask", "strict", "bash-only"]; const nextMode = (current: GateMode) => MODE_CYCLE[(MODE_CYCLE.indexOf(current) + 1) % MODE_CYCLE.length]!; // --gate sets the initial mode for this run, overriding the // session-persisted mode. Does not persist (future sessions without the // flag fall back to the session's last mode, then ask). pi.registerFlag("gate", { description: "Initial permission gate mode: ask | strict | bash-only", type: "string", default: "", }); pi.registerCommand("gate", { description: "Cycle permission gate mode (ask → strict → bash-only → ask)", handler: async (_args, ctx) => { setMode(nextMode(mode), ctx, true); }, }); pi.registerShortcut("ctrl+alt+g", { description: "Cycle permission gate mode (ask → strict → bash-only → ask)", handler: async (ctx) => { setMode(nextMode(mode), ctx, true); }, }); // ── Session lifecycle ─────────────────────────────────────────────────────── // Turn-scoped approvals: reset whenever a new user message starts a turn. // Turn counter reset: approvals persist for the session, but the footer // count reflects only what the current request has been granted. pi.on("before_agent_start", async (event, ctx) => { approvalsThisTurn = 0; if (ctx.hasUI) updateStatus(ctx); }); pi.on("session_start", async (_event, ctx) => { // Mode resolution, lowest → highest precedence: // ask (built-in) < global settings < project settings < // session-persisted mode (resume/fork) < --gate flag const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); const fromGlobal = readSettingsGateMode(join(agentDir, "settings.json")); if (fromGlobal) mode = fromGlobal; const fromProject = readSettingsGateMode(join(ctx.cwd, ".pi", "settings.json")); if (fromProject) mode = fromProject; // Restore mode from the most recent mode entry on this session's branch. for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === MODE_ENTRY_TYPE) { const stored = (entry.data as { mode?: string } | undefined)?.mode; if (isValidMode(stored)) mode = stored; } } // --gate wins over everything for this run. (getFlag takes the // bare name — pi strips the leading dashes when storing.) const flag = pi.getFlag("gate"); if (isValidMode(flag)) { mode = flag; } if (ctx.hasUI) updateStatus(ctx); }); // ── Bash gate ─────────────────────────────────────────────────────────────── // Remember recently aborted commands so the model can't hammer retries. const recentlyAborted = new Map(); const ABORT_REMEMBER_MS = 60_000; // Session approval memory: commands approved with "Allow similar" pass // silently for the rest of the session. In-memory only (cleared on /reload // or restart). Aborting a command revokes its approval. Exact matches are // whitespace-normalized (trim + collapse spaces/newlines). const approvedSession = new Set(); // Root approvals — granted ONLY by medium-severity approvals. A later // command whose roots were all approved passes as long as the analyzer // still rates it medium; anything high-severity always prompts again. // High-severity approvals are exact-command only, so dangerous roots // (sudo, rm -rf, git push --force, …) never auto-widen. const approvedRootsSession = new Map(); // Approvals granted during the current turn — shown next to the mode in // the footer and reset on every user message, so the number reflects what // the current request has been granted, not an ever-growing tally. let approvalsThisTurn = 0; const normalizeCommand = (c: string) => c.trim().replace(/\s+/g, " "); pi.on("tool_call", async (event, ctx) => { if (!isToolCallEventType("bash", event)) return undefined; const command = event.input.command; const risk = analyzeBashCommand(command); if (!risk) return undefined; // free pass // Subagents: nobody can answer a prompt. Hard-block only catastrophic // (high-severity) operations; scoped-risky commands pass. if (isSubagent) { if (risk.severity === "high") { return { block: true, reason: `Blocked by permission gate (subagent): ${risk.reasons.join(", ")}. ` + `This is a non-interactive session — propose a safer alternative or ask the parent agent to confirm with the user.\n ${command}`, }; } return undefined; } // Main session, strict mode or no UI: block outright. if (mode === "strict" || !ctx.hasUI) { return { block: true, reason: `Blocked by permission gate (${mode === "strict" ? "strict" : "no UI"}). ` + `Matched: ${risk.reasons.join(", ")}\n ${command}`, }; } const norm = normalizeCommand(command); // Session approval memory: exact command approved earlier → pass silently. if (approvedSession.has(norm)) return undefined; // Root approval (medium-severity approvals only): all roots approved and // still rated medium → pass. A high-severity variant (rm -rf, sudo, …) // escalates past the cap and prompts again. const roots = commandRoots(command); if ( risk.severity === "medium" && roots.length > 0 && roots.every((r) => approvedRootsSession.has(r)) ) { return undefined; } // Retry-loop protection: same command aborted recently → auto-block. const lastAbort = recentlyAborted.get(normalizeCommand(command)); if (lastAbort && Date.now() - lastAbort < ABORT_REMEMBER_MS) { return { block: true, reason: "Blocked by permission gate: this exact command was aborted recently. " + "Do not retry it — propose a safer alternative instead.", }; } // Three-way decision. The title is multi-line (select renders it like // confirm's title+message) so the reasons and command stay visible. // Label reflects what the approval grants: medium commands approve their // root commands (similar variants pass this turn); high commands grant // exact-command retries only. const allowLabel = risk.severity === "medium" ? "Allow similar (session)" : "Allow exact retries (session)"; const choice = await ctx.ui.select( [ `⚠️ ${risk.severity === "high" ? "High" : "Medium"}-risk command`, "", risk.reasons.map((r) => `• ${r}`).join("\n"), "", ` ${previewCommand(command)}`, "", risk.severity === "medium" && roots.length > 0 ? `(approving also lets similar ${roots.join(" / ")} commands pass this turn)` : "(only this exact command will be remembered)", ].join("\n"), ["Allow once", allowLabel, "Abort"], ); if (choice === allowLabel) { approvedSession.add(norm); recentlyAborted.delete(norm); if (risk.severity === "medium") { for (const r of roots) approvedRootsSession.set(r, "medium"); } approvalsThisTurn++; if (ctx.hasUI) updateStatus(ctx); return undefined; } if (choice !== "Allow once") { // Abort (or dialog dismissed with Esc). if (recentlyAborted.size > 100) recentlyAborted.clear(); recentlyAborted.set(norm, Date.now()); approvedSession.delete(norm); // aborting revokes approval approvedRootsSession.delete(norm); return { block: true, reason: "Blocked by user" }; } return undefined; }); // ── Write/edit gate (disabled in bash-only mode) ────────────────────────── pi.on("tool_call", async (event, ctx) => { if (mode === "bash-only") return undefined; // writes/edits allowed everywhere if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) { return undefined; } const path = event.input.path; const isProtected = PROTECTED_PATH_PARTS.some((p) => path.includes(p)); if (!isProtected) return undefined; // free pass if (ctx.hasUI) { ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning"); } return { block: true, reason: `Path "${path}" is protected` }; }); }