claros-browser 0.1.0__tar.gz
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.
- claros_browser-0.1.0/.gitignore +12 -0
- claros_browser-0.1.0/LICENSE +21 -0
- claros_browser-0.1.0/PKG-INFO +28 -0
- claros_browser-0.1.0/README.md +18 -0
- claros_browser-0.1.0/claros_browser/__init__.py +242 -0
- claros_browser-0.1.0/claros_browser/page.js +340 -0
- claros_browser-0.1.0/e2e/run.py +161 -0
- claros_browser-0.1.0/pyproject.toml +21 -0
- claros_browser-0.1.0/tests/test_guard.py +178 -0
- claros_browser-0.1.0/uv.lock +5696 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Timur Monasypov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: claros-browser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Claros guard for browser-use agents: consequential clicks and submits are checked against your experts' rules before they reach the app
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: browser-use==0.13.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# claros-browser
|
|
12
|
+
|
|
13
|
+
Claros guard for [browser-use](https://github.com/browser-use/browser-use) agents. Consequential clicks, submits and
|
|
14
|
+
Enter presses are held in the page and checked against your team's rules before they reach the app: **stop**, **ask**
|
|
15
|
+
a person, or **nudge** with a fix.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from browser_use import Agent, Tools
|
|
19
|
+
from claros_browser import guard_tools
|
|
20
|
+
|
|
21
|
+
tools = Tools()
|
|
22
|
+
guard_tools(tools) # CLAROS_KEY from the env; the server is found via the Claros web app
|
|
23
|
+
Agent(task=..., llm=..., tools=tools)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Claros unreachable means stopped (fail closed) unless `fail_open=True`. Docs: https://claros-eta.vercel.app/docs
|
|
27
|
+
|
|
28
|
+
MIT licence.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# claros-browser
|
|
2
|
+
|
|
3
|
+
Claros guard for [browser-use](https://github.com/browser-use/browser-use) agents. Consequential clicks, submits and
|
|
4
|
+
Enter presses are held in the page and checked against your team's rules before they reach the app: **stop**, **ask**
|
|
5
|
+
a person, or **nudge** with a fix.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from browser_use import Agent, Tools
|
|
9
|
+
from claros_browser import guard_tools
|
|
10
|
+
|
|
11
|
+
tools = Tools()
|
|
12
|
+
guard_tools(tools) # CLAROS_KEY from the env; the server is found via the Claros web app
|
|
13
|
+
Agent(task=..., llm=..., tools=tools)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Claros unreachable means stopped (fail closed) unless `fail_open=True`. Docs: https://claros-eta.vercel.app/docs
|
|
17
|
+
|
|
18
|
+
MIT licence.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Claros guard for browser-use agents (browser-use 0.13.11).
|
|
2
|
+
|
|
3
|
+
from browser_use import Agent, Tools
|
|
4
|
+
from claros_browser import guard_tools
|
|
5
|
+
tools = Tools(); guard_tools(tools) # CLAROS_URL / CLAROS_KEY from the env
|
|
6
|
+
Agent(task=..., llm=..., tools=tools)
|
|
7
|
+
|
|
8
|
+
page.js (the same file as packages/browser/src/page.js) is injected into every page: a consequential click / submit /
|
|
9
|
+
Enter is held in the page, never reaching the app. After each agent action the guard takes the held actions, checks
|
|
10
|
+
each with Claros (POST /v1/check/page, or /v1/check/screen from a screenshot when the DOM shows no record), replays the
|
|
11
|
+
allowed ones and drops the stopped ones; the action then returns ActionResult(error=<the rule, the expert's words, who
|
|
12
|
+
to ask>) so the agent sees why. Claros unreachable → stopped (fail closed) unless fail_open=True. A nudge (only
|
|
13
|
+
`nudge` rules broken) lets the action through; its text (rule, expert's words, suggested fix) is appended to the
|
|
14
|
+
action result's extracted_content (and long_term_memory when set), logged as a warning and passed to on_nudge.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import base64
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.parse
|
|
26
|
+
import urllib.request
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable, Optional
|
|
29
|
+
|
|
30
|
+
PAGE_JS = (Path(__file__).parent / "page.js").read_text(encoding="utf-8")
|
|
31
|
+
DEFAULT_WEB = "https://claros-eta.vercel.app"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def discover(web: Optional[str] = None) -> str:
|
|
35
|
+
"""The web app says where its server is (<web>/.well-known/claros.json → {"api": …}), so a moved server needs no
|
|
36
|
+
release."""
|
|
37
|
+
web = (web or os.getenv("CLAROS_WEB_URL") or DEFAULT_WEB).rstrip("/")
|
|
38
|
+
try:
|
|
39
|
+
with urllib.request.urlopen(f"{web}/.well-known/claros.json", timeout=5) as r:
|
|
40
|
+
return json.load(r)["api"]
|
|
41
|
+
except (urllib.error.URLError, OSError, KeyError, ValueError) as e:
|
|
42
|
+
raise RuntimeError(f"Claros: {web} did not say where its server is ({e}); set CLAROS_URL") from e
|
|
43
|
+
log = logging.getLogger("claros_browser")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ClarosStop(Exception):
|
|
47
|
+
def __init__(self, result: Optional[dict], message: str, item: Optional[dict] = None) -> None:
|
|
48
|
+
super().__init__(message)
|
|
49
|
+
self.result = result # the /v1/check answer; None when Claros couldn't be reached
|
|
50
|
+
self.action = (item or {}).get("action")
|
|
51
|
+
self.url = (item or {}).get("url")
|
|
52
|
+
self.violations = [v for v in (result or {}).get("violations", []) if v.get("action") in ("stop", "ask")]
|
|
53
|
+
v = self.violations[0] if self.violations else {}
|
|
54
|
+
self.rule, self.quote, self.expert = v.get("rule"), v.get("quote"), v.get("expert")
|
|
55
|
+
self.who_to_ask = v.get("who_to_ask") or v.get("owner") or v.get("expert")
|
|
56
|
+
self.check_id = (result or {}).get("check_id")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stop_text(r: dict, action: str) -> str:
|
|
60
|
+
"""Same wording as the MCP guard and the Playwright hook."""
|
|
61
|
+
hits = [v for v in r.get("violations", []) if v.get("action") in ("stop", "ask")]
|
|
62
|
+
out = [f'Claros stopped "{action}": it breaks {"a rule" if len(hits) == 1 else "rules"} from the expert-approved '
|
|
63
|
+
f'Work Map "{r.get("workflow_id")}". The click never reached the app. Do not retry it as is.']
|
|
64
|
+
for v in hits:
|
|
65
|
+
who = v.get("who_to_ask") or v.get("owner") or v.get("expert") or "the process owner"
|
|
66
|
+
out += ["", f"Rule: {v.get('rule')}"]
|
|
67
|
+
if v.get("reason"):
|
|
68
|
+
out.append(f"Why Claros stopped it: {v['reason']}")
|
|
69
|
+
if v.get("quote"):
|
|
70
|
+
by = f" ({v['expert']})" if v.get("expert") else ""
|
|
71
|
+
out.append(f"In the expert's words{by}: \"{v['quote']}\"")
|
|
72
|
+
out.append(f"Who to ask: {who}")
|
|
73
|
+
out.append(f"Next: stop and ask {who} before doing this; continue only with their answer." if v["action"] == "ask"
|
|
74
|
+
else f"Next: change the record so the rule holds, or ask {who} to approve an exception. Tell the user why.")
|
|
75
|
+
return "\n".join(out)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def nudges_of(r: Optional[dict]) -> list[dict]:
|
|
79
|
+
"""The rules a nudge let through ("warn" is the nudge's old name, from older Claros servers)."""
|
|
80
|
+
return [v for v in (r or {}).get("violations", []) if v.get("action") in ("nudge", "warn")]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def nudge_text(r: dict, action: str) -> str:
|
|
84
|
+
"""Same wording as the MCP guard's nudge and the Playwright hook's."""
|
|
85
|
+
hits = nudges_of(r)
|
|
86
|
+
one = len(hits) == 1
|
|
87
|
+
out = [f'Claros nudge: "{action}" went through, but it doesn\'t follow {"a rule" if one else "rules"} your team '
|
|
88
|
+
f'holds. Fix it if you still can, and follow {"it" if one else "them"} from now on.']
|
|
89
|
+
for v in hits:
|
|
90
|
+
out += ["", f"Rule: {v.get('rule')}"]
|
|
91
|
+
if v.get("reason"):
|
|
92
|
+
out.append(f"Why: {v['reason']}")
|
|
93
|
+
if v.get("quote"):
|
|
94
|
+
by = f" ({v['expert']})" if v.get("expert") else ""
|
|
95
|
+
out.append(f"In the expert's words{by}: \"{v['quote']}\"")
|
|
96
|
+
if v.get("fix"):
|
|
97
|
+
out.append(f"Suggested fix: {v['fix']}")
|
|
98
|
+
return "\n".join(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Guard:
|
|
102
|
+
def __init__(self, url: Optional[str] = None, key: Optional[str] = None, timeout: float = 5.0,
|
|
103
|
+
fail_open: bool = False, actor: Optional[str] = None, workflow_id: Optional[str] = None,
|
|
104
|
+
on_nudge: Optional[Callable[[dict], Any]] = None) -> None:
|
|
105
|
+
self.url = (url or os.getenv("CLAROS_URL") or discover()).rstrip("/")
|
|
106
|
+
self.key = key if key is not None else os.getenv("CLAROS_KEY", "")
|
|
107
|
+
self.timeout, self.fail_open, self.actor, self.workflow_id = timeout, fail_open, actor, workflow_id
|
|
108
|
+
self.on_nudge = on_nudge
|
|
109
|
+
self.decisions: list[dict] = []
|
|
110
|
+
self.stops: list[ClarosStop] = []
|
|
111
|
+
self.nudges: list[dict] = []
|
|
112
|
+
self._targets: set[str] = set()
|
|
113
|
+
self._kinds: dict[str, dict] = {}
|
|
114
|
+
|
|
115
|
+
# ---- Claros ----
|
|
116
|
+
def _http(self, path: str, body: Optional[dict] = None) -> dict:
|
|
117
|
+
req = urllib.request.Request(self.url + path, method="POST" if body is not None else "GET",
|
|
118
|
+
data=json.dumps(body).encode() if body is not None else None,
|
|
119
|
+
headers={"content-type": "application/json",
|
|
120
|
+
**({"authorization": f"Bearer {self.key}"} if self.key else {})})
|
|
121
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r: # noqa: S310 (the configured Claros URL)
|
|
122
|
+
return json.loads(r.read())
|
|
123
|
+
|
|
124
|
+
async def _kinds_for(self, origin: str) -> dict:
|
|
125
|
+
if origin not in self._kinds:
|
|
126
|
+
self._kinds[origin] = {}
|
|
127
|
+
if origin.startswith("http"):
|
|
128
|
+
try:
|
|
129
|
+
q = urllib.parse.urlencode({"app": origin})
|
|
130
|
+
self._kinds[origin] = (await asyncio.to_thread(self._http, f"/v1/tools/kinds?{q}")).get("kinds") or {}
|
|
131
|
+
except Exception: # noqa: BLE001 no kinds: the vocabulary decides
|
|
132
|
+
pass
|
|
133
|
+
return self._kinds[origin]
|
|
134
|
+
|
|
135
|
+
async def decide(self, item: dict, session: Any = None) -> dict:
|
|
136
|
+
t0 = time.perf_counter()
|
|
137
|
+
meta = {"app": item.get("app"), "action": item.get("action"), "url": item.get("url"),
|
|
138
|
+
"title": item.get("title"), "actor": self.actor, "workflow_id": self.workflow_id}
|
|
139
|
+
r, how, error = None, "page", None
|
|
140
|
+
try:
|
|
141
|
+
if (await self._kinds_for(item.get("app") or "")).get(item.get("action")) == "read":
|
|
142
|
+
r = {"allowed": True, "reason": "read (tool kinds)"}
|
|
143
|
+
elif not item.get("state") and not item.get("rows") and session is not None:
|
|
144
|
+
how = "screen" # canvas, cross-origin frame: read the pixels
|
|
145
|
+
img = await session.take_screenshot(format="jpeg", quality=70)
|
|
146
|
+
r = await asyncio.to_thread(self._http, "/v1/check/screen",
|
|
147
|
+
{**meta, "image": base64.b64encode(img).decode()})
|
|
148
|
+
else:
|
|
149
|
+
r = await asyncio.to_thread(self._http, "/v1/check/page",
|
|
150
|
+
{**meta, "state": item.get("state") or {}, "rows": item.get("rows") or []})
|
|
151
|
+
except Exception as e: # noqa: BLE001
|
|
152
|
+
error = f"timed out after {self.timeout}s" if isinstance(e, TimeoutError) else str(e) or type(e).__name__
|
|
153
|
+
d = {"action": item.get("action"), "kind": item.get("kind"), "url": item.get("url"), "via": how,
|
|
154
|
+
"ms": round((time.perf_counter() - t0) * 1000), "server_ms": (r or {}).get("latency_ms"),
|
|
155
|
+
"allowed": self.fail_open if error else bool(r.get("allowed")), "workflow_id": (r or {}).get("workflow_id"),
|
|
156
|
+
"violations": [v.get("guardrail_id") for v in (r or {}).get("violations", [])],
|
|
157
|
+
"check_id": (r or {}).get("check_id"), "error": error, "result": r}
|
|
158
|
+
self.decisions.append(d)
|
|
159
|
+
if d["allowed"] and nudges_of(r): # the action proceeds; the nudge goes back to the agent
|
|
160
|
+
d["nudge"] = {"action": item.get("action"), "url": item.get("url"), "text": nudge_text(r, item.get("action")),
|
|
161
|
+
"violations": nudges_of(r), "check_id": r.get("check_id")}
|
|
162
|
+
self.nudges.append(d["nudge"])
|
|
163
|
+
log.warning(d["nudge"]["text"])
|
|
164
|
+
if self.on_nudge:
|
|
165
|
+
self.on_nudge(d["nudge"])
|
|
166
|
+
if not d["allowed"]:
|
|
167
|
+
d["stop"] = (ClarosStop(None, f'Claros couldn\'t check "{item.get("action")}" ({error}), so it was not '
|
|
168
|
+
"done. Ask a person to confirm it is allowed before trying again.", item)
|
|
169
|
+
if error else ClarosStop(r, stop_text(r, item.get("action")), item))
|
|
170
|
+
self.stops.append(d["stop"])
|
|
171
|
+
return d
|
|
172
|
+
|
|
173
|
+
# ---- the page (CDP through browser-use's session) ----
|
|
174
|
+
async def _eval(self, session: Any, expr: str) -> Any:
|
|
175
|
+
cdp = await session.get_or_create_cdp_session()
|
|
176
|
+
r = await cdp.cdp_client.send.Runtime.evaluate(
|
|
177
|
+
params={"expression": expr, "returnByValue": True, "awaitPromise": True}, session_id=cdp.session_id)
|
|
178
|
+
return (r.get("result") or {}).get("value")
|
|
179
|
+
|
|
180
|
+
async def install(self, session: Any) -> None:
|
|
181
|
+
"""page.js on every new document of the focused tab (once per tab) and on the current one (idempotent)."""
|
|
182
|
+
cdp = await session.get_or_create_cdp_session()
|
|
183
|
+
tid = getattr(cdp, "target_id", None) or ""
|
|
184
|
+
if tid not in self._targets:
|
|
185
|
+
await session._cdp_add_init_script(PAGE_JS) # noqa: SLF001 browser-use 0.13.11: Page.addScriptToEvaluateOnNewDocument
|
|
186
|
+
self._targets.add(tid)
|
|
187
|
+
await self._eval(session, PAGE_JS)
|
|
188
|
+
origin = await self._eval(session, "location.origin") or ""
|
|
189
|
+
kinds = await self._kinds_for(origin)
|
|
190
|
+
if kinds:
|
|
191
|
+
await self._eval(session, f"window.__claros && window.__claros.setKinds({json.dumps(kinds)})")
|
|
192
|
+
|
|
193
|
+
async def drain(self, session: Any) -> Optional[ClarosStop]:
|
|
194
|
+
"""Take the held actions, check each, replay the allowed ones, drop the stopped ones. Main frame only."""
|
|
195
|
+
stop = None
|
|
196
|
+
try:
|
|
197
|
+
items = await self._eval(session, "window.__claros ? window.__claros.take() : []") or []
|
|
198
|
+
except Exception: # noqa: BLE001 the page is gone (navigation): anything it held went with it, unsent
|
|
199
|
+
items = []
|
|
200
|
+
for it in items:
|
|
201
|
+
d = await self.decide(it, session)
|
|
202
|
+
fn = "release" if d["allowed"] else "drop"
|
|
203
|
+
try:
|
|
204
|
+
await self._eval(session, f"window.__claros.{fn}({int(it['id'])})")
|
|
205
|
+
except Exception: # noqa: BLE001 navigated away: the held action is gone with its page
|
|
206
|
+
pass
|
|
207
|
+
stop = stop or d.get("stop")
|
|
208
|
+
return stop
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def guard_tools(tools: Any, **opts: Any) -> Guard:
|
|
212
|
+
"""Wraps a browser-use Tools (Controller) instance: every action runs with page.js installed, and the actions it
|
|
213
|
+
caused are checked before they reach the app. Returns the Guard (decisions, stops)."""
|
|
214
|
+
from browser_use.agent.views import ActionResult
|
|
215
|
+
|
|
216
|
+
g = Guard(**opts)
|
|
217
|
+
orig = tools.act
|
|
218
|
+
|
|
219
|
+
async def act(action: Any, browser_session: Any, *a: Any, **kw: Any) -> Any:
|
|
220
|
+
try:
|
|
221
|
+
await g.install(browser_session)
|
|
222
|
+
except Exception: # noqa: BLE001 no page yet (about:blank startup): nothing to hold
|
|
223
|
+
pass
|
|
224
|
+
res = await orig(action, browser_session, *a, **kw)
|
|
225
|
+
seen = len(g.nudges)
|
|
226
|
+
stop = await g.drain(browser_session)
|
|
227
|
+
if stop:
|
|
228
|
+
return ActionResult(error=str(stop))
|
|
229
|
+
if new := "\n\n".join(n["text"] for n in g.nudges[seen:]): # into the agent's memory, so it self-corrects
|
|
230
|
+
res = res if isinstance(res, ActionResult) else ActionResult()
|
|
231
|
+
res.extracted_content = "\n\n".join(x for x in (res.extracted_content, new) if x)
|
|
232
|
+
res.include_extracted_content_only_once = False
|
|
233
|
+
if res.long_term_memory: # set → extracted_content wouldn't reach the memory
|
|
234
|
+
res.long_term_memory += "\n\n" + new
|
|
235
|
+
return res
|
|
236
|
+
|
|
237
|
+
tools.act = act
|
|
238
|
+
tools.claros = g
|
|
239
|
+
return g
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
__all__ = ["ClarosStop", "Guard", "PAGE_JS", "guard_tools", "nudge_text", "stop_text"]
|