diff options
Diffstat (limited to 'mcp/pi/browser-gate.ts')
| -rw-r--r-- | mcp/pi/browser-gate.ts | 60 |
1 files changed, 33 insertions, 27 deletions
diff --git a/mcp/pi/browser-gate.ts b/mcp/pi/browser-gate.ts index 2ebe460..3c0c888 100644 --- a/mcp/pi/browser-gate.ts +++ b/mcp/pi/browser-gate.ts @@ -1,20 +1,19 @@ /** * 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. + * 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 answers the adapter's approval broker for one server only: + * It gates the tools of one server only, mcp__<server>__*, 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 MCP server are left to the adapter. + * Calls to any other tool are left alone. * - * 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. + * 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. @@ -29,20 +28,15 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a // 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"; +// pi names a server's tools mcp__<server>__<tool>, 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; -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; @@ -92,15 +86,27 @@ export default function browserGate(pi: ExtensionAPI): void { 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("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) => { |
