agentlock-browser 0.1.0__py3-none-any.whl

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,29 @@
1
+ """agentlock-browser -- provenance-gated browsing tools for AI agents.
2
+
3
+ Adversarial and legitimate tool requests are semantically identical. What
4
+ distinguishes them is not what the request says but where its values came
5
+ from. This server gates browsing on that, at the infrastructure layer, using
6
+ AgentLock.
7
+
8
+ Copyright 2026 David Grice
9
+ SPDX-License-Identifier: AGPL-3.0-or-later
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ from agentlock_browser.config import BrowserConfig
17
+ from agentlock_browser.gate import BrowserGate, Decision
18
+ from agentlock_browser.provenance import Channel, ProvenanceLedger
19
+ from agentlock_browser.service import BrowserService
20
+
21
+ __all__ = [
22
+ "BrowserConfig",
23
+ "BrowserGate",
24
+ "BrowserService",
25
+ "Channel",
26
+ "Decision",
27
+ "ProvenanceLedger",
28
+ "__version__",
29
+ ]
@@ -0,0 +1,21 @@
1
+ """Entry point: ``agentlock-browser`` runs the MCP server on stdio.
2
+
3
+ Copyright 2026 David Grice
4
+ SPDX-License-Identifier: AGPL-3.0-or-later
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import anyio
10
+
11
+ from agentlock_browser.config import BrowserConfig
12
+ from agentlock_browser.server import build_server
13
+
14
+
15
+ def main() -> None:
16
+ server = build_server(BrowserConfig.load())
17
+ anyio.run(server.run_stdio_async)
18
+
19
+
20
+ if __name__ == "__main__":
21
+ main()
@@ -0,0 +1,484 @@
1
+ """The browser this server owns.
2
+
3
+ agentlock-browser drives chromium through Playwright directly. It does not
4
+ wrap @playwright/mcp and shares no code with it. The probe in probe/REPORT.md
5
+ is the reason: that server returns one undifferentiated text blob per call,
6
+ identifies elements with opaque server-side refs, accepts raw CSS selectors in
7
+ the same field as those refs, and exposes arbitrary JavaScript through
8
+ ``browser_evaluate``. None of that can carry provenance.
9
+
10
+ What this module guarantees instead:
11
+
12
+ * every result is structured, and page text arrives as separately identified
13
+ elements or blocks -- never as one string;
14
+ * element ids are stable for the current page load and regenerate on
15
+ navigation, so an id cannot outlive the page it describes;
16
+ * there is no evaluate tool. The extraction script below is a fixed,
17
+ server-authored constant; no model input reaches it, and nothing in the MCP
18
+ surface can run JavaScript.
19
+
20
+ Navigation is intercepted through the CDP ``Fetch`` domain rather than
21
+ Playwright's ``page.route``. That is not a preference: ``page.route`` never
22
+ offers a redirect hop to a handler, so a 302 to another origin was followed
23
+ before anything could gate it (probe/origin/REPORT.md R1,
24
+ probe/cdp/REPORT.md). ``Fetch.requestPaused`` does surface the hop, and
25
+ carries ``redirectedRequestId`` pointing back at the request it came from.
26
+ This makes the server chromium-only.
27
+
28
+ Copyright 2026 David Grice
29
+ SPDX-License-Identifier: AGPL-3.0-or-later
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ from dataclasses import dataclass
36
+ from typing import Any
37
+
38
+ from playwright.async_api import Playwright, async_playwright
39
+
40
+ from agentlock_browser.config import BrowserConfig, origin_of
41
+
42
+ __all__ = ["BrowserSession", "PageElement", "TextBlock"]
43
+
44
+ #: Elements an inventory should name: everything interactive, plus the
45
+ #: headings and text containers that make a page readable.
46
+ SNAPSHOT_SEL = (
47
+ "a[href], button, input, select, textarea, "
48
+ "h1, h2, h3, h4, h5, h6, p, li, label"
49
+ )
50
+
51
+ #: Block-level containers for read_text. Generic containers are included so
52
+ #: text in a bare <div> is not silently missed; the extractor keeps only leaf
53
+ #: matches, so a paragraph's text is attributed to the paragraph and not also
54
+ #: to every ancestor that happens to match.
55
+ BLOCK_SEL = (
56
+ "h1, h2, h3, h4, h5, h6, p, li, blockquote, pre, td, th, dt, dd, "
57
+ "figcaption, div, section, article, main, aside, span"
58
+ )
59
+
60
+ #: Fixed extraction script. A constant, never interpolated with model input.
61
+ _EXTRACT_JS = """
62
+ ({ selector, leafOnly }) => {
63
+ const nodes = Array.from(document.querySelectorAll(selector));
64
+ const visible = (el) => {
65
+ const s = window.getComputedStyle(el);
66
+ if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0')
67
+ return false;
68
+ const r = el.getBoundingClientRect();
69
+ return (r.width > 0 && r.height > 0) || el.tagName === 'OPTION';
70
+ };
71
+ const roleOf = (el) => {
72
+ const explicit = el.getAttribute('role');
73
+ if (explicit) return explicit;
74
+ const tag = el.tagName.toLowerCase();
75
+ if (tag === 'a') return 'link';
76
+ if (tag === 'button') return 'button';
77
+ if (tag === 'select') return 'combobox';
78
+ if (tag === 'textarea') return 'textbox';
79
+ if (tag === 'input') {
80
+ const t = (el.getAttribute('type') || 'text').toLowerCase();
81
+ if (t === 'checkbox') return 'checkbox';
82
+ if (t === 'radio') return 'radio';
83
+ if (t === 'submit' || t === 'button') return 'button';
84
+ return 'textbox';
85
+ }
86
+ if (/^h[1-6]$/.test(tag)) return 'heading';
87
+ if (tag === 'li') return 'listitem';
88
+ if (tag === 'p') return 'paragraph';
89
+ if (tag === 'label') return 'label';
90
+ return tag;
91
+ };
92
+ const nameOf = (el) => (
93
+ el.getAttribute('aria-label') ||
94
+ el.getAttribute('alt') ||
95
+ el.getAttribute('placeholder') ||
96
+ el.getAttribute('title') ||
97
+ (el.tagName === 'INPUT' ? (el.getAttribute('name') || '') : '') ||
98
+ (el.innerText || '').trim().slice(0, 120)
99
+ );
100
+ return nodes.map((el, i) => ({
101
+ index: i,
102
+ leaf: el.querySelector(selector) === null,
103
+ role: roleOf(el),
104
+ name: (nameOf(el) || '').trim().slice(0, 200),
105
+ text: (el.innerText || '').trim().slice(0, 2000),
106
+ href: el.tagName === 'A' ? (el.href || '') : '',
107
+ visible: visible(el),
108
+ })).filter((item) => !leafOnly || item.leaf);
109
+ }
110
+ """
111
+
112
+
113
+ #: Only main-frame document requests are paused. Subresources are not
114
+ #: navigation and are never gated, so pausing them would cost latency for
115
+ #: nothing.
116
+ _FETCH_PATTERNS = [
117
+ {"urlPattern": "*", "resourceType": "Document", "requestStage": "Request"}
118
+ ]
119
+
120
+ #: One tab, enforced at launch. The Fetch interceptor is attached to one
121
+ #: page, so a second top-level page is not gated by it at all: probe/popup
122
+ #: recorded a ``target="_blank"`` link, a ``window.open`` on click, and a
123
+ #: ``window.open`` on load each loading evil.test with the interceptor never
124
+ #: firing and no decision reaching the log. This flag makes the renderer
125
+ #: refuse to create such a page in the first place.
126
+ _BLOCK_NEW_PAGES_ARG = "--block-new-web-contents"
127
+
128
+ #: A denied navigation is answered with 204, not failed. Measured in
129
+ #: probe/cdp/REPORT.md: ``Fetch.failRequest`` leaves the page on
130
+ #: ``chrome-error://chromewebdata/`` even when a document was committed
131
+ #: before, while a 204 leaves it exactly where it was. "The page stays put"
132
+ #: has to mean the URL it was at before the call.
133
+ _DENY_RESPONSE_CODE = 204
134
+
135
+
136
+ @dataclass
137
+ class PageElement:
138
+ id: str
139
+ role: str
140
+ name: str
141
+ text: str
142
+ href: str
143
+
144
+
145
+ @dataclass
146
+ class TextBlock:
147
+ id: str
148
+ text: str
149
+
150
+
151
+ class BrowserSession:
152
+ """Owns one chromium page and the identifiers that describe it."""
153
+
154
+ def __init__(self, config: BrowserConfig) -> None:
155
+ self.config = config
156
+ self._pw: Playwright | None = None
157
+ self._browser: Any = None
158
+ self._context: Any = None
159
+ self.page: Any = None
160
+
161
+ #: Increments on every committed main-frame navigation. Element ids
162
+ #: carry it, so an id minted before a navigation can never resolve
163
+ #: after one.
164
+ self.epoch = 0
165
+ #: Ids returned by the most recent snapshot, and the hrefs they
166
+ #: resolved to. navigate(link_id) is checked against exactly this.
167
+ self.snapshot_ids: dict[str, str] = {}
168
+ self.snapshot_epoch = -1
169
+
170
+ #: Set while an authorized goto is in flight. Its origin is the
171
+ #: origin the navigation was authorized for, which is what a redirect
172
+ #: hop is compared against.
173
+ self._nav_grant: str | None = None
174
+ #: Called with (url, origin, redirected_from) when a cross-origin
175
+ #: navigation is intercepted. Returns True to allow. Installed by
176
+ #: the server.
177
+ self.on_cross_origin: Any = None
178
+ #: Intercepted-and-refused targets since the last read, for reporting.
179
+ self.blocked: list[str] = []
180
+ #: Called with the url of a new top-level page that appeared anyway.
181
+ #: Installed by the server, which logs it. The page is closed either
182
+ #: way; this only decides whether the closure is recorded.
183
+ self.on_new_page: Any = None
184
+ #: Urls of pages closed by that listener, for reporting.
185
+ self.blocked_pages: list[str] = []
186
+ #: Anything the interceptor itself failed on. Recorded rather than
187
+ #: swallowed: the handler fails closed, so a bug here stops browsing
188
+ #: rather than quietly letting a navigation through.
189
+ self.interceptor_errors: list[str] = []
190
+ #: Error text from the last goto, when the navigation was refused.
191
+ self.last_navigation_error: str = ""
192
+
193
+ self._cdp: Any = None
194
+ self._main_frame_id: str = ""
195
+ self._loop: Any = None
196
+
197
+ # -- lifecycle ---------------------------------------------------------
198
+
199
+ async def start(self) -> None:
200
+ self._pw = await async_playwright().start()
201
+ args = list(self.config.chromium_args)
202
+ if _BLOCK_NEW_PAGES_ARG not in args:
203
+ args.append(_BLOCK_NEW_PAGES_ARG)
204
+ self._browser = await self._pw.chromium.launch(
205
+ headless=self.config.headless,
206
+ args=args,
207
+ )
208
+ self._context = await self._browser.new_context(
209
+ user_agent=self.config.user_agent,
210
+ )
211
+ self._context.set_default_navigation_timeout(self.config.nav_timeout_ms)
212
+ self._context.set_default_timeout(self.config.action_timeout_ms)
213
+ self.page = await self._context.new_page()
214
+ self.page.on("framenavigated", self._on_frame_navigated)
215
+
216
+ self._loop = asyncio.get_running_loop()
217
+ # Registered after self.page exists, so the listener never sees the
218
+ # page this session owns.
219
+ self._context.on("page", self._on_new_page)
220
+ self._cdp = await self._context.new_cdp_session(self.page)
221
+ frame_tree = await self._cdp.send("Page.getFrameTree")
222
+ self._main_frame_id = frame_tree["frameTree"]["frame"]["id"]
223
+ await self._cdp.send("Fetch.enable", {"patterns": _FETCH_PATTERNS})
224
+ self._cdp.on("Fetch.requestPaused", self._on_request_paused)
225
+
226
+ async def close(self) -> None:
227
+ for closer in (self._context, self._browser):
228
+ if closer is not None:
229
+ await closer.close()
230
+ if self._pw is not None:
231
+ await self._pw.stop()
232
+ self._pw = self._browser = self._context = self.page = None
233
+
234
+ async def __aenter__(self) -> BrowserSession:
235
+ await self.start()
236
+ return self
237
+
238
+ async def __aexit__(self, *exc: object) -> None:
239
+ await self.close()
240
+
241
+ # -- navigation interception ------------------------------------------
242
+
243
+ def _on_frame_navigated(self, frame: Any) -> None:
244
+ if self.page is not None and frame == self.page.main_frame:
245
+ self.epoch += 1
246
+ self.snapshot_ids = {}
247
+
248
+ def _on_new_page(self, page: Any) -> None:
249
+ """A second top-level page appeared. Close it.
250
+
251
+ ``--block-new-web-contents`` should mean this never fires. It is here
252
+ because a launch flag is an assumption about chromium, and the one-tab
253
+ rule should not rest on one: anything the flag misses is closed here
254
+ and recorded. The page is never handed to a tool, never snapshotted,
255
+ and never becomes ``self.page``.
256
+ """
257
+ if page is self.page:
258
+ return
259
+ if self._loop is not None:
260
+ self._loop.create_task(self._close_new_page(page))
261
+
262
+ async def _close_new_page(self, page: Any) -> None:
263
+ """Close a page that appeared anyway, and record that it was closed.
264
+
265
+ Fails closed: an exception here is appended to ``interceptor_errors``
266
+ rather than swallowed, because a page this session cannot close is a
267
+ page outside everything the gate decides.
268
+ """
269
+ url = ""
270
+ try:
271
+ url = page.url
272
+ self.blocked_pages.append(url)
273
+ if self.on_new_page is not None:
274
+ self.on_new_page(url)
275
+ await page.close()
276
+ except Exception as exc: # noqa: BLE001 - recorded, never swallowed
277
+ self.interceptor_errors.append(
278
+ f"{type(exc).__name__}: {exc} (closing new page {url!r})"
279
+ )
280
+
281
+ def _on_request_paused(self, event: dict[str, Any]) -> None:
282
+ """CDP hands this to us synchronously; the decision runs as a task.
283
+
284
+ The request stays paused until the task answers it, so nothing races
285
+ ahead of the gate.
286
+ """
287
+ if self._loop is not None:
288
+ self._loop.create_task(self._handle_paused(event))
289
+
290
+ def _authorized_origin(self) -> str:
291
+ """The origin this navigation is allowed to be on.
292
+
293
+ While an authorized goto is in flight that is the origin the gate
294
+ approved, not the page's current origin: during a redirect chain the
295
+ page has not moved yet, and comparing against where it still happens
296
+ to be would let the first hop off the authorized origin through.
297
+ """
298
+ if self._nav_grant is not None:
299
+ return origin_of(self._nav_grant)
300
+ return origin_of(self.page.url if self.page else "")
301
+
302
+ async def _handle_paused(self, event: dict[str, Any]) -> None:
303
+ request_id = event.get("requestId", "")
304
+ target = event.get("request", {}).get("url", "")
305
+ redirected_from = event.get("redirectedRequestId")
306
+ try:
307
+ if event.get("frameId") != self._main_frame_id:
308
+ # A subframe document. Not navigation of the page the tools
309
+ # act on, and not gated in v0.
310
+ await self._continue(request_id)
311
+ return
312
+
313
+ if self._nav_grant is not None and target == self._nav_grant:
314
+ # The navigation navigate() just authorized.
315
+ await self._continue(request_id)
316
+ return
317
+
318
+ authorized = self._authorized_origin()
319
+ if not authorized or origin_of(target) == authorized:
320
+ # Same-origin navigation is ungated in v0 (PREDICTIONS.md).
321
+ await self._continue(request_id)
322
+ return
323
+
324
+ if await self._authorized_cross_origin(target, authorized,
325
+ redirected_from):
326
+ await self._continue(request_id)
327
+ else:
328
+ self.blocked.append(target)
329
+ await self._refuse(request_id)
330
+ except Exception as exc: # noqa: BLE001 - fails closed, and says so
331
+ self.interceptor_errors.append(
332
+ f"{type(exc).__name__}: {exc} (request {request_id} for {target})"
333
+ )
334
+ try:
335
+ await self._refuse(request_id)
336
+ except Exception as inner: # noqa: BLE001
337
+ self.interceptor_errors.append(
338
+ f"refuse failed: {type(inner).__name__}: {inner}"
339
+ )
340
+
341
+ async def _continue(self, request_id: str) -> None:
342
+ await self._cdp.send("Fetch.continueRequest", {"requestId": request_id})
343
+
344
+ async def _refuse(self, request_id: str) -> None:
345
+ """Answer a refused navigation with 204, leaving the page where it is."""
346
+ await self._cdp.send("Fetch.fulfillRequest", {
347
+ "requestId": request_id,
348
+ "responseCode": _DENY_RESPONSE_CODE,
349
+ "responseHeaders": [],
350
+ "body": "",
351
+ })
352
+
353
+ async def _authorized_cross_origin(
354
+ self, target: str, origin: str, redirected_from: str | None
355
+ ) -> bool:
356
+ """Ask the owner of this session whether the navigation may proceed."""
357
+ if self.on_cross_origin is None:
358
+ return True
359
+ return bool(self.on_cross_origin(target, origin, redirected_from))
360
+
361
+ # -- actions -----------------------------------------------------------
362
+
363
+ async def goto(self, url: str) -> None:
364
+ """Navigate to an already-authorized URL.
365
+
366
+ A navigation the interceptor refuses mid-flight surfaces here as an
367
+ aborted goto. That is a decision this server made, not a failure, so
368
+ it is recorded rather than raised: the page is still on the URL it was
369
+ on before the call, and the caller reports that.
370
+ """
371
+ self.last_navigation_error = ""
372
+ self._nav_grant = url
373
+ try:
374
+ await self.page.goto(url, wait_until="domcontentloaded")
375
+ except Exception as exc: # noqa: BLE001 - reported, never swallowed
376
+ self.last_navigation_error = f"{type(exc).__name__}: {exc}".splitlines()[0]
377
+ finally:
378
+ self._nav_grant = None
379
+
380
+ async def back(self) -> bool:
381
+ response = await self.page.go_back(wait_until="domcontentloaded")
382
+ return response is not None
383
+
384
+ @property
385
+ def url(self) -> str:
386
+ return self.page.url if self.page else ""
387
+
388
+ @property
389
+ def origin(self) -> str:
390
+ return origin_of(self.url)
391
+
392
+ async def title(self) -> str:
393
+ return await self.page.title()
394
+
395
+ async def _extract(
396
+ self, selector: str, leaf_only: bool = False
397
+ ) -> list[dict[str, Any]]:
398
+ return await self.page.evaluate(
399
+ _EXTRACT_JS, {"selector": selector, "leafOnly": leaf_only}
400
+ )
401
+
402
+ async def snapshot(self) -> list[PageElement]:
403
+ """Structured element inventory for the current page load.
404
+
405
+ Ids are ``<epoch>-e<index>``: deterministic from document order, so
406
+ two snapshots of the same unchanged page return the same ids, and the
407
+ epoch makes every id from a previous page load unresolvable.
408
+ """
409
+ raw = await self._extract(SNAPSHOT_SEL)
410
+ elements = [
411
+ PageElement(
412
+ id=f"{self.epoch}-e{item['index']}",
413
+ role=item["role"],
414
+ name=item["name"],
415
+ text=item["text"],
416
+ href=item["href"],
417
+ )
418
+ for item in raw
419
+ if item["visible"] or item["href"]
420
+ ]
421
+ self.snapshot_ids = {e.id: e.href for e in elements}
422
+ self.snapshot_epoch = self.epoch
423
+ return elements
424
+
425
+ async def read_text(self) -> list[TextBlock]:
426
+ """Page text as separately identified blocks. Never one string."""
427
+ raw = await self._extract(BLOCK_SEL, leaf_only=True)
428
+ return [
429
+ TextBlock(id=f"{self.epoch}-b{item['index']}", text=item["text"])
430
+ for item in raw
431
+ if item["visible"] and item["text"]
432
+ ]
433
+
434
+ def resolve_link(self, element_id: str) -> str | None:
435
+ """The href a link id resolved to in the most recent snapshot.
436
+
437
+ Returns None when the id is unknown, or when it belongs to a previous
438
+ page load. This is the whole freshness rule.
439
+ """
440
+ if self.snapshot_epoch != self.epoch:
441
+ return None
442
+ href = self.snapshot_ids.get(element_id)
443
+ return href or None
444
+
445
+ def _locator(self, element_id: str) -> Any:
446
+ """Resolve an element id back to a live locator.
447
+
448
+ The id encodes the page-load epoch and the element's index in document
449
+ order under a fixed selector. A mismatched epoch is refused rather
450
+ than silently resolved against a different page.
451
+ """
452
+ try:
453
+ epoch_str, kind_index = element_id.split("-", 1)
454
+ epoch = int(epoch_str)
455
+ kind, index = kind_index[0], int(kind_index[1:])
456
+ except (ValueError, IndexError):
457
+ raise KeyError(f"malformed element id: {element_id!r}") from None
458
+ if kind != "e":
459
+ # read_text block ids ("<epoch>-b<n>") index a different node list.
460
+ # Resolving one here would act on a different element than the id
461
+ # names, so it is refused rather than coerced.
462
+ raise KeyError(
463
+ f"{element_id!r} is a text-block id; click and type take "
464
+ f"element ids from snapshot (\"<epoch>-e<n>\")"
465
+ )
466
+ if epoch != self.epoch:
467
+ raise KeyError(
468
+ f"element id {element_id!r} belongs to page load {epoch}, "
469
+ f"current page load is {self.epoch}"
470
+ )
471
+ return self.page.locator(SNAPSHOT_SEL).nth(index)
472
+
473
+ async def click(self, element_id: str) -> None:
474
+ """Click, then let any navigation the click triggered be decided.
475
+
476
+ Without the settle wait the click returns before the route handler has
477
+ seen the navigation request, and the caller reads an interception
478
+ result that has not happened yet.
479
+ """
480
+ await self._locator(element_id).click()
481
+ await self.page.wait_for_timeout(self.config.settle_ms)
482
+
483
+ async def fill(self, element_id: str, value: str) -> None:
484
+ await self._locator(element_id).fill(value)
@@ -0,0 +1,123 @@
1
+ """Configuration for the agentlock-browser MCP server.
2
+
3
+ Everything the operator controls lives here. Nothing in this module is
4
+ reachable by the model: config is loaded once at startup, from a JSON file
5
+ and/or the environment, before the first tool call is served.
6
+
7
+ Copyright 2026 David Grice
8
+ SPDX-License-Identifier: AGPL-3.0-or-later
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from urllib.parse import urlsplit
18
+
19
+ __all__ = ["BrowserConfig", "origin_of"]
20
+
21
+
22
+ def origin_of(url: str) -> str:
23
+ """The scheme://host[:port] origin of a URL, lowercased.
24
+
25
+ Returns "" for a URL with no scheme or host (about:blank, data:, "").
26
+ """
27
+ parts = urlsplit(url.strip())
28
+ if not parts.scheme or not parts.netloc:
29
+ return ""
30
+ return f"{parts.scheme.lower()}://{parts.netloc.lower()}"
31
+
32
+
33
+ @dataclass
34
+ class BrowserConfig:
35
+ """Operator-controlled configuration.
36
+
37
+ Attributes:
38
+ allowlist: Origins the operator has pre-authorized for navigation.
39
+ Values in this list are the ALLOWLIST provenance channel. Compared
40
+ as origins (scheme://host[:port]), never as substrings.
41
+ operator_text: The operator's own message text for this session. This
42
+ is the USER provenance channel and the ONLY source of it. See
43
+ README "The USER channel" for why this is a startup input and not
44
+ a tool argument.
45
+ log_path: JSONL decision log. Every gate decision and every provenance
46
+ record is appended here.
47
+ headless: Run chromium headless. Default True.
48
+ chromium_args: Extra chromium launch arguments. Used by the test
49
+ harness to map evil.test onto a local server.
50
+ user_agent: Optional user-agent override.
51
+ nav_timeout_ms: Navigation timeout.
52
+ action_timeout_ms: Click/type timeout.
53
+ settle_ms: How long to let work a click triggered settle before
54
+ reporting the result, so an intercepted navigation is decided
55
+ before the caller reads the outcome.
56
+ role: The role presented to the AgentLock gate.
57
+ signing_key: HMAC-SHA256 receipt signing key. Generated per process
58
+ when absent, which makes receipts verifiable only in-process.
59
+ """
60
+
61
+ allowlist: list[str] = field(default_factory=list)
62
+ operator_text: str = ""
63
+ log_path: str = "agentlock-browser.jsonl"
64
+ headless: bool = True
65
+ chromium_args: list[str] = field(default_factory=list)
66
+ user_agent: str | None = None
67
+ nav_timeout_ms: int = 30_000
68
+ action_timeout_ms: int = 10_000
69
+ settle_ms: int = 500
70
+ role: str = "operator"
71
+ signing_key: bytes | None = None
72
+
73
+ def __post_init__(self) -> None:
74
+ # Normalize the allowlist to origins once, at load, so no comparison
75
+ # downstream ever has to guess whether it holds a URL or an origin.
76
+ self.allowlist = [origin_of(a) or a.strip().lower() for a in self.allowlist]
77
+
78
+ def is_allowlisted(self, url: str) -> bool:
79
+ """Is this URL's origin on the operator's allowlist?
80
+
81
+ Origin equality, not prefix or substring: "https://evil.com/example.com"
82
+ does not match an allowlisted "https://example.com".
83
+ """
84
+ origin = origin_of(url)
85
+ return bool(origin) and origin in self.allowlist
86
+
87
+ @classmethod
88
+ def load(cls, path: str | os.PathLike[str] | None = None) -> BrowserConfig:
89
+ """Load config from a JSON file and the environment.
90
+
91
+ Environment overrides the file. Recognized variables:
92
+
93
+ * ``AGENTLOCK_BROWSER_CONFIG`` -- path to the JSON file
94
+ * ``AGENTLOCK_BROWSER_ALLOWLIST`` -- comma-separated origins
95
+ * ``AGENTLOCK_BROWSER_OPERATOR_TEXT`` -- the operator's message text
96
+ * ``AGENTLOCK_BROWSER_LOG`` -- JSONL decision log path
97
+ * ``AGENTLOCK_BROWSER_HEADLESS`` -- "0"/"false" to run headed
98
+ """
99
+ data: dict[str, object] = {}
100
+ cfg_path = path or os.environ.get("AGENTLOCK_BROWSER_CONFIG")
101
+ if cfg_path:
102
+ p = Path(cfg_path).expanduser()
103
+ if p.exists():
104
+ data = json.loads(p.read_text())
105
+
106
+ allowlist_env = os.environ.get("AGENTLOCK_BROWSER_ALLOWLIST")
107
+ if allowlist_env is not None:
108
+ data["allowlist"] = [a for a in allowlist_env.split(",") if a.strip()]
109
+
110
+ operator_env = os.environ.get("AGENTLOCK_BROWSER_OPERATOR_TEXT")
111
+ if operator_env is not None:
112
+ data["operator_text"] = operator_env
113
+
114
+ log_env = os.environ.get("AGENTLOCK_BROWSER_LOG")
115
+ if log_env:
116
+ data["log_path"] = log_env
117
+
118
+ headless_env = os.environ.get("AGENTLOCK_BROWSER_HEADLESS")
119
+ if headless_env is not None:
120
+ data["headless"] = headless_env.strip().lower() not in ("0", "false", "no")
121
+
122
+ known = {f for f in cls.__dataclass_fields__ if f != "signing_key"}
123
+ return cls(**{k: v for k, v in data.items() if k in known}) # type: ignore[arg-type]