Files
hermes-agent/tools/web_tools_extract.py
Teknium f7641ace78 fix(web): pair a cached body with its own final URL; keyless Firecrawl reports its redirect target
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).
2026-10-28 13:54:02 -07:00

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)