mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
The cache body and its final-URL annotation were separate writes, so two fetches of one key interleaved as A-put, B-put, B-final, A-final left B's body validated against A's destination. The index entry now records the body's sha256; the final-URL write is compare-and-set on it and a hit is served only when the body on disk matches the entry it was recorded for. The keyless Firecrawl path returned the requested URL and dropped metadata.url, so the shared redirect gate never saw the destination. It now reports metadata.url (sourceURL = requested).
305 lines
15 KiB
Python
305 lines
15 KiB
Python
"""web_extract helpers: URL validation, provider resolution, cache-aware dispatch.
|
|
|
|
Order of controls (each is a gate, never skipped by a cache hit): secret-URL
|
|
refusal -> SSRF filter (in web_tools.web_extract_tool) -> provider resolution
|
|
(strict selection) -> per-URL website policy -> disk cache -> vendor call with
|
|
one-shot keyless rescue -> website policy + SSRF on every page reported under a
|
|
URL that was not requested (redirect targets). Logs under the origin
|
|
(tools.web_tools) logger.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from tools.tool_backend_helpers import selection_error, selection_exists
|
|
from tools.url_safety import normalize_url_for_request
|
|
from tools.web_tools_rescue import _rescue_eligible, _rescue_extract
|
|
|
|
logger = logging.getLogger("tools.web_tools")
|
|
|
|
_NO_RESULT_ERROR = "Extract backend returned no result for this URL"
|
|
_DEFAULT_EXTRACT_TIMEOUT_S = 120.0
|
|
_EXTRACT_BACKENDS_HINT = "firecrawl, tavily, keenable, exa, or parallel."
|
|
_INVALID_ITEM_ERROR = (
|
|
"Invalid URL item at index {}: expected a URL string or an object with a string 'url' or 'href' field"
|
|
)
|
|
|
|
|
|
def _web_extract_url(value: Any) -> Optional[str]:
|
|
"""URL from a model-supplied extract item (str, or dict with ``url``/``href``); None if unusable.
|
|
|
|
Models sometimes forward a whole search result instead of its URL, hence the dict form. Never
|
|
stringify arbitrary objects into misleading fetch targets.
|
|
"""
|
|
if isinstance(value, dict):
|
|
value = value.get("url") or value.get("href")
|
|
return (value.strip() or None) if isinstance(value, str) else None
|
|
|
|
|
|
def _disabled_plugin_error(capability: str, disabled_key: str) -> str:
|
|
"""Error text when the configured backend's bundled plugin is disabled in config."""
|
|
vendor = disabled_key.split("/", 1)[-1]
|
|
return (
|
|
f"web.{capability}_backend is set to '{vendor}', but its plugin ('{disabled_key}') is disabled "
|
|
f"in config. Re-enable it with `hermes plugins enable {disabled_key}` "
|
|
"(or remove it from plugins.disabled)."
|
|
)
|
|
|
|
|
|
def _no_provider_error(capability: str, fallback: str) -> str:
|
|
"""Error when no provider resolved: point at a disabled bundled plugin if that is the real cause."""
|
|
from agent.web_search_registry import _disabled_web_plugin_for
|
|
disabled_key = _disabled_web_plugin_for(capability=capability)
|
|
return _disabled_plugin_error(capability, disabled_key) if disabled_key else fallback
|
|
|
|
|
|
def _strict_selection_error(capability: str, backend: str) -> str:
|
|
"""Error for a stored-but-unregistered backend: name the disabled plugin, else the bad selection.
|
|
Strict selection never silently switches to whatever the availability walk finds."""
|
|
failure = f"no registered web {capability} provider has that name"
|
|
return _no_provider_error(capability, selection_error("web", f"'{backend}'", failure))
|
|
|
|
|
|
def _result_entry(url: str, error: Optional[str]) -> dict[str, Any]:
|
|
return {"url": url, "title": "", "content": "", "error": error}
|
|
|
|
|
|
def _extract_error_json(error: str) -> str:
|
|
return json.dumps({"success": False, "error": error}, ensure_ascii=False)
|
|
|
|
|
|
def _refuse_all(error: str):
|
|
"""Whole-call refusal tuple for ``_validate_extract_urls`` (exfiltration prevention)."""
|
|
return None, None, None, json.dumps({"success": False, "error": error})
|
|
|
|
|
|
def _merge_in_order(
|
|
total: int, fixed: dict[int, dict], fetch_positions: list[int], fetch_urls: list[str], results: list[dict]
|
|
) -> list[dict]:
|
|
"""Rebuild a ``total``-long result list: *fixed* entries by position, fetched *results* at
|
|
*fetch_positions* (a short provider list yields ``_NO_RESULT_ERROR`` entries for the rest)."""
|
|
merged = dict(fixed)
|
|
for pos, position in enumerate(fetch_positions):
|
|
missing = _result_entry(fetch_urls[pos], _NO_RESULT_ERROR)
|
|
merged[position] = results[pos] if pos < len(results) else missing
|
|
return [merged[i] for i in range(total)]
|
|
|
|
|
|
def _validate_extract_urls(urls: list[Any]):
|
|
"""Normalize model-supplied items and block URLs carrying secrets (percent-encoded forms are unquoted
|
|
and checked too). Returns ``(normalized_urls, normalized_indices, invalid_urls, blocked_json)``;
|
|
``blocked_json`` is a whole-call refusal (exfiltration prevention) or None."""
|
|
from agent.redact import _PREFIX_RE
|
|
from urllib.parse import unquote
|
|
|
|
normalized_urls, normalized_indices, invalid_urls = [], [], {}
|
|
for index, item in enumerate(urls):
|
|
_url = _web_extract_url(item)
|
|
if _url is None:
|
|
invalid_urls[index] = _result_entry("", _INVALID_ITEM_ERROR.format(index))
|
|
continue
|
|
normalized_url = normalize_url_for_request(_url)
|
|
if any(_PREFIX_RE.search(c) for c in (_url, unquote(_url), normalized_url, unquote(normalized_url))):
|
|
return _refuse_all(
|
|
"Blocked: URL contains what appears to be an API key or token. "
|
|
"Secrets must not be sent in URLs."
|
|
)
|
|
normalized_urls.append(normalized_url)
|
|
normalized_indices.append(index)
|
|
return normalized_urls, normalized_indices, invalid_urls, None
|
|
|
|
|
|
def _resolve_extract_provider(backend: str):
|
|
"""Resolve the extract provider for *backend*; returns ``(provider, error_json)``.
|
|
|
|
A registered search-only backend is a typed error (never a silent switch). An unregistered name with
|
|
a stored web selection is a strict-selection error; with no selection, fall through to the walk.
|
|
"""
|
|
from agent.web_search_registry import get_active_extract_provider, get_provider as _wsp_get_provider
|
|
provider = _wsp_get_provider(backend) if backend else None
|
|
if provider is not None and provider.supports_extract():
|
|
return provider, None
|
|
if provider is not None:
|
|
return None, _extract_error_json(
|
|
f"{provider.display_name} is a search-only backend and cannot extract URL content. "
|
|
"Set web.extract_backend to " + _EXTRACT_BACKENDS_HINT
|
|
)
|
|
if backend and selection_exists("web"):
|
|
return None, _extract_error_json(_strict_selection_error("extract", backend))
|
|
provider = get_active_extract_provider()
|
|
if provider is None:
|
|
fallback = "No web extract provider configured. Set web.extract_backend to " + _EXTRACT_BACKENDS_HINT
|
|
return None, _extract_error_json(_no_provider_error("extract", fallback))
|
|
return provider, None
|
|
|
|
|
|
def _extract_timeout_seconds() -> float:
|
|
"""Wall-clock cap for one provider ``extract()`` dispatch (``web.extract_timeout``, default 120s).
|
|
|
|
A hanging backend (server keeps the response open without finishing) otherwise stalls the
|
|
tool call indefinitely. 0 or a negative value disables the cap.
|
|
"""
|
|
from tools.web_tools import _load_web_config
|
|
try:
|
|
return float(_load_web_config().get("extract_timeout", _DEFAULT_EXTRACT_TIMEOUT_S))
|
|
except (TypeError, ValueError):
|
|
return _DEFAULT_EXTRACT_TIMEOUT_S
|
|
|
|
|
|
def _backend_failed_whole_batch(results: list[dict]) -> bool:
|
|
from plugins.web._common import is_origin_http_error
|
|
errors = [r.get("error") for r in results]
|
|
return bool(errors) and all(errors) and not any(is_origin_http_error(e) for e in errors)
|
|
|
|
|
|
async def _fetch_with_rescue(provider, fetch_urls: list[str], format: Optional[str]) -> tuple[list[dict], bool]:
|
|
"""Call ``provider.extract`` (async or sync-in-thread), with one-shot keyless rescue.
|
|
|
|
Rescue fires on a raised exception — including a dispatch timeout — or when the WHOLE batch
|
|
failed (backend outage, not per-page problems): an origin HTTP 4xx/5xx is the page's own answer,
|
|
so a batch with one never triggers it. Returns ``(results, rescued)``.
|
|
"""
|
|
import inspect
|
|
timeout = _extract_timeout_seconds()
|
|
try:
|
|
if inspect.iscoroutinefunction(provider.extract):
|
|
coro = provider.extract(fetch_urls, format=format)
|
|
else: # sync extract() runs in a thread so network I/O never blocks the loop
|
|
coro = asyncio.to_thread(provider.extract, fetch_urls, format=format)
|
|
if timeout > 0:
|
|
results = await asyncio.wait_for(coro, timeout=timeout)
|
|
else:
|
|
results = await coro
|
|
except TimeoutError: # hanging backend — bounded, never a stalled tool call
|
|
logger.warning("web_extract provider '%s' timed out after %.0fs for %d URL(s)",
|
|
provider.name, timeout, len(fetch_urls))
|
|
failed = [_result_entry(u, f"Extract timed out after {timeout:.0f}s via {provider.name}")
|
|
for u in fetch_urls]
|
|
if not _rescue_eligible(provider):
|
|
return failed, False
|
|
return await asyncio.to_thread(_rescue_extract, provider.name, fetch_urls, failed), True
|
|
except Exception as exc:
|
|
if not _rescue_eligible(provider):
|
|
raise
|
|
failed = [_result_entry(u, str(exc)) for u in fetch_urls]
|
|
return await asyncio.to_thread(_rescue_extract, provider.name, fetch_urls, failed), True
|
|
if _backend_failed_whole_batch(results) and _rescue_eligible(provider):
|
|
return await asyncio.to_thread(_rescue_extract, provider.name, fetch_urls, results), True
|
|
return results, False
|
|
|
|
|
|
async def _final_url_refusal(fetched: dict) -> Optional[dict]:
|
|
"""Refusal entry when a page's reported (post-redirect) URL fails the website policy or SSRF gate,
|
|
in the shape Firecrawl's own redirect re-check returns; None when it passes. A policy check that
|
|
raises refuses (fail closed): the requested URL passed, but nothing vouches for this one."""
|
|
from tools.url_safety import async_is_safe_url
|
|
from tools.website_policy import check_website_access
|
|
final_url, title = fetched["url"], fetched.get("title", "")
|
|
refusal = {"url": final_url, "title": title, "content": "", "raw_content": ""}
|
|
try:
|
|
blocked = check_website_access(final_url)
|
|
except Exception as exc:
|
|
logger.warning("Website policy check failed for redirect target %s", final_url, exc_info=True)
|
|
return {**refusal, "error": f"Website policy check failed for redirect target: {exc}"}
|
|
if blocked:
|
|
logger.info("Blocked redirected web_extract for %s by rule %s", blocked["host"], blocked["rule"])
|
|
policy = {k: blocked[k] for k in ("host", "rule", "source")}
|
|
return {**refusal, "error": blocked["message"], "blocked_by_policy": policy}
|
|
if not await async_is_safe_url(final_url):
|
|
logger.info("Blocked redirected web_extract for unsafe final URL: %s", final_url)
|
|
return {**refusal, "error": "Blocked: URL targets a private or internal network address"}
|
|
return None
|
|
|
|
|
|
async def _gate_final_urls(results: list[dict], requested: set[str]) -> list[dict]:
|
|
"""Re-gate every page a provider reports under a URL other than one we asked for (a redirect
|
|
target, a canonicalised host): the SSRF and website-policy checks only ever saw the requested URL.
|
|
Covers every provider shape — Keenable reports the target in ``url`` with ``sourceURL`` = requested,
|
|
Exa/Parallel/Tavily report the vendor's URL in both — and rescued batches alike."""
|
|
gated = []
|
|
for fetched in results:
|
|
url = fetched.get("url")
|
|
if url and url not in requested and not fetched.get("error"):
|
|
fetched = await _final_url_refusal(fetched) or fetched
|
|
gated.append(fetched)
|
|
return gated
|
|
|
|
|
|
async def _dispatch_extract(provider, fetch_urls: list[str], format: Optional[str]) -> list[dict]:
|
|
"""Fetch *fetch_urls* (with keyless rescue), re-gate redirect targets, then cache the passes.
|
|
Rescued batches are never cached."""
|
|
from tools.web_result_cache import extract_cache_put, extract_cache_record_final_url
|
|
results, rescued = await _fetch_with_rescue(provider, fetch_urls, format)
|
|
requested = set(fetch_urls)
|
|
results = await _gate_final_urls(results, requested)
|
|
if rescued:
|
|
return results
|
|
|
|
# Cache each successful fetch under the REQUESTED url it reports as its own — never by list
|
|
# position: providers omit failed URLs or return successes out of request order, and a positional
|
|
# write filed one page's text under another URL's key for the whole TTL. ``metadata.sourceURL``
|
|
# counts because Keenable/Firecrawl put the requested URL there when ``url`` is the redirect target.
|
|
# An entry naming no requested URL is served but not cached (a miss re-fetches; a mis-key poisons).
|
|
for fetched in results:
|
|
meta = fetched.get("metadata")
|
|
source = meta.get("sourceURL") if isinstance(meta, dict) else None
|
|
url = next((u for u in (fetched.get("url"), source) if u in requested), None)
|
|
_content = fetched.get("raw_content", "") or fetched.get("content", "")
|
|
if url and _content and not fetched.get("error"):
|
|
stored = extract_cache_put(url, _content, fetched.get("title", ""), format=format, provider=provider.name)
|
|
if stored and fetched.get("url") and fetched["url"] != url:
|
|
extract_cache_record_final_url(url, fetched["url"], _content, format=format, provider=provider.name)
|
|
return results
|
|
|
|
|
|
async def _cached_final_url_allowed(hit: dict, requested: str) -> bool:
|
|
"""Gate a cache hit on the URL its content came from: a redirector cached before its target was
|
|
blocklisted (or turned private) must not keep serving that target. Consumes ``final_url``."""
|
|
final_url = hit.pop("final_url", requested)
|
|
if final_url == requested: # the requested URL already passed both gates
|
|
return True
|
|
from tools.url_safety import async_is_safe_url
|
|
from tools.website_policy import check_website_access
|
|
try:
|
|
if check_website_access(final_url) is not None:
|
|
return False
|
|
except Exception: # fail closed: a miss, and the live fetch re-gates the redirect
|
|
logger.warning("Website policy check failed for cached final URL %s", final_url, exc_info=True)
|
|
return False
|
|
return await async_is_safe_url(final_url)
|
|
|
|
|
|
async def _extract_safe_urls(provider, safe_urls: list[str], format: Optional[str]) -> list[dict]:
|
|
"""Serve cache hits, fetch the rest, and merge back in ``safe_urls`` order.
|
|
|
|
The disk cache (tools/web_result_cache.py) sits AFTER the secret-URL gate, SSRF gate, and provider
|
|
resolution, and is gated per-URL on the website policy — a hit skips only the vendor call, never a
|
|
control; policy-blocked URLs are cache misses, judged on both the requested and the cached final URL. Keys include provider and format, so switching either
|
|
within the TTL never serves the other's content."""
|
|
from tools.web_result_cache import extract_cache_get
|
|
from tools.website_policy import check_website_access as _check_site
|
|
cached_results, fetch_urls, fetch_positions = {}, [], []
|
|
for position, url in enumerate(safe_urls):
|
|
try:
|
|
_policy_block = _check_site(url)
|
|
except Exception:
|
|
_policy_block = None
|
|
hit = extract_cache_get(url, format=format, provider=provider.name) if _policy_block is None else None
|
|
if hit is not None and not await _cached_final_url_allowed(hit, url):
|
|
hit = None # a miss: the live fetch re-checks the redirect and refuses with blocked_by_policy
|
|
if hit is not None:
|
|
cached_results[position] = hit
|
|
else:
|
|
fetch_urls.append(url)
|
|
fetch_positions.append(position)
|
|
|
|
if not fetch_urls:
|
|
return [cached_results[i] for i in range(len(safe_urls))]
|
|
logger.info("Web extract via %s: %d URL(s)", provider.name, len(fetch_urls))
|
|
results = await _dispatch_extract(provider, fetch_urls, format)
|
|
if not cached_results:
|
|
return results
|
|
return _merge_in_order(len(safe_urls), cached_results, fetch_positions, fetch_urls, results)
|