diff options
Diffstat (limited to 'mcp/pi/browser-gate.ts')
| -rw-r--r-- | mcp/pi/browser-gate.ts | 134 |
1 files changed, 134 insertions, 0 deletions
diff --git a/mcp/pi/browser-gate.ts b/mcp/pi/browser-gate.ts new file mode 100644 index 0000000..2ebe460 --- /dev/null +++ b/mcp/pi/browser-gate.ts @@ -0,0 +1,134 @@ +/** + * One grant per pi session for a browser MCP server. + * + * pi reaches this server through pi-mcp-adapter. pi has no permission prompts of its own, and the + * adapter's approval remembers a grant only for identical arguments - for a browser, where no two + * calls are alike, that is a prompt on nearly every call. This asks once instead, and the answer + * covers the session. + * + * It answers the adapter's approval broker for one server only: + * 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 MCP server are left to the adapter. + * + * Keep `approveTools: true` on the server in mcp.json as well. If this extension ever fails to + * load, the adapter then falls back to asking on every call, and to failing closed when headless - + * never to running unchecked. + * + * 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|<minutes>|indefinite] + * /browser revoke + * PI_BROWSER_AUTHORIZE=indefinite|<minutes> 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-mcp-adapter's MCP_TOOL_APPROVAL_REQUEST_EVENT. Named here rather than imported: this file is +// loaded from ~/.pi/agent/extensions, where the adapter's package does not resolve. +const APPROVAL_EVENT = "pi-mcp-adapter:tool-approval-request"; +const DEFAULT_MINUTES = 15; + +// Epoch ms it runs out at, or the rest of this pi process. +type Grant = number | "indefinite" | undefined; + +interface ApprovalRequest { + serverName: string; + originalToolName: string; + claim(handler: () => Promise<"allow_once" | "deny">): boolean; +} + +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<boolean> | 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<boolean> => { + 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.events.on(APPROVAL_EVENT, (data: unknown) => { + const request = data as ApprovalRequest; + if (request?.serverName !== SERVER) return; + // Claimed synchronously, as the adapter requires; the decision itself can take its time. + request.claim(async () => { + if (authorized()) return "allow_once"; + if (!ctx?.hasUI) return "deny"; + return (await ask(request.originalToolName)) ? "allow_once" : "deny"; + }); + }); + + 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|<minutes>|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"); + }, + }); +} |
