diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 861e68bb..0f50d12a 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -499,6 +499,13 @@ async def run_browser_agent( return None if not (sk_obj and steps): return None + # Audit finding: replay bypasses the per-tool gate and act-and-confirm, + # so a recorded Send/Submit must never re-fire silently. Those flows + # always run the live agent, which confirms before anything outward. + safe, why = browser_skills.replay_safety(steps) + if not safe: + logger.info(f"[browser-skills] skill on {host} not replayed: {why}; running the full agent so the send is confirmed") + return None replay_attempted = True logger.info(f"[browser-skills] REPLAY attempt: {len(steps)} steps on {host} (after {turns_spent} LLM turn(s))") rlog: list[dict] = [] diff --git a/backend/apps/agents/browser/browser_skills.py b/backend/apps/agents/browser/browser_skills.py index a3cc7707..d70461b5 100644 --- a/backend/apps/agents/browser/browser_skills.py +++ b/backend/apps/agents/browser/browser_skills.py @@ -287,6 +287,26 @@ def distill_steps(action_log: list[dict]) -> list[dict]: return steps +def replay_safety(steps: list[dict]) -> tuple[bool, str]: + """A skill with an outward-facing step (click Send/Submit/Pay, type into a + composer) must never auto-replay with zero LLM and zero confirmation; only + the live agent path confirms sends. Reuses the batch replayer's wordlist so + there is exactly one definition of "irreversible".""" + from backend.apps.agents.browser import browser_batch_replay + for i, s in enumerate(steps): + tool = s.get("tool", "") + p = s.get("params", {}) or {} + probe = None + if tool in ("BrowserClickByName", "BrowserClick"): + probe = {"action": "click", "name": p.get("name") or p.get("selector") or ""} + elif tool == "BrowserType": + probe = {"action": "type", "selector": p.get("selector") or ""} + if probe and browser_batch_replay.is_send_step(probe): + what = probe.get("name") or probe.get("selector") + return False, f"step {i+1} looks irreversible/outward-facing ({what!r})" + return True, "" + + def steps_are_persistable(steps: list[dict]) -> bool: """True only if NO step touches sensitive text / a password-shaped field / a tokenized URL. Sensitive skills stay in-memory; they never hit disk.""" diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py index 4be14b20..2a00045d 100644 --- a/backend/tests/test_browser_agent_loop.py +++ b/backend/tests/test_browser_agent_loop.py @@ -98,10 +98,12 @@ def _install(monkeypatch, primary, aux): found = "const spec=" in expr and 'const spec=""' not in expr return {"text": json.dumps({"ready": True, "quiet": 9999, "elems": 100, "found": found}), "url": DOC_URL} if action == "list_interactives": - return {"text": '1 interactive elements:\n[1]