claros-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,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"]
|
claros_browser/page.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// Claros browser hook, in-page half. Injected into every frame by the harness adapter (Playwright: addInitScript;
|
|
2
|
+
// browser-use: Page.addScriptToEvaluateOnNewDocument). App-agnostic: no app names, selectors or URLs.
|
|
3
|
+
//
|
|
4
|
+
// A capture-phase listener on window sees every click / submit / Enter before the app does. A consequential one
|
|
5
|
+
// (a submit of a POST form, or a button/link whose accessible name says commit: submit, save, send, pay, approve,
|
|
6
|
+
// delete, deploy, merge, confirm … in several languages, or that Claros's tool kinds call a write) is held: its
|
|
7
|
+
// default and the app's handlers never run. The record the page shows is read at that moment. The harness takes the
|
|
8
|
+
// held actions (__claros.take()), checks them with Claros, then replays the allowed ones (release) and drops the
|
|
9
|
+
// stopped ones (drop). Reads and typing pass through untouched. No harness → held forever (fail closed).
|
|
10
|
+
(() => {
|
|
11
|
+
if (window.__claros) return;
|
|
12
|
+
|
|
13
|
+
const norm = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
|
|
14
|
+
const words = (s) => norm(s).toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
15
|
+
|
|
16
|
+
// commit vocabulary: whole words, and stems (prefixes) for inflected languages
|
|
17
|
+
const WORDS = new Set(("submit save send pay post book buy merge update create approve reject delete " +
|
|
18
|
+
"remove deploy confirm publish checkout transfer refund release execute archive purchase revoke grant " +
|
|
19
|
+
// de
|
|
20
|
+
"senden absenden speichern buchen bezahlen zahlen genehmigen freigeben löschen bestätigen einreichen " +
|
|
21
|
+
"übermitteln veröffentlichen erstellen anlegen überweisen erstatten ablehnen aktualisieren bereitstellen " +
|
|
22
|
+
// fr
|
|
23
|
+
"envoyer enregistrer soumettre payer approuver valider supprimer confirmer publier déployer créer fusionner " +
|
|
24
|
+
"rembourser rejeter commander " +
|
|
25
|
+
// es / pt / it
|
|
26
|
+
"enviar guardar pagar aprobar eliminar borrar confirmar publicar desplegar crear fusionar reembolsar rechazar " +
|
|
27
|
+
"actualizar salvar aprovar excluir implantar inviare salvare pagare approvare eliminare confermare pubblicare " +
|
|
28
|
+
// ru
|
|
29
|
+
"отправить сохранить оплатить утвердить согласовать удалить подтвердить опубликовать провести создать " +
|
|
30
|
+
"развернуть обновить отклонить оформить перевести вернуть").split(" "));
|
|
31
|
+
const STEMS = ["submit", "approv", "delet", "remov", "deploy", "confirm", "publish", "refund", "releas", "execut",
|
|
32
|
+
"archiv", "purchas", "revok", "absend", "speicher", "bezahl", "genehmig", "freigeb", "lösch", "bestätig",
|
|
33
|
+
"einreich", "übermittel", "veröffentlich", "erstell", "überweis", "erstatt", "ablehn", "aktualisier",
|
|
34
|
+
"enregistr", "approuv", "supprim", "déploy", "rembours", "aprob", "elimin", "despleg", "reembols", "rechaz",
|
|
35
|
+
"actualiz", "conferm", "pubblic", "отправ", "сохран", "оплат", "утверд", "согласов", "удал", "подтверд",
|
|
36
|
+
"опублик", "провед", "провест", "развер", "обнов", "отклон", "оформ"];
|
|
37
|
+
const PHRASES = ["place order", "sign off", "mettre à jour", "make payment"];
|
|
38
|
+
// "Cancel" / "Close" alone dismiss; with an object ("Cancel order", "Close ticket") they commit
|
|
39
|
+
const DISMISS = new Set(["cancel", "close", "abbrechen", "schließen", "annuler", "fermer", "cancelar", "cerrar",
|
|
40
|
+
"fechar", "annulla", "chiudi", "отмена", "отменить", "закрыть"]);
|
|
41
|
+
// a name starting with one of these navigates or reads, whatever follows ("Next: review and submit")
|
|
42
|
+
const BENIGN = new Set(["next", "back", "previous", "prev", "preview", "search", "filter", "find", "view", "show",
|
|
43
|
+
"open", "edit", "login", "weiter", "zurück", "vorschau", "suchen", "suivant", "précédent", "aperçu",
|
|
44
|
+
"rechercher", "siguiente", "anterior", "buscar", "avanti", "indietro", "далее", "назад", "поиск", "найти"]);
|
|
45
|
+
// in a dialog, the affirmative answer is the commit ("Deploy to production?" → "OK")
|
|
46
|
+
const YES = new Set(["ok", "yes", "ja", "oui", "sí", "si", "sim", "да", "continue", "proceed"]);
|
|
47
|
+
|
|
48
|
+
let kinds = {}; // Claros tool kinds for this app: {button name: "read" | "write"} (expert/LLM classified)
|
|
49
|
+
|
|
50
|
+
function commitName(name, inDialog) {
|
|
51
|
+
const k = kinds[norm(name)];
|
|
52
|
+
if (k === "write") return true;
|
|
53
|
+
if (k === "read") return false;
|
|
54
|
+
const w = words(name);
|
|
55
|
+
if (!w.length || BENIGN.has(w[0]) || (w[0] === "sign" && (w[1] === "in" || w[1] === "up")) ||
|
|
56
|
+
(w[0] === "log" && w[1] === "in")) return false;
|
|
57
|
+
if (DISMISS.has(w[0])) return w.length > 1 && !w.slice(1).every((x) => DISMISS.has(x));
|
|
58
|
+
if (inDialog && YES.has(w[0])) return true;
|
|
59
|
+
const joined = w.join(" ");
|
|
60
|
+
return w.some((x) => WORDS.has(x) || STEMS.some((s) => x.startsWith(s))) || PHRASES.some((p) => joined.includes(p));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------- DOM helpers (open shadow roots included) ----------
|
|
64
|
+
function all(root, sel) {
|
|
65
|
+
const out = [...root.querySelectorAll(sel)];
|
|
66
|
+
for (const el of root.querySelectorAll("*")) if (el.shadowRoot) out.push(...all(el.shadowRoot, sel));
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
const textOf = (el) => norm(el ? (el.innerText ?? el.textContent) : "");
|
|
70
|
+
function byIds(el, attr) {
|
|
71
|
+
const ids = (el.getAttribute(attr) || "").split(/\s+/).filter(Boolean);
|
|
72
|
+
const root = el.getRootNode();
|
|
73
|
+
return norm(ids.map((id) => textOf((root.getElementById ? root : document).getElementById(id))).join(" "));
|
|
74
|
+
}
|
|
75
|
+
const ACTIONABLE = "button, a[href], input[type=submit], input[type=button], input[type=image], [role=button], " +
|
|
76
|
+
"[role=link], [role=menuitem], summary";
|
|
77
|
+
|
|
78
|
+
function nameOf(el) {
|
|
79
|
+
return byIds(el, "aria-labelledby") || norm(el.getAttribute("aria-label")) ||
|
|
80
|
+
(el.tagName === "INPUT" ? norm(el.value || el.alt || (el.type === "submit" ? "Submit" : "")) : "") ||
|
|
81
|
+
textOf(el) || norm(el.getAttribute("title"));
|
|
82
|
+
}
|
|
83
|
+
const inDialog = (el) => !!el.closest("dialog, [role=dialog], [role=alertdialog]");
|
|
84
|
+
const disabled = (el) => el.disabled || el.getAttribute("aria-disabled") === "true";
|
|
85
|
+
|
|
86
|
+
/** The element a click lands on, if it's a consequential action: {el, name} or null. */
|
|
87
|
+
function classify(target) {
|
|
88
|
+
const el = target?.closest?.(ACTIONABLE);
|
|
89
|
+
if (!el || disabled(el)) return null;
|
|
90
|
+
const name = nameOf(el);
|
|
91
|
+
const form = el.form || null;
|
|
92
|
+
const submits = form && (el.type === "submit" || el.type === "image" ||
|
|
93
|
+
(el.tagName === "BUTTON" && !el.getAttribute("type")));
|
|
94
|
+
const post = submits && (el.getAttribute("formmethod") || form.getAttribute("method") || "get").toLowerCase() !== "get";
|
|
95
|
+
if (post) return { el, name: name || "Submit" };
|
|
96
|
+
// a row's own button inside a form ("Remove line") edits the form; the form's submit is the commit
|
|
97
|
+
if (!submits && el.closest("form") && el.closest("tr, [role=row], li")) return null;
|
|
98
|
+
if (commitName(name, inDialog(el))) return { el, name };
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------- reading the record ----------
|
|
103
|
+
const CONTROLS = "input, select, textarea, [contenteditable=''], [contenteditable=true], [role=textbox], " +
|
|
104
|
+
"[role=combobox], [role=checkbox], [role=switch], [role=radio], [role=spinbutton], [role=slider], " +
|
|
105
|
+
"[role=listbox], output";
|
|
106
|
+
const SKIP = new Set(["password", "submit", "button", "reset", "image", "file"]);
|
|
107
|
+
|
|
108
|
+
function clean(s) {
|
|
109
|
+
return norm(s).replace(/\s*[::*]+\s*$/, "").replace(/^\*\s*/, "");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function labelOf(c) {
|
|
113
|
+
const l = byIds(c, "aria-labelledby") || norm(c.getAttribute("aria-label"));
|
|
114
|
+
if (l) return clean(l);
|
|
115
|
+
const host = c.parentElement?.closest("[role=combobox]"); // an input inside a custom combobox
|
|
116
|
+
if (host) {
|
|
117
|
+
const h = byIds(host, "aria-labelledby") || norm(host.getAttribute("aria-label"));
|
|
118
|
+
if (h) return clean(h);
|
|
119
|
+
}
|
|
120
|
+
for (const lab of c.labels || []) {
|
|
121
|
+
const x = lab.cloneNode(true);
|
|
122
|
+
x.querySelectorAll(CONTROLS + ", option").forEach((n) => n.remove());
|
|
123
|
+
if (norm(x.textContent)) return clean(x.textContent);
|
|
124
|
+
}
|
|
125
|
+
const ph = c.getAttribute("placeholder") || c.getAttribute("aria-placeholder") || c.getAttribute("title");
|
|
126
|
+
if (ph) return clean(ph);
|
|
127
|
+
// nearby text: the closest text before the control, within a few ancestors that hold no other control
|
|
128
|
+
let n = c;
|
|
129
|
+
for (let depth = 0; n && depth < 3; depth++, n = n.parentElement) {
|
|
130
|
+
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
|
|
131
|
+
if (s.matches(CONTROLS) || s.querySelector(CONTROLS)) break;
|
|
132
|
+
const t = textOf(s);
|
|
133
|
+
if (t && t.length <= 60) return clean(t);
|
|
134
|
+
}
|
|
135
|
+
if (n.parentElement && all(n.parentElement, CONTROLS).length > 1) break;
|
|
136
|
+
}
|
|
137
|
+
return clean(c.getAttribute("name") || c.id || "");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function valueOf(c) {
|
|
141
|
+
const role = c.getAttribute("role");
|
|
142
|
+
if (c.tagName === "SELECT") {
|
|
143
|
+
const sel = [...c.selectedOptions].map((o) => norm(o.label || o.text));
|
|
144
|
+
return c.multiple ? sel.join(", ") : (sel[0] ?? "");
|
|
145
|
+
}
|
|
146
|
+
if (c.tagName === "INPUT" && (c.type === "checkbox" || c.type === "radio")) return c.checked ? 1 : 0;
|
|
147
|
+
if (role === "checkbox" || role === "switch" || role === "radio") return c.getAttribute("aria-checked") === "true" ? 1 : 0;
|
|
148
|
+
if (c.tagName === "INPUT" || c.tagName === "TEXTAREA") return c.value;
|
|
149
|
+
if (role === "spinbutton" || role === "slider") return c.getAttribute("aria-valuetext") ?? c.getAttribute("aria-valuenow") ?? textOf(c);
|
|
150
|
+
if (role === "listbox") return all(c, "[aria-selected=true]").map(textOf).join(", ");
|
|
151
|
+
if (role === "combobox") {
|
|
152
|
+
const inner = c.querySelector("input");
|
|
153
|
+
return inner ? inner.value : (c.getAttribute("aria-valuetext") ?? textOf(c));
|
|
154
|
+
}
|
|
155
|
+
return textOf(c);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const optionLabel = (r) => (r.labels?.[0] ? clean(r.labels[0].textContent) : "") || norm(r.getAttribute("aria-label")) ||
|
|
159
|
+
textOf(r) || r.value;
|
|
160
|
+
|
|
161
|
+
function groupLabel(r) {
|
|
162
|
+
const g = r.closest("[role=radiogroup], fieldset, [role=group]");
|
|
163
|
+
if (g) {
|
|
164
|
+
const l = byIds(g, "aria-labelledby") || norm(g.getAttribute("aria-label")) || textOf(g.querySelector("legend"));
|
|
165
|
+
if (l) return clean(l);
|
|
166
|
+
}
|
|
167
|
+
return clean(r.getAttribute("name") || "");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Each table / grid in the scope → rows of {column header: cell value}; a two-column table with row headers is
|
|
171
|
+
* key-value pairs instead. Returns the cells' controls so they aren't read again as fields. */
|
|
172
|
+
function tables(scope, state) {
|
|
173
|
+
const rows = [], used = new Set();
|
|
174
|
+
for (const t of all(scope, "table, [role=table], [role=grid]")) {
|
|
175
|
+
if (t.parentElement?.closest("table, [role=table], [role=grid]")) continue; // nested: the outer one reads it
|
|
176
|
+
const native = t.tagName === "TABLE";
|
|
177
|
+
const trs = native ? [...t.rows] : all(t, "[role=row]");
|
|
178
|
+
const cellsOf = (tr) => native ? [...tr.cells] : [...tr.querySelectorAll("[role=cell], [role=gridcell], [role=columnheader], [role=rowheader]")];
|
|
179
|
+
const head = trs.find((tr) => cellsOf(tr).length && cellsOf(tr).every((c) => c.tagName === "TH" || /header/.test(c.getAttribute("role") || "")));
|
|
180
|
+
const body = trs.filter((tr) => tr !== head && cellsOf(tr).length);
|
|
181
|
+
if (!head) { // row headers: <tr><th>Label</th><td>value</td></tr>
|
|
182
|
+
for (const tr of body) {
|
|
183
|
+
const c = cellsOf(tr);
|
|
184
|
+
if (c.length === 2 && (c[0].tagName === "TH" || c[0].getAttribute("role") === "rowheader")) {
|
|
185
|
+
state[clean(textOf(c[0]))] ??= cellValue(c[1], used);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const cols = cellsOf(head).map((c) => clean(textOf(c)));
|
|
191
|
+
for (const tr of body) {
|
|
192
|
+
const row = {};
|
|
193
|
+
cellsOf(tr).forEach((cell, i) => {
|
|
194
|
+
const v = cellValue(cell, used);
|
|
195
|
+
if (cols[i] && v !== "" && v != null) row[cols[i]] = v;
|
|
196
|
+
});
|
|
197
|
+
if (Object.keys(row).length) rows.push(row);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { rows, used };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function cellValue(cell, used) {
|
|
204
|
+
const cs = all(cell, CONTROLS).filter(field);
|
|
205
|
+
cs.forEach((c) => used.add(c));
|
|
206
|
+
if (!cs.length) return textOf(cell);
|
|
207
|
+
const vals = cs.map(valueOf).filter((v) => v !== "");
|
|
208
|
+
return vals.length === 1 ? vals[0] : vals.join(" ");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function field(c) {
|
|
212
|
+
if (c.tagName === "INPUT" && SKIP.has(c.type)) return false;
|
|
213
|
+
if (/^(BUTTON|FIELDSET|OBJECT)$/.test(c.tagName) && !c.getAttribute("role")) return false; // form.elements has these
|
|
214
|
+
if (c.closest("[aria-hidden=true]")) return false;
|
|
215
|
+
// a wrapper role (combobox around an input) is read through its input
|
|
216
|
+
if (!/^(INPUT|SELECT|TEXTAREA)$/.test(c.tagName) && c.querySelector("input, select, textarea")) return false;
|
|
217
|
+
// an option inside a listbox, a textbox inside a combobox: the outer control is the field
|
|
218
|
+
if (c.parentElement?.closest("[role=listbox], select")) return false;
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const holdsRecord = (n) => all(n, CONTROLS).some((c) => field(c) && c.type !== "hidden") || all(n, "dl, table").length > 0;
|
|
223
|
+
|
|
224
|
+
/** Where the record lives: the button's form if it holds fields, else the nearest ancestor that does (a dialog with
|
|
225
|
+
* fields; a bottom action bar or a field-less confirm dialog → the screen around it), else the whole page. */
|
|
226
|
+
function scopeOf(el) {
|
|
227
|
+
const f = el.form || el.closest("form, [role=form]");
|
|
228
|
+
if (f && holdsRecord(f)) return f;
|
|
229
|
+
// ponytail: nearest ancestor with any field; a search box beside the button would win over the record further up
|
|
230
|
+
for (let n = el.parentElement; n && n !== document.documentElement; n = n.parentElement) {
|
|
231
|
+
if (holdsRecord(n)) return n;
|
|
232
|
+
}
|
|
233
|
+
return document.body;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The record the page shows around `el`: {state: {label: value}, rows: [{column: value}]}. */
|
|
237
|
+
function record(el) {
|
|
238
|
+
const scope = scopeOf(el);
|
|
239
|
+
const state = {};
|
|
240
|
+
const { rows, used } = tables(scope, state);
|
|
241
|
+
const fields = [];
|
|
242
|
+
const radios = new Map();
|
|
243
|
+
const ctrls = new Set(all(scope, CONTROLS));
|
|
244
|
+
if (scope.elements) for (const c of scope.elements) ctrls.add(c); // form="…" controls outside the form
|
|
245
|
+
const isForm = scope.tagName === "FORM"; // a form submits its hidden steps too; elsewhere unrendered = not shown
|
|
246
|
+
for (const c of ctrls) {
|
|
247
|
+
if (used.has(c) || !field(c) || (!isForm && c.type !== "hidden" && !c.getClientRects().length)) continue;
|
|
248
|
+
if ((c.tagName === "INPUT" && c.type === "radio") || c.getAttribute("role") === "radio") {
|
|
249
|
+
const g = groupLabel(c);
|
|
250
|
+
if (!radios.has(g)) radios.set(g, "");
|
|
251
|
+
if (valueOf(c)) radios.set(g, optionLabel(c));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (c.tagName === "INPUT" && c.type === "hidden") continue; // read below, by name, last
|
|
255
|
+
const label = labelOf(c);
|
|
256
|
+
if (label) fields.push([label, valueOf(c)]);
|
|
257
|
+
}
|
|
258
|
+
for (const [g, v] of radios) if (g) fields.push([g, v]);
|
|
259
|
+
// line items without a table: the n-th repeat of a label belongs to row n
|
|
260
|
+
const count = {};
|
|
261
|
+
for (const [l] of fields) count[l] = (count[l] || 0) + 1;
|
|
262
|
+
const seen = {}, extra = [];
|
|
263
|
+
for (const [l, v] of fields) {
|
|
264
|
+
if (count[l] > 1) {
|
|
265
|
+
const i = (seen[l] = (seen[l] ?? -1) + 1);
|
|
266
|
+
(extra[i] ??= {})[l] = v;
|
|
267
|
+
} else state[l] = v;
|
|
268
|
+
}
|
|
269
|
+
rows.push(...extra.filter((r) => r && Object.keys(r).length));
|
|
270
|
+
// read-only summaries (a wizard's review step): <dl>
|
|
271
|
+
for (const dl of all(scope, "dl")) {
|
|
272
|
+
for (const dt of dl.querySelectorAll("dt")) {
|
|
273
|
+
const dd = dt.nextElementSibling;
|
|
274
|
+
if (dd?.tagName === "DD") state[clean(textOf(dt))] ??= textOf(dd);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (scope.elements) {
|
|
278
|
+
for (const c of scope.elements) if (c.type === "hidden" && c.name) state[c.name] ??= c.value;
|
|
279
|
+
}
|
|
280
|
+
delete state[""];
|
|
281
|
+
return { state, rows };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------- holding and replaying ----------
|
|
285
|
+
const held = new Map();
|
|
286
|
+
let seq = 0, pass = false;
|
|
287
|
+
|
|
288
|
+
function hold(e, item, replay) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
e.stopImmediatePropagation();
|
|
291
|
+
const id = ++seq;
|
|
292
|
+
let rec;
|
|
293
|
+
try { rec = record(item.el); } catch (err) { rec = { state: {}, rows: [], error: String(err) }; }
|
|
294
|
+
held.set(id, { replay, info: { id, kind: item.kind, action: item.name, app: location.origin, url: location.href,
|
|
295
|
+
title: document.title, frame: window !== window.top, ...rec } });
|
|
296
|
+
try { window.__clarosNotify?.(); } catch { /* no binding: the harness drains after its action */ }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function withPass(fn) {
|
|
300
|
+
pass = true;
|
|
301
|
+
try { fn(); } finally { pass = false; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
window.addEventListener("click", (e) => {
|
|
305
|
+
if (pass) return;
|
|
306
|
+
const c = classify(e.composedPath()[0] || e.target);
|
|
307
|
+
if (c) hold(e, { ...c, kind: "click" }, () => withPass(() => c.el.click()));
|
|
308
|
+
}, true);
|
|
309
|
+
|
|
310
|
+
window.addEventListener("submit", (e) => {
|
|
311
|
+
if (pass) return;
|
|
312
|
+
const form = e.target, sub = e.submitter;
|
|
313
|
+
const method = ((sub && sub.getAttribute("formmethod")) || form.getAttribute("method") || "get").toLowerCase();
|
|
314
|
+
const name = sub ? nameOf(sub) : (norm(form.getAttribute("aria-label")) || "Submit");
|
|
315
|
+
if (method === "get" && !commitName(name, false)) return; // a search form
|
|
316
|
+
hold(e, { el: sub || form, name, kind: "submit" },
|
|
317
|
+
() => withPass(() => (sub && sub.form === form ? form.requestSubmit(sub) : form.requestSubmit())));
|
|
318
|
+
}, true);
|
|
319
|
+
|
|
320
|
+
// Enter / Space on a custom (non-native) button: apps act on keydown there, no click follows
|
|
321
|
+
window.addEventListener("keydown", (e) => {
|
|
322
|
+
if (pass || (e.key !== "Enter" && e.key !== " ")) return;
|
|
323
|
+
const t = e.composedPath()[0] || e.target;
|
|
324
|
+
if (!t?.closest || t.closest("button, a[href], input, textarea, select")) return; // native: a click follows
|
|
325
|
+
const c = classify(t);
|
|
326
|
+
if (c) hold(e, { ...c, kind: "key" }, () => withPass(() => t.dispatchEvent(new KeyboardEvent("keydown",
|
|
327
|
+
{ key: e.key, code: e.code, bubbles: true, cancelable: true, composed: true }))));
|
|
328
|
+
}, true);
|
|
329
|
+
|
|
330
|
+
window.__claros = {
|
|
331
|
+
take: () => [...held.values()].filter((h) => !h.taken && (h.taken = true)).map((h) => h.info),
|
|
332
|
+
release: (id) => { const h = held.get(id); held.delete(id); if (h) h.replay(); return !!h; },
|
|
333
|
+
drop: (id) => held.delete(id),
|
|
334
|
+
setKinds: (k) => { kinds = k || {}; },
|
|
335
|
+
// for tests and the e2e report
|
|
336
|
+
classify: (el) => { const c = classify(el); return c && c.name; },
|
|
337
|
+
record: (el) => record(el),
|
|
338
|
+
commitName,
|
|
339
|
+
};
|
|
340
|
+
})();
|
|
@@ -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,6 @@
|
|
|
1
|
+
claros_browser/__init__.py,sha256=T-rQk5YEqZjEehw41QBcb9EsFGWA-0-ow6fy9Hr_jv0,13174
|
|
2
|
+
claros_browser/page.js,sha256=XIMlviezI2WSkSSKd8NMuMg7XHmidafEsAc44YezdRA,18281
|
|
3
|
+
claros_browser-0.1.0.dist-info/METADATA,sha256=kQoQdIMdHPG4NqXv3wM70w6xwr9gZbwq3XeaHol8jRU,1010
|
|
4
|
+
claros_browser-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
claros_browser-0.1.0.dist-info/licenses/LICENSE,sha256=Buv88Tdsa2GqRMkcoCi3yfBy9x8z22K7IwIS2M9lEa0,1072
|
|
6
|
+
claros_browser-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|