Files
hermes-agent/tools/browser_tool_eval_policy.py

260 lines
11 KiB
Python

"""browser_console(expression=...) policy: SSRF guard gating, private-URL probes,
and the opt-in sensitive-primitive denylist.
Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``)
so ``patch("tools.browser_tool.X")`` in tests keeps working; this module must not
import ``tools.browser_tool`` at import time (cycle).
"""
import re
from typing import Optional
from tools.browser_tool_origin import origin_module as _origin
def _eval_ssrf_guard_active(effective_task_id: str) -> bool:
"""Return True when eval-driven private-network access must be guarded.
Matches the gating used by ``browser_navigate`` / ``browser_snapshot`` /
``browser_vision``: the SSRF guard is only meaningful for non-local
backends (cloud browser, or a containerized terminal whose browser-on-host
can reach internal networks the terminal can't), and is skipped for local
sidecar sessions and when ``allow_private_urls`` is set.
"""
_bt = _origin()
return (
not _bt._is_local_backend()
and not _bt._is_local_sidecar_key(effective_task_id)
and not _bt._allow_private_urls()
)
# URL-shaped literals embedded in a JS expression (http/https only). Used to
# pre-screen ``browser_console(expression=...)`` calls that fetch/XHR/navigate
# to a private host directly — that path never updates ``location.href`` so the
# post-eval page-URL recheck below can't see it.
_JS_URL_LITERAL_RE = re.compile(r"""https?://[^\s'"`)\]<>]+""", re.IGNORECASE)
def _expression_targets_private_url(expression: str) -> Optional[str]:
"""Return the first private/always-blocked URL literal in a JS expression.
Best-effort: scans for ``http(s)://...`` literals (fetch/XHR/navigation
targets the agent may have embedded) and returns the first one that targets
a private/internal address or the always-blocked cloud-metadata floor.
Returns ``None`` when no such literal is found.
"""
_bt = _origin()
if not isinstance(expression, str):
return None
for match in _JS_URL_LITERAL_RE.findall(expression):
candidate = match.rstrip(".,;")
if _bt._is_always_blocked_url(candidate) or not _bt._is_safe_url(candidate):
return candidate
return None
def _current_page_private_url(effective_task_id: str) -> Optional[str]:
"""Return the current page URL when it targets a private/internal address.
Reads ``window.location.href`` via a low-cost eval and returns it when the
page has been navigated (e.g. via ``location.href = '...'`` in a prior
eval) to an address the SSRF guard would reject. Returns ``None`` when the
page is public, the URL can't be determined, or the check errors (fail-open
on probe failure, matching the snapshot/vision guards).
"""
_bt = _origin()
try:
url_result = _bt._run_browser_command(
effective_task_id, "eval", ["window.location.href"], timeout=5, _engine_override="auto"
)
if url_result.get("success"):
current_url = (
url_result.get("data", {}).get("result", "") .strip().strip('"').strip("'")
)
if current_url and (
_bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url)
):
return current_url
except Exception as exc:
_bt.logger.debug("_current_page_private_url: probe failed (%s)", exc)
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"),
)
_JS_STRING_LITERAL_RE = re.compile(
r"""'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`""", re.S
)
_SENSITIVE_BROWSER_EVAL_TOKENS: tuple[tuple[str, str], ...] = (
("cookie", "document.cookie"),
("localStorage", "web storage"),
("sessionStorage", "web storage"),
("indexedDB", "IndexedDB"),
("caches", "Cache Storage"),
("clipboard", "navigator sensitive API"),
("credentials", "navigator sensitive API"),
("serviceWorker", "navigator sensitive API"),
("fetch", "network request"),
("XMLHttpRequest", "network request"),
("WebSocket", "network request"),
("EventSource", "network request"),
("sendBeacon", "network beacon"),
)
def _allow_unsafe_browser_evaluate() -> bool:
"""Return whether sensitive browser JS evaluation is explicitly allowed.
When true, ``browser_console(expression=...)`` runs without the
sensitive-primitive denylist even if ``browser.restrict_evaluate`` is set.
"""
_bt = _origin()
return _bt._browser_cfg(
"allow_unsafe_evaluate", False,
lambda v: _bt.is_truthy_value(v, default=False),
"browser.allow_unsafe_evaluate from config",
)
def _restrict_browser_evaluate() -> bool:
"""Return whether the sensitive-primitive eval denylist is enabled.
Off by default. ``browser_console(expression=...)`` is the agent's only
programmatic page-inspection path, and the denylist blocks the *names* of
common primitives (``fetch``, ``cookie``, ``querySelector(...input...)``)
rather than any actual exfiltration — which also blocks a large class of
legitimate DOM extraction (any selector or page script text containing
those words). Egress itself is still gated by the SSRF/private-URL guards
in ``_browser_eval`` regardless of this setting. Users who want the
strict vocabulary denylist (e.g. when browsing hostile pages with a
logged-in profile) opt in with ``browser.restrict_evaluate: true``;
``browser.allow_unsafe_evaluate: true`` overrides it back off.
"""
_bt = _origin()
return _bt._browser_cfg(
"restrict_evaluate", False,
lambda v: _bt.is_truthy_value(v, default=False),
"browser.restrict_evaluate from config",
)
def _decode_js_string_literal(literal: str) -> str:
"""Best-effort decode of a JavaScript string literal for policy checks.
This is not a JS parser. It only normalizes common escaped property names
such as ``document["co\\x6fkie"]`` before the fail-closed sensitive-token
check below.
"""
if len(literal) < 2:
return literal
body = literal[1:-1]
try:
return bytes(body, "utf-8").decode("unicode_escape")
except Exception:
return body
def _decoded_js_string_literals(expression: str) -> list[str]:
return [_decode_js_string_literal(match.group(0)) for match in _JS_STRING_LITERAL_RE.finditer(expression)]
def _sensitive_browser_eval_token_reason(expression: str) -> Optional[str]:
"""Return a risk reason for direct or quoted sensitive browser primitives.
``browser_console(expression=...)`` executes in the page origin. A denylist
that only searches direct spellings like ``document.cookie`` and ``fetch(``
misses equivalent JavaScript property access such as ``document["cookie"]``
or ``globalThis["fetch"](...)``. Treat sensitive primitive names as risky
whether they appear as identifiers or decoded string-literal property names.
Concatenating all string literals catches simple obfuscations like
``document["coo" + "kie"]`` while the config opt-in preserves the escape
hatch for trusted pages.
"""
string_literals = _decoded_js_string_literals(expression)
concatenated_literals = "".join(string_literals).lower()
for token, reason in _SENSITIVE_BROWSER_EVAL_TOKENS:
if re.search(rf"\b{re.escape(token)}\b", expression, re.I):
return reason
token_lower = token.lower()
if any(token_lower in literal.lower() for literal in string_literals):
return reason
if token_lower in concatenated_literals:
return reason
return None
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 _sensitive_browser_eval_token_reason(expression)
def _enforce_browser_eval_policy(expression: str) -> Optional[str]:
"""Block sensitive browser JS evaluation when the opt-in denylist is on.
The denylist is opt-in (``browser.restrict_evaluate: true``) because it
gates on primitive *names*, which cripples legitimate DOM extraction —
see ``_restrict_browser_evaluate``. Network egress to private/internal
addresses is enforced separately in ``_browser_eval`` and does not depend
on this policy.
"""
_bt = _origin()
if not _bt._restrict_browser_evaluate():
return None
if _bt._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}) while browser.restrict_evaluate is "
"enabled. Use browser_snapshot/browser_get_images/browser_console "
"without expression for normal inspection, or set "
"browser.restrict_evaluate: false in config.yaml to allow "
"programmatic evaluation."
)
def _camofox_current_page_private_url(tab_id: str, user_id: str) -> Optional[str]:
"""Return the Camofox page URL when it targets a private/internal address.
Camofox analogue of ``_current_page_private_url`` (evaluate endpoint instead
of the agent-browser CLI). Returns ``None`` when the page is public, the URL
can't be determined, or the probe errors (fail-open on probe failure,
matching the snapshot/vision guards — do not change to fail-closed without
also changing the sibling).
"""
_bt = _origin()
try:
from tools.browser_camofox import _post
data = _post(
f"/tabs/{tab_id}/evaluate",
body={"expression": "window.location.href", "userId": user_id},
)
current_url = str(data.get("result") if isinstance(data, dict) else data or "")
current_url = current_url.strip().strip('"').strip("'")
if current_url and (_bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url)):
return current_url
except Exception as exc:
_bt.logger.debug("_camofox_current_page_private_url: probe failed (%s)", exc)
return None