@melaya/runner 1.0.118 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ // packages/runner/src/browserAuthz.ts
2
+ //
3
+ // Melaya Browser, Phase 1 (plan Sections 6 and 10): the RUNNER-SIDE second
4
+ // gate. The server is the first gate; this module re-enforces, immediately
5
+ // before every consequential facade operation, the origin / scheme /
6
+ // private-network policy and the grant's effect ceiling. It is wired in at
7
+ // THREE boundaries, not just tool arguments:
8
+ //
9
+ // 1. BrowserContext routing (context.route / page.route): every HTTP/S
10
+ // request, redirect, subframe, popup document load, and download
11
+ // attempt is checked against the policy. Arg-only checks are
12
+ // bypassable (a page can redirect, open popups, or fetch cross-origin);
13
+ // routing is not.
14
+ // 2. CDP target creation (new pages / popups observed on the context):
15
+ // a page whose destination URL is denied is closed before the agent
16
+ // can observe or act on it.
17
+ // 3. WebSocket interception + WebRTC neutralisation (plan Section 10,
18
+ // egress extension):
19
+ // WebSocket: Playwright's `page.routeWebSocket(...)` intercepts
20
+ // ws:// and wss:// upgrade handshakes at the Playwright layer before
21
+ // the browser establishes the connection. Any ws/wss URL that fails
22
+ // the same origin / private-network policy check is aborted. This
23
+ // prevents page JS from reaching ws://169.254.169.254 or any other
24
+ // forbidden endpoint via a WebSocket.
25
+ // WebRTC: RTCPeerConnection is disabled via a page-init script that
26
+ // removes RTCPeerConnection, RTCSessionDescription, and
27
+ // RTCIceCandidate from the window and forces WebRTC IP handling
28
+ // mode via the CDP Network domain (disableWebRtc). This prevents
29
+ // data-channel exfiltration and IP leakage even when network*
30
+ // rules would otherwise allow them.
31
+ // COVERAGE NOTE: routeWebSocket is available in Playwright >= 1.48.
32
+ // If the pinned version is older, WebSocket blocking degrades to a
33
+ // best-effort page-init script that overrides window.WebSocket.
34
+ // WebRTC neutralisation is purely init-script-based and works on
35
+ // all Playwright versions.
36
+ //
37
+ // Default-deny (plan Section 10): localhost and loopback, RFC1918 and
38
+ // CGNAT, link-local, cloud metadata IPs, unspecified addresses, unexpected
39
+ // ports, DNS rebinding (every resolved A/AAAA record is re-checked, not
40
+ // just the hostname literal), file: / data: / javascript: / blob: /
41
+ // about: (except about:blank), extension and browser-internal pages.
42
+ // Enterprise intranet targets are reachable only when an origin scope
43
+ // explicitly names them (org policy is expressed through originScopes).
44
+ import { lookup as dnsLookup } from "node:dns/promises";
45
+ import { isIP } from "node:net";
46
+ import { BROWSER_EFFECT_RANK } from "./browserGrantVerify.js";
47
+ export const deny = (code, message) => ({
48
+ allowed: false,
49
+ code,
50
+ message,
51
+ });
52
+ const ALLOW = { allowed: true };
53
+ // ---------------------------------------------------------------------
54
+ // Scheme policy
55
+ // ---------------------------------------------------------------------
56
+ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
57
+ /** Schemes that are never navigable by the agent, listed for clearer
58
+ * error messages (everything not in ALLOWED_SCHEMES denies anyway). */
59
+ const BROWSER_INTERNAL_SCHEMES = new Set([
60
+ "chrome:", "edge:", "brave:", "about:", "chrome-extension:",
61
+ "moz-extension:", "extension:", "devtools:", "view-source:",
62
+ "chrome-untrusted:", "chrome-search:",
63
+ ]);
64
+ // ---------------------------------------------------------------------
65
+ // Address classification
66
+ // ---------------------------------------------------------------------
67
+ /** Cloud metadata endpoints (AWS/GCP/Azure 169.254.169.254 is also
68
+ * link-local, but keep the explicit list for non-link-local providers). */
69
+ const METADATA_HOSTS = new Set([
70
+ "169.254.169.254", // AWS / GCP / Azure IMDS
71
+ "fd00:ec2::254", // AWS IMDSv6
72
+ "100.100.100.200", // Alibaba Cloud
73
+ "192.0.0.192", // Oracle Cloud legacy
74
+ "metadata.google.internal",
75
+ "metadata.goog",
76
+ ]);
77
+ function ipv4ToInt(ip) {
78
+ const p = ip.split(".").map(Number);
79
+ return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0;
80
+ }
81
+ function inCidr4(ip, base, bits) {
82
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
83
+ return (ip & mask) === (ipv4ToInt(base) & mask);
84
+ }
85
+ /** True when the literal IP is loopback, private, link-local, CGNAT,
86
+ * unspecified, broadcast, or a metadata address. Conservative: any
87
+ * parse failure classifies as forbidden (fail closed). */
88
+ export function isForbiddenIp(ip) {
89
+ const family = isIP(ip);
90
+ if (family === 4) {
91
+ const n = ipv4ToInt(ip);
92
+ return (inCidr4(n, "127.0.0.0", 8) || // loopback
93
+ inCidr4(n, "10.0.0.0", 8) || // RFC1918
94
+ inCidr4(n, "172.16.0.0", 12) || // RFC1918
95
+ inCidr4(n, "192.168.0.0", 16) || // RFC1918
96
+ inCidr4(n, "169.254.0.0", 16) || // link-local (incl. IMDS)
97
+ inCidr4(n, "100.64.0.0", 10) || // CGNAT
98
+ inCidr4(n, "0.0.0.0", 8) || // unspecified
99
+ inCidr4(n, "192.0.0.0", 24) || // IETF protocol assignments
100
+ inCidr4(n, "198.18.0.0", 15) || // benchmarking
101
+ n === 0xffffffff // broadcast
102
+ );
103
+ }
104
+ if (family === 6) {
105
+ const low = ip.toLowerCase();
106
+ // Normalize a v4-mapped literal (::ffff:1.2.3.4) to its v4 rules.
107
+ const v4m = low.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
108
+ if (v4m)
109
+ return isForbiddenIp(v4m[1]);
110
+ return (low === "::" || low === "::1" ||
111
+ low.startsWith("fe80:") || // link-local
112
+ low.startsWith("fc") || low.startsWith("fd") || // ULA
113
+ METADATA_HOSTS.has(low));
114
+ }
115
+ // Not an IP literal at all — caller handles hostnames separately.
116
+ return false;
117
+ }
118
+ const LOCALHOST_NAMES = new Set(["localhost", "localhost.localdomain", "ip6-localhost"]);
119
+ function isLocalhostName(host) {
120
+ const h = host.toLowerCase().replace(/\.$/, "");
121
+ return LOCALHOST_NAMES.has(h) || h.endsWith(".localhost") || h.endsWith(".local");
122
+ }
123
+ export function parseOriginScope(raw) {
124
+ if (raw === "*") {
125
+ return { raw, scheme: "", wildcardHost: true, host: "", port: -1, any: true };
126
+ }
127
+ const m = raw.match(/^(https?):\/\/(\*\.)?([^/:*\s]+)(?::(\d+))?$/i);
128
+ if (!m)
129
+ return null;
130
+ const scheme = `${m[1].toLowerCase()}:`;
131
+ const port = m[4] ? Number(m[4]) : scheme === "https:" ? 443 : 80;
132
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
133
+ return null;
134
+ return {
135
+ raw,
136
+ scheme,
137
+ wildcardHost: Boolean(m[2]),
138
+ host: m[3].toLowerCase(),
139
+ port,
140
+ any: false,
141
+ };
142
+ }
143
+ export function buildOriginPolicy(originScopes) {
144
+ const scopes = [];
145
+ for (const raw of originScopes) {
146
+ const p = parseOriginScope(raw);
147
+ if (p)
148
+ scopes.push(p);
149
+ // Unparseable scopes are DROPPED, never widened (fail closed).
150
+ }
151
+ return { scopes, hasScopes: scopes.length > 0 };
152
+ }
153
+ function urlPort(u) {
154
+ if (u.port)
155
+ return Number(u.port);
156
+ return u.protocol === "https:" ? 443 : 80;
157
+ }
158
+ /** Standard web ports plus common dev-server ports are still gated by
159
+ * scopes; a scope with an explicit port is required for ANY port other
160
+ * than the scheme default (unexpected-port rule, plan Section 10). */
161
+ function portAllowed(u, scope) {
162
+ const p = urlPort(u);
163
+ if (scope.any)
164
+ return p === 443 || p === 80; // "*" never grants odd ports
165
+ return p === scope.port;
166
+ }
167
+ function hostMatches(host, scope) {
168
+ const h = host.toLowerCase().replace(/\.$/, "");
169
+ if (scope.any)
170
+ return true;
171
+ if (scope.wildcardHost)
172
+ return h !== scope.host && h.endsWith(`.${scope.host}`);
173
+ return h === scope.host;
174
+ }
175
+ // ---------------------------------------------------------------------
176
+ // URL evaluation
177
+ // ---------------------------------------------------------------------
178
+ /** Synchronous structural checks: scheme, browser pages, localhost names,
179
+ * IP-literal private ranges, metadata hosts, origin-scope + port match.
180
+ * Does NOT resolve DNS — call evaluateUrlResolved for the full gate. */
181
+ export function evaluateUrlSync(rawUrl, policy) {
182
+ let u;
183
+ try {
184
+ u = new URL(rawUrl);
185
+ }
186
+ catch {
187
+ return deny("scheme_forbidden", `not a valid absolute URL: ${truncate(rawUrl)}`);
188
+ }
189
+ if (u.protocol === "about:" && rawUrl === "about:blank")
190
+ return ALLOW; // inert
191
+ if (BROWSER_INTERNAL_SCHEMES.has(u.protocol)) {
192
+ return deny("browser_internal_page", `browser-internal page '${u.protocol}' is not navigable by the agent`);
193
+ }
194
+ if (!ALLOWED_SCHEMES.has(u.protocol)) {
195
+ return deny("scheme_forbidden", `scheme '${u.protocol}' is denied (only http/https)`);
196
+ }
197
+ const host = u.hostname.replace(/^\[|\]$/g, "");
198
+ if (isLocalhostName(host)) {
199
+ return deny("private_network", `localhost destination '${host}' is denied by default`);
200
+ }
201
+ if (METADATA_HOSTS.has(host.toLowerCase())) {
202
+ return deny("metadata_endpoint", `cloud metadata endpoint '${host}' is denied`);
203
+ }
204
+ if (isIP(host) && isForbiddenIp(host)) {
205
+ return deny("private_network", `private/link-local/metadata address '${host}' is denied`);
206
+ }
207
+ if (!policy.hasScopes) {
208
+ return deny("origin_not_in_scope", "grant carries no parseable origin scopes; denying all origins");
209
+ }
210
+ const matched = policy.scopes.find((s) => (s.any || s.scheme === u.protocol) && hostMatches(host, s));
211
+ if (!matched) {
212
+ return deny("origin_not_in_scope", `origin '${u.protocol}//${host}' is not in the grant's origin scopes`);
213
+ }
214
+ if (!portAllowed(u, matched)) {
215
+ return deny("port_forbidden", `port ${urlPort(u)} is not allowed for scope '${matched.raw}'`);
216
+ }
217
+ return ALLOW;
218
+ }
219
+ /** Full gate: structural checks PLUS DNS-rebind protection. Every
220
+ * resolved address for the hostname must be public; one private record
221
+ * denies the whole navigation. Resolution failure denies (fail closed).
222
+ * NOTE: a TOCTOU window between this lookup and Chromium's own lookup
223
+ * remains (documented residual risk); the request-level route handler
224
+ * re-runs this on every request which keeps the window per-request
225
+ * rather than per-session. */
226
+ export async function evaluateUrlResolved(rawUrl, policy) {
227
+ const structural = evaluateUrlSync(rawUrl, policy);
228
+ if (!structural.allowed)
229
+ return structural;
230
+ let host;
231
+ try {
232
+ host = new URL(rawUrl).hostname.replace(/^\[|\]$/g, "");
233
+ }
234
+ catch {
235
+ return deny("scheme_forbidden", "unparseable URL");
236
+ }
237
+ if (rawUrl === "about:blank")
238
+ return ALLOW;
239
+ if (isIP(host))
240
+ return ALLOW; // literal already vetted structurally
241
+ try {
242
+ const records = await dnsLookup(host, { all: true, verbatim: true });
243
+ if (!records.length)
244
+ return deny("dns_rebind", `hostname '${host}' resolved to no addresses`);
245
+ for (const r of records) {
246
+ if (isForbiddenIp(r.address)) {
247
+ return deny("dns_rebind", `hostname '${host}' resolves to forbidden address ${r.address}`);
248
+ }
249
+ }
250
+ return ALLOW;
251
+ }
252
+ catch (e) {
253
+ return deny("dns_rebind", `DNS resolution failed for '${host}': ${e?.message || e} (fail closed)`);
254
+ }
255
+ }
256
+ // ---------------------------------------------------------------------
257
+ // Effect ceiling (grant second gate)
258
+ // ---------------------------------------------------------------------
259
+ /** An operation's declared effect must (a) be inside the grant's
260
+ * actionScopes and (b) not exceed the effect ceiling. Unknown effect
261
+ * strings deny. */
262
+ export function checkEffect(effect, grant) {
263
+ if (!(effect in BROWSER_EFFECT_RANK)) {
264
+ return deny("effect_not_granted", `unknown effect class '${effect}'`);
265
+ }
266
+ const e = effect;
267
+ if (BROWSER_EFFECT_RANK[e] > BROWSER_EFFECT_RANK[grant.effectCeiling]) {
268
+ return deny("effect_over_ceiling", `effect '${e}' exceeds grant ceiling '${grant.effectCeiling}'`);
269
+ }
270
+ if (!grant.actionScopes.includes(e)) {
271
+ return deny("effect_not_granted", `effect '${e}' is not in the grant's actionScopes`);
272
+ }
273
+ return ALLOW;
274
+ }
275
+ // ---------------------------------------------------------------------
276
+ // WebSocket interception (plan Section 10 egress extension)
277
+ // ---------------------------------------------------------------------
278
+ /** Intercept a WebSocket handshake on a page and close it if the
279
+ * destination URL is forbidden. Uses Playwright's routeWebSocket API
280
+ * (available from Playwright 1.48). On older versions the call is a
281
+ * no-op and the init-script fallback (installed in installPageGuards)
282
+ * blocks the connection at the JS layer instead.
283
+ *
284
+ * ws:// and wss:// URLs are evaluated with the same origin / private-
285
+ * network / metadata checks as HTTP requests. Forbidden connections are
286
+ * closed before the server handshake completes. */
287
+ async function interceptWebSockets(page, policy, hooks) {
288
+ try {
289
+ if (typeof page["routeWebSocket"] !== "function")
290
+ return;
291
+ await page.routeWebSocket("**", (ws) => {
292
+ const wsAny = ws;
293
+ const url = wsAny.url();
294
+ // Evaluate synchronously — the WS URL is known at handshake time.
295
+ // Rewrite ws:// -> http:// and wss:// -> https:// so the URL parser
296
+ // and the origin-scope matcher treat them identically to HTTP(S).
297
+ const httpUrl = url.replace(/^wss?:\/\//, (m) => (m === "wss://" ? "https://" : "http://"));
298
+ const d = evaluateUrlSync(httpUrl, policy);
299
+ if (d.allowed) {
300
+ wsAny.connect?.();
301
+ }
302
+ else {
303
+ hooks.onViolation({ url, code: d.code, message: d.message, surface: "websocket_intercept" });
304
+ wsAny.close();
305
+ }
306
+ });
307
+ }
308
+ catch {
309
+ // routeWebSocket unavailable on this Playwright build; init-script
310
+ // fallback covers window.WebSocket in installPageGuards below.
311
+ }
312
+ }
313
+ // ---------------------------------------------------------------------
314
+ // Page init scripts (WebSocket + WebRTC neutralisation)
315
+ // ---------------------------------------------------------------------
316
+ /** Init script injected into EVERY new document on governed pages.
317
+ *
318
+ * Covers two egress surfaces that bypass the context.route/page.route
319
+ * handler (which only sees HTTP(S) requests):
320
+ *
321
+ * 1. WebSocket fallback: overrides window.WebSocket with a wrapper that
322
+ * blocks ws:// URLs whose hostname matches any forbidden address. This
323
+ * runs BEFORE page JS, so even inline scripts cannot open a raw
324
+ * WebSocket to 169.254.169.254 before the routeWebSocket intercept
325
+ * fires. The list of forbidden prefixes mirrors isForbiddenIp and the
326
+ * private-network ranges in browserAuthz. Allowed wss?:// connections
327
+ * still use the native WebSocket (the wrapper delegates).
328
+ *
329
+ * 2. WebRTC neutralisation: removes RTCPeerConnection, RTCSessionDescription,
330
+ * and RTCIceCandidate from the window object so page JS cannot create a
331
+ * peer connection at all. This prevents data-channel exfiltration and
332
+ * IP leakage via STUN/TURN. Combined with the CDP Network.enable /
333
+ * setRTCConfiguration override (applied separately via CDP below), this
334
+ * provides layered defence. */
335
+ const PAGE_GUARD_INIT_SCRIPT = `(function() {
336
+ // WebRTC neutralisation: remove peer connection constructors.
337
+ try { delete window.RTCPeerConnection; } catch (_) {}
338
+ try { delete window.RTCSessionDescription; } catch (_) {}
339
+ try { delete window.RTCIceCandidate; } catch (_) {}
340
+ // Also zero out webkit/moz-prefixed variants some legacy code still uses.
341
+ try { delete window.webkitRTCPeerConnection; } catch (_) {}
342
+ try { delete window.mozRTCPeerConnection; } catch (_) {}
343
+ // Override navigator.mediaDevices.getUserMedia to prevent media/camera
344
+ // capture that could be combined with a data channel.
345
+ try {
346
+ if (navigator.mediaDevices && typeof navigator.mediaDevices === 'object') {
347
+ Object.defineProperty(navigator, 'mediaDevices', { value: Object.assign({}, navigator.mediaDevices, { getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')) }), configurable: false, writable: false });
348
+ }
349
+ } catch (_) {}
350
+ // WebSocket URL-blocking fallback (defence-in-depth, supplements
351
+ // routeWebSocket interception which fires at the Playwright layer).
352
+ var _NativeWS = window.WebSocket;
353
+ function _isForbiddenWsHost(url) {
354
+ try {
355
+ var u = new URL(url);
356
+ var h = u.hostname.replace(/^\\[|\\]$/g,'');
357
+ // Loopback / localhost
358
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
359
+ // Link-local (169.254.x.x) — cloud metadata endpoints live here
360
+ if (/^169\\.254\\./.test(h)) return true;
361
+ // RFC1918
362
+ if (/^10\\./.test(h)) return true;
363
+ if (/^172\\.(1[6-9]|2[0-9]|3[01])\\./.test(h)) return true;
364
+ if (/^192\\.168\\./.test(h)) return true;
365
+ // CGNAT
366
+ if (/^100\\.(6[4-9]|[7-9][0-9]|1([01][0-9]|2[0-7]))\\./.test(h)) return true;
367
+ // Explicit metadata hosts
368
+ if (h === '100.100.100.200' || h === 'metadata.google.internal' || h === 'metadata.goog') return true;
369
+ } catch (_) {}
370
+ return false;
371
+ }
372
+ window.WebSocket = function WrappedWebSocket(url, protocols) {
373
+ if (_isForbiddenWsHost(String(url))) {
374
+ throw new DOMException('WebSocket connection to ' + url + ' is blocked by Melaya egress policy', 'SecurityError');
375
+ }
376
+ return protocols !== undefined ? new _NativeWS(url, protocols) : new _NativeWS(url);
377
+ };
378
+ window.WebSocket.prototype = _NativeWS.prototype;
379
+ window.WebSocket.CONNECTING = _NativeWS.CONNECTING;
380
+ window.WebSocket.OPEN = _NativeWS.OPEN;
381
+ window.WebSocket.CLOSING = _NativeWS.CLOSING;
382
+ window.WebSocket.CLOSED = _NativeWS.CLOSED;
383
+ })();`;
384
+ /** Disable WebRTC at the CDP Network domain level in addition to the
385
+ * init-script layer (belt-and-suspenders). Silently skips if CDP
386
+ * session creation fails (e.g. attached browser, same-process frame). */
387
+ export async function disableWebRtcViaCdp(cdpSession) {
388
+ try {
389
+ const cdp = cdpSession;
390
+ await cdp.send("Network.enable").catch(() => { });
391
+ // Force webRtcIpHandlingPolicy to disable_non_proxied_udp, which
392
+ // prevents IP leakage via STUN even if RTCPeerConnection is somehow
393
+ // re-instated by a polyfill.
394
+ await cdp.send("Page.setWebRTCIPHandlingPolicy", {
395
+ policy: "disable_non_proxied_udp",
396
+ }).catch(() => { });
397
+ }
398
+ catch {
399
+ // CDP domain unavailable on this browser build — init script alone.
400
+ }
401
+ }
402
+ /** Install request-level enforcement on a BrowserContext we OWN (launch
403
+ * mode). Every document, subresource, XHR, redirect hop, and worker
404
+ * script fetch flows through here; disallowed ones are aborted.
405
+ * Downloads are default-denied via the page download handler.
406
+ * WebSocket connections are intercepted via routeWebSocket (Playwright
407
+ * 1.48+) with a page-init-script fallback for older builds.
408
+ * WebRTC is neutralised via an init script on every new page. */
409
+ export async function enforceOnContext(context, policy, hooks) {
410
+ await context.route("**/*", async (route) => {
411
+ const url = route.request().url();
412
+ if (hooks.isCancelled?.()) {
413
+ await route.abort("blockedbyclient").catch(() => { });
414
+ return;
415
+ }
416
+ const d = await evaluateUrlResolved(url, policy);
417
+ if (d.allowed) {
418
+ await route.continue().catch(() => { });
419
+ }
420
+ else {
421
+ hooks.onViolation({ url, code: d.code, message: d.message, surface: "context_route" });
422
+ await route.abort("blockedbyclient").catch(() => { });
423
+ }
424
+ });
425
+ // Inject the WebSocket + WebRTC guard script into every new document
426
+ // BEFORE page JS runs.
427
+ await context.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
428
+ context.on("page", (page) => {
429
+ void interceptWebSockets(page, policy, hooks);
430
+ void guardPage(page, policy, hooks, /*closeOnDeny*/ true);
431
+ });
432
+ }
433
+ /** Install enforcement on a SINGLE page (attach mode: we never take over
434
+ * routing for the user's whole externally-owned browser context, only
435
+ * the leased target the run operates on; navigation of the leased page
436
+ * and its popups is still fully gated).
437
+ * WebSocket interception and WebRTC neutralisation are applied to this
438
+ * page and its popups in the same way as the owned-context path, scoped
439
+ * to the single leased target. */
440
+ export async function enforceOnPage(page, policy, hooks) {
441
+ await page.route("**/*", async (route) => {
442
+ const url = route.request().url();
443
+ if (hooks.isCancelled?.()) {
444
+ await route.abort("blockedbyclient").catch(() => { });
445
+ return;
446
+ }
447
+ const d = await evaluateUrlResolved(url, policy);
448
+ if (d.allowed) {
449
+ await route.continue().catch(() => { });
450
+ }
451
+ else {
452
+ hooks.onViolation({ url, code: d.code, message: d.message, surface: "page_route" });
453
+ await route.abort("blockedbyclient").catch(() => { });
454
+ }
455
+ });
456
+ // WebSocket + WebRTC for the leased page.
457
+ await page.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
458
+ await interceptWebSockets(page, policy, hooks);
459
+ await guardPage(page, policy, hooks, /*closeOnDeny*/ false);
460
+ page.on("popup", (popup) => {
461
+ void popup.addInitScript({ content: PAGE_GUARD_INIT_SCRIPT }).catch(() => { });
462
+ void interceptWebSockets(popup, policy, hooks);
463
+ void guardPage(popup, policy, hooks, /*closeOnDeny*/ true);
464
+ });
465
+ }
466
+ /** CDP-target-creation gate: when a page/popup materializes, verify its
467
+ * destination; deny -> close before the agent can observe or act on it.
468
+ * Also default-denies automatic downloads (plan Section 10). */
469
+ async function guardPage(page, policy, hooks, closeOnDeny) {
470
+ page.on("download", (download) => {
471
+ hooks.onViolation({
472
+ url: download.url(),
473
+ code: "effect_not_granted",
474
+ message: "automatic downloads are default-denied in Phase 1",
475
+ surface: "download",
476
+ });
477
+ void download.cancel().catch(() => { });
478
+ });
479
+ page.on("framenavigated", (frame) => {
480
+ const url = frame.url();
481
+ if (!url || url === "about:blank")
482
+ return;
483
+ const d = evaluateUrlSync(url, policy);
484
+ if (!d.allowed) {
485
+ hooks.onViolation({ url, code: d.code, message: d.message, surface: "frame_navigated" });
486
+ if (closeOnDeny && frame === page.mainFrame()) {
487
+ void page.close({ runBeforeUnload: false }).catch(() => { });
488
+ }
489
+ }
490
+ });
491
+ // Initial URL check for popups that spawn pre-navigated.
492
+ const url = page.url();
493
+ if (url && url !== "about:blank") {
494
+ const d = evaluateUrlSync(url, policy);
495
+ if (!d.allowed) {
496
+ hooks.onViolation({ url, code: d.code, message: d.message, surface: "target_created" });
497
+ if (closeOnDeny)
498
+ await page.close({ runBeforeUnload: false }).catch(() => { });
499
+ }
500
+ }
501
+ }
502
+ function truncate(s, n = 160) {
503
+ return s.length > n ? `${s.slice(0, n)}…` : s;
504
+ }
@@ -0,0 +1,50 @@
1
+ import type { BrowserGrant } from "./browserGrantVerify.js";
2
+ import { type SpaceSpec } from "./sessionManager.js";
3
+ import { type BrowserEngineId } from "./browserProvisioner.js";
4
+ export interface BrowserRunSpec {
5
+ runId: string;
6
+ grant: BrowserGrant;
7
+ mode: "launch" | "attach";
8
+ engine?: BrowserEngineId;
9
+ cdpWsEndpoint?: string;
10
+ space?: SpaceSpec;
11
+ codeMode?: boolean;
12
+ headless?: boolean;
13
+ }
14
+ export interface BrowserBridge {
15
+ url: string;
16
+ port: number;
17
+ /** Register a verified run. Returns the per-run bearer token to inject
18
+ * as MEL_BROWSER_TOKEN. The grant itself NEVER leaves this process. */
19
+ registerRun(spec: BrowserRunSpec): {
20
+ token: string;
21
+ };
22
+ /** Idempotent full teardown for one run: cancels in-flight ops,
23
+ * releases leases, closes ONLY owned contexts, forgets the grant. */
24
+ teardownRun(runId: string, reason: string): Promise<void>;
25
+ teardownAll(reason: string): Promise<void>;
26
+ hasRun(runId: string): boolean;
27
+ /**
28
+ * Set the watch-lease state for a session (plan Section 10.f).
29
+ *
30
+ * Called by the connection layer when the server emits a
31
+ * `browser:watch` socket event `{ sessionId, active }`. When
32
+ * active=true, the bridge begins capturing frames at an adaptive FPS
33
+ * (change-detection, capped at 2-4 fps) and POSTing each JPEG frame
34
+ * to `framePostUrl` as `{ sessionId, runId, jpegB64 }` authenticated
35
+ * with the runner's relay nonce. When active=false, capturing stops.
36
+ *
37
+ * The producer runs only while a viewer is watching and stops
38
+ * automatically when the run is torn down.
39
+ */
40
+ setWatchLease(sessionId: string, active: boolean, framePostUrl: string, runAuthHeader: string): void;
41
+ shutdown(): Promise<void>;
42
+ }
43
+ export declare function startBrowserBridge(opts: {
44
+ log: (msg: string) => void;
45
+ verbose?: boolean;
46
+ }): Promise<BrowserBridge>;
47
+ export declare class BridgeError extends Error {
48
+ readonly code: string;
49
+ constructor(code: string, message: string);
50
+ }