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.
@@ -0,0 +1,12 @@
1
+ .env
2
+ data/*
3
+ node_modules/
4
+ .venv/
5
+ __pycache__/
6
+ .next/
7
+ *.db
8
+ !data/fixtures/
9
+ !data/demo/
10
+ !data/golden/
11
+ !data/judge_dev/
12
+ .env.bak
@@ -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"]