aboutsummaryrefslogtreecommitdiff
path: root/mcp/pi/browser-gate.ts
blob: 3c0c8883c32d66e272dc3d587ce16de02787ac58 (plain)
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");
		},
	});
}