/** * One grant per pi session for a browser MCP server. * * pi reaches this server through its built-in MCP support, which has no permission prompts of its * own: every MCP call goes through pi's tool pipeline, and an extension's `tool_call` handler is * the only place to stop one. This asks once, and the answer covers the session. * * It gates the tools of one server only, mcp____*, whether the model calls them directly * or a codemode script does: * authorized -> allow * not yet, interactive -> ask once: for this session, for 15 minutes, or deny * not yet, headless (-p) -> deny, unless PI_BROWSER_AUTHORIZE granted it * Calls to any other tool are left alone. * * Nothing backs this up: if this extension fails to load, browser calls run unchecked. `/browser` * answering is how to tell that it loaded. * * Install: copy this file into ~/.pi/agent/extensions/ (or list it in settings.json's * "extensions"), and set PI_BROWSER_GATE_SERVER if your server is not named my-chrome. * * /browser show the grant * /browser authorize [15m||indefinite] * /browser revoke * PI_BROWSER_AUTHORIZE=indefinite| grant every session up front, headless included */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; // The MCP server this gates, as named in mcp.json. const SERVER = process.env.PI_BROWSER_GATE_SERVER?.trim() || "my-chrome"; // pi names a server's tools mcp____, with "-" in the server name turned into "_" // (server names may hold only letters, digits, "_" and "-"). A tool whose name collides gets a // hash suffix, never a different prefix. const TOOL_PREFIX = `mcp__${SERVER.replace(/-/g, "_")}__`; const DEFAULT_MINUTES = 15; // Epoch ms it runs out at, or the rest of this pi process. type Grant = number | "indefinite" | undefined; function parseGrant(raw: string | undefined): Grant { const value = raw?.trim().toLowerCase(); if (!value) return undefined; if (value === "indefinite" || value === "session" || value === "forever") return "indefinite"; const minutes = Number(value.endsWith("m") ? value.slice(0, -1) : value); return Number.isFinite(minutes) && minutes > 0 ? Date.now() + minutes * 60_000 : undefined; } export default function browserGate(pi: ExtensionAPI): void { let grant: Grant = parseGrant(process.env.PI_BROWSER_AUTHORIZE); let ctx: ExtensionContext | undefined; let asking: Promise | undefined; const authorized = (): boolean => grant === "indefinite" || (typeof grant === "number" && grant > Date.now()); const summary = (): string => { if (grant === "indefinite") return `${SERVER}: authorized for this session`; if (typeof grant === "number" && grant > Date.now()) { return `${SERVER}: authorized for ~${Math.ceil((grant - Date.now()) / 60_000)} more minutes`; } return `${SERVER}: locked; the next call asks (or run /browser authorize)`; }; const showStatus = (): void => { ctx?.ui.setStatus("browser-gate", authorized() ? `● ${SERVER}` : undefined); }; // One question at a time: calls made in parallel wait on the same answer rather than each // raising a prompt of its own. const ask = (toolName: string): Promise => { asking ??= (async () => { try { const session = "For this session"; const timed = `For ${DEFAULT_MINUTES} minutes`; const choice = await ctx!.ui.select( `${SERVER}: let this pi session drive your browser? (first call: ${toolName})`, [session, timed, "Deny"], ); if (choice === session) grant = "indefinite"; else if (choice === timed) grant = Date.now() + DEFAULT_MINUTES * 60_000; showStatus(); return authorized(); } finally { asking = undefined; } })(); return asking; }; pi.on("tool_call", async (event, callCtx) => { if (!event.toolName.startsWith(TOOL_PREFIX)) return; ctx = callCtx; const tool = event.toolName.slice(TOOL_PREFIX.length); try { if (authorized()) return; if (ctx?.hasUI && (await ask(tool))) return; return { block: true, reason: ctx?.hasUI ? `${SERVER}: the user did not allow this pi session to drive the browser` : `${SERVER}: browser access is not authorized in a headless session (set PI_BROWSER_AUTHORIZE)`, }; } catch (error) { // Nothing else asks, so a gate that cannot decide denies rather than leaving the call to // whatever pi does with a hook that threw. return { block: true, reason: `${SERVER}: the gate could not decide (${error instanceof Error ? error.message : String(error)})`, }; } }); pi.on("session_start", async (_event, sessionCtx) => { ctx = sessionCtx; showStatus(); }); pi.registerCommand("browser", { description: `Browser access for this pi session (${SERVER}).\n /browser show the grant\n /browser authorize [15m||indefinite]\n /browser revoke`, handler: async (args, commandCtx) => { ctx = commandCtx; const [action, value] = (args ?? "").trim().split(/\s+/); if (action === "authorize") { const next = parseGrant(value || `${DEFAULT_MINUTES}m`); if (!next) { commandCtx.ui.notify("Use /browser authorize 15m, /browser authorize 45, or /browser authorize indefinite.", "warning"); return; } grant = next; } else if (action === "revoke") { grant = undefined; } else if (action) { commandCtx.ui.notify(`Unknown /browser action "${action}": use authorize or revoke.`, "warning"); return; } showStatus(); commandCtx.ui.notify(summary(), "info"); }, }); }