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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.