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.
- agentlock_browser/__init__.py +29 -0
- agentlock_browser/__main__.py +21 -0
- agentlock_browser/browser.py +484 -0
- agentlock_browser/config.py +123 -0
- agentlock_browser/gate.py +419 -0
- agentlock_browser/log.py +60 -0
- agentlock_browser/models.py +115 -0
- agentlock_browser/provenance.py +139 -0
- agentlock_browser/server.py +149 -0
- agentlock_browser/service.py +275 -0
- agentlock_browser-0.1.0.dist-info/METADATA +312 -0
- agentlock_browser-0.1.0.dist-info/RECORD +15 -0
- agentlock_browser-0.1.0.dist-info/WHEEL +4 -0
- agentlock_browser-0.1.0.dist-info/entry_points.txt +2 -0
- agentlock_browser-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -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]
|