import { getMarkdownTheme } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Markdown } from "@earendil-works/pi-tui"; import { analyzeBash, assessAnalysis, assessPath, type PermissionPolicy, type PermissionVerdict, } from "bash-effect-analyzer"; import { hostname } from "node:os"; import * as path from "node:path"; import { compactConfirm } from "../_char_common/compact-confirm"; import { loadSettings } from "../_char_common/settings"; import { resolvesToFilesystemRoot, rootTargetCommand } from "./root-target"; interface PermissionConfig { allowedCommandPrefixes: string[]; writableDirectories: string[]; readableDirectories: string[]; yoloDisabledHosts: string[]; } function readPermissionConfig(cwd: string): PermissionConfig { const result: PermissionConfig = { allowedCommandPrefixes: [], writableDirectories: [], readableDirectories: [], yoloDisabledHosts: [], }; const settings = loadSettings(cwd); const pg = settings.permissionGate; if (pg && typeof pg === "object") { const gate = pg as Record; const stringList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; result.allowedCommandPrefixes.push(...stringList(gate.allowedCommandPrefixes)); result.writableDirectories.push(...stringList(gate.writableDirectories)); result.readableDirectories.push(...stringList(gate.readableDirectories)); result.yoloDisabledHosts.push(...stringList(gate.yoloDisabledHosts)); } return result; } function bullet(items: string[], prefix = "-"): string { return items.map((s) => `${prefix} ${s}`).join("\n"); } function formatAllowMessage(label: string, verdict: PermissionVerdict): string { return `🔓 auto-approved ${label}\n${bullet(verdict.allowReasons, " •")}`; } interface PromptDetail { heading: string; body: string; } function buildPromptMarkdown(detail: PromptDetail, verdict: PermissionVerdict): string { const sections = [detail.body, `**Concerns**\n${bullet(verdict.promptReasons)}`]; if (verdict.allowReasons.length > 0) { sections.push( `**Benign effects** (would auto-approve on their own)\n${bullet(verdict.allowReasons)}`, ); } return sections.join("\n\n"); } // Render the verdict details as a Markdown widget pinned above the editor, // then ask via the compact single-line prompt. The built-in `ctx.ui.confirm` // draws ~11 rows of chrome (borders, spacers, key hints) for what is // fundamentally a y/n question, and embedding the long body into its title // also triggers a redraw loop when the rendered prompt exceeds the terminal // height. `compactConfirm` keeps the dialog itself to a single wrapped line // and requires the user to type "yes" or "no" explicitly so a stray // keystroke can't approve a destructive operation. async function promptForApproval( ctx: ExtensionContext, detail: PromptDetail, verdict: PermissionVerdict, ): Promise { const markdown = buildPromptMarkdown(detail, verdict); ctx.ui.setWidget( "permission-gate-prompt", (_tui, _theme) => new Markdown(markdown, 1, 0, getMarkdownTheme()), ); try { return await compactConfirm(ctx, detail.heading, ""); } finally { ctx.ui.setWidget("permission-gate-prompt", undefined); } } export default function (pi: ExtensionAPI) { let yolo = false; let yoloDisabled = false; let strict = false; let strictReads = false; let allowedCommands: string[] = []; let configWritableDirs: string[] = []; let configReadableDirs: string[] = []; let lastPersistedSnapshot: string | null = null; function persistAllowedCommands() { const snapshot = JSON.stringify(allowedCommands); if (snapshot === lastPersistedSnapshot) return; pi.appendEntry("permission-gate-allowed", { commands: [...allowedCommands] }); lastPersistedSnapshot = snapshot; } const DIM = "\x1b[2m"; const RESET = "\x1b[22m"; function buildPolicy(cwd: string): PermissionPolicy { return { cwd, allowedPrefixes: allowedCommands, // /dev/null is the universal Unix bit-bucket; redirecting to it has no // filesystem effect, so it's always safe regardless of cwd. writablePaths: ["/dev/null", ...configWritableDirs], restrictReads: strictReads, readablePaths: configReadableDirs, }; } function updateStatus(ctx: { ui: { setStatus(id: string, text: string | undefined): void }; }) { if (yolo) { ctx.ui.setStatus("permission-gate", `${DIM}🔓 yolo${RESET}`); } else if (strict && strictReads) { ctx.ui.setStatus("permission-gate", `${DIM}🔒 strict + strict-reads${RESET}`); } else if (strict) { ctx.ui.setStatus("permission-gate", `${DIM}🔒 strict${RESET}`); } else if (strictReads) { ctx.ui.setStatus("permission-gate", `${DIM}🔒 strict-reads${RESET}`); } else if (allowedCommands.length > 0) { ctx.ui.setStatus( "permission-gate", `${DIM}🔓 ${allowedCommands.length} allowed command(s)${RESET}`, ); } else { ctx.ui.setStatus("permission-gate", undefined); } } pi.on("session_start", async (_event, ctx) => { const config = readPermissionConfig(ctx.cwd); allowedCommands = [...config.allowedCommandPrefixes]; configWritableDirs = config.writableDirectories.map((d) => path.resolve(ctx.cwd, d)); configReadableDirs = config.readableDirectories.map((d) => path.resolve(ctx.cwd, d)); yoloDisabled = config.yoloDisabledHosts.includes(hostname()); // Each turn_start writes a full snapshot, so only the most recent one // reflects the session's actual state - unioning all historical snapshots // would resurrect any command the user ever toggled off. let lastSnapshot: string[] | null = null; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === "permission-gate-allowed") { lastSnapshot = (entry.data as { commands?: string[] })?.commands ?? []; } } if (lastSnapshot) { for (const p of lastSnapshot) { if (!allowedCommands.includes(p)) allowedCommands.push(p); } // Seed the dedup key so we don't immediately re-append an identical // snapshot on the next turn_start. lastPersistedSnapshot = JSON.stringify(allowedCommands); } updateStatus(ctx); }); pi.on("turn_start", async () => { if (allowedCommands.length > 0) persistAllowedCommands(); }); // prettier-ignore const PERM_MODES = { yolo: { on: "🔓 yolo mode on - all permission gates disabled", off: "🔒 yolo mode off - permission gates active", toggle: () => { yolo = !yolo; if (yolo) { strict = false; strictReads = false; } return yolo; }, }, strict: { on: "🔒 strict mode on - any action requiring approval will be auto-denied", off: "🔓 strict mode off - approval prompts re-enabled", toggle: () => { strict = !strict; if (strict) yolo = false; return strict; }, }, "strict-reads": { on: "🔒 strict-reads mode on - reads confined to cwd + readableDirectories (bash commands and read/ls/grep/find tools)", off: "🔓 strict-reads mode off", toggle: () => { strictReads = !strictReads; if (strictReads) yolo = false; return strictReads; }, }, } as const satisfies Record boolean }>; type PermMode = keyof typeof PERM_MODES; function describeState(): string { const flags = [ yolo ? "yolo" : null, strict ? "strict" : null, strictReads ? "strict-reads" : null, ].filter((f): f is string => f !== null); const modeLine = flags.length === 0 ? "all modes off (interactive prompting)" : `active: ${flags.join(", ")}`; const allowLine = allowedCommands.length === 0 ? "no allowed command prefixes" : `allowed prefixes:\n${allowedCommands.map((c) => ` • ${c}`).join("\n")}`; return `${modeLine}\n${allowLine}`; } pi.registerCommand("perms", { description: "Show or toggle permission-gate modes: /perms [yolo|strict|strict-reads]", getArgumentCompletions: (prefix: string) => { const items = (Object.keys(PERM_MODES) as PermMode[]) .filter((m) => m.startsWith(prefix)) .map((m) => ({ value: m, label: m })); return items.length > 0 ? items : null; }, handler: async (args, ctx) => { const arg = args?.trim(); if (!arg) { ctx.ui.notify(describeState(), "info"); return; } if (arg === "yolo" && !yolo && yoloDisabled) { ctx.ui.notify("yolo mode is disabled on this host (permissionGate.yoloDisabledHosts)", "warning"); return; } if (!(arg in PERM_MODES)) { ctx.ui.notify( `Unknown mode "${arg}". Valid: ${Object.keys(PERM_MODES).join(", ")}`, "warning", ); return; } const mode = PERM_MODES[arg as PermMode]; const enabled = mode.toggle(); ctx.ui.notify(enabled ? mode.on : mode.off, "info"); updateStatus(ctx); }, }); pi.registerCommand("allow", { description: "Toggle a command prefix on the session allow-list - ALL effects of matching commands are auto-approved", getArgumentCompletions: (prefix: string) => { if (allowedCommands.length === 0) return null; const items = allowedCommands.map((c) => ({ value: c, label: c })); const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { const command = args?.trim(); if (!command) { if (allowedCommands.length === 0) { ctx.ui.notify("No allowed commands. Usage: /allow ", "info"); } else { ctx.ui.notify( `Allowed commands:\n${allowedCommands.map((c) => ` • ${c}`).join("\n")}`, "info", ); } return; } const existingIndex = allowedCommands.indexOf(command); if (existingIndex >= 0) { allowedCommands.splice(existingIndex, 1); ctx.ui.notify(`🔒 "${command}" is no longer auto-approved`, "info"); } else { allowedCommands.push(command); ctx.ui.notify( `🔓 Commands matching "${command}" are now auto-approved for this session`, "info", ); } persistAllowedCommands(); updateStatus(ctx); }, }); async function enforceVerdict(opts: { ctx: ExtensionContext; verdict: PermissionVerdict; allowLabel: string; deniedLine: string; promptDetail: PromptDetail; }): Promise<{ block: true; reason: string } | undefined> { const { ctx, verdict, allowLabel, deniedLine, promptDetail } = opts; if (verdict.decision === "allow") { if (ctx.hasUI) { ctx.ui.notify(formatAllowMessage(allowLabel, verdict), "info"); } return; } if (strict) { return { block: true, reason: `Denied by strict mode. Concerns:\n${bullet(verdict.promptReasons)}`, }; } if (!ctx.hasUI) { return { block: true, reason: `Cannot prompt for permission (no UI). ${deniedLine}\n${bullet(verdict.promptReasons, " •")}`, }; } const ok = await promptForApproval(ctx, promptDetail, verdict); if (!ok) { return { block: true, reason: `Denied by user. Concerns:\n${bullet(verdict.promptReasons)}`, }; } return; } pi.on("tool_call", async (event, ctx) => { if (event.toolName === "bash") { const command = (event.input as { command?: string }).command ?? ""; const analysis = await analyzeBash(command, { environment: { HOME: process.env.HOME, OLDPWD: process.env.OLDPWD, CDPATH: process.env.CDPATH, }, }); const deniedCommand = rootTargetCommand(analysis, ctx.cwd); if (deniedCommand) { return { block: true, reason: deniedCommand === "find" ? "Denied: `find` may not search filesystem root `/` because it would take too long. Search a narrower directory instead." : "Denied: `rm` may not target filesystem root `/`.", }; } if (yolo) return; const policy = buildPolicy(ctx.cwd); const verdict = assessAnalysis(analysis, policy); return enforceVerdict({ ctx, verdict, allowLabel: `\`${command}\``, deniedLine: `Denied: ${command}`, promptDetail: { heading: "Bash command", body: "```bash\n" + command + "\n```" }, }); } if (event.toolName === "find") { const rawPath = (event.input as { path?: string }).path ?? "."; if (resolvesToFilesystemRoot(ctx.cwd, rawPath)) { return { block: true, reason: "Denied: `find` may not search filesystem root `/` because it would take too long. Search a narrower directory instead.", }; } } if (event.toolName === "read") { const rawPath = (event.input as { path?: string }).path; if (rawPath) { const filename = path.basename(path.resolve(ctx.cwd, rawPath)); if (filename === ".env" || filename === ".env.local") { return { block: true, reason: `Denied: \`read\` may not access \`${filename}\` because it may contain secrets.`, }; } } } if (yolo) return; if (event.toolName === "edit" || event.toolName === "write") { return resolvePathDecision({ ctx, rawPath: (event.input as { path?: string; file_path?: string }).path ?? (event.input as { file_path?: string }).file_path, verdict: (p) => assessPath("write", p, buildPolicy(ctx.cwd)), confirmTitle: "Write outside CWD", label: `${event.toolName}`, }); } // Read-style tools are only gated in strict-reads mode — in normal // operation reads are unrestricted and the bash safe-command list does the // policy work. if ( strictReads && (event.toolName === "read" || event.toolName === "ls" || event.toolName === "grep" || event.toolName === "find") ) { const rawPath = (event.input as { path?: string }).path; // ls/grep/find without a path default to cwd, which is always allowed. if (!rawPath) return; return resolvePathDecision({ ctx, rawPath, verdict: (p) => assessPath("read", p, buildPolicy(ctx.cwd)), confirmTitle: "Read outside CWD", label: `${event.toolName}`, }); } }); async function resolvePathDecision(opts: { ctx: ExtensionContext; rawPath: string | undefined; verdict: (rawPath: string) => PermissionVerdict; confirmTitle: string; label: string; }): Promise<{ block: true; reason: string } | undefined> { const { ctx, rawPath, verdict: makeVerdict, confirmTitle, label } = opts; if (!rawPath) return; const resolved = path.resolve(ctx.cwd, rawPath); return enforceVerdict({ ctx, verdict: makeVerdict(rawPath), allowLabel: `${label} \`${rawPath}\``, deniedLine: `Denied ${label} ${rawPath}`, promptDetail: { heading: confirmTitle, body: `**Tool:** \`${label}\`\n\n**Path:** \`${rawPath}\`\n\n**Resolved:** \`${resolved}\``, }, }); } }