mcp/pi/browser-gate.ts (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
/**
* 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__<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 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|<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 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;
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.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|<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");
},
});
}
|