| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526 |
- """Agentic Validator 的受控公开网页搜索与读取适配层。
- 本模块只提供两个只读能力:搜索公开页面和读取已授权 URL。它不复用
- 业务 Browser,不持有 Cookie,也不提供点击、登录、脚本执行或文件下载。
- """
- from __future__ import annotations
- import asyncio
- from collections.abc import Awaitable, Callable, Mapping, Sequence
- from dataclasses import dataclass, field
- from datetime import datetime, timezone
- from hashlib import sha256
- from html.parser import HTMLParser
- import ipaddress
- import json
- import math
- import os
- import socket
- from typing import Any, Protocol
- from urllib.parse import urljoin, urlsplit, urlunsplit
- import httpx
- from cyber_agent.tools.registry import ToolRegistry
- MAX_SEARCH_RESULTS = 5
- MAX_REDIRECTS = 3
- MAX_RESPONSE_BYTES = 512 * 1024
- MAX_PAGE_CHARS = 16_000
- MAX_TRACE_PAGE_CHARS = 50_000
- class ValidatorWebError(RuntimeError):
- """Validator 网页工具的可呈现错误。"""
- class ProviderUnavailable(ValidatorWebError):
- """搜索 Provider未配置、无密钥或暂不可用。"""
- class UnsafeUrlError(ValidatorWebError):
- """URL、DNS或重定向越过公开网络只读边界。"""
- class ValidatorWebSearchProvider(Protocol):
- """搜索服务的可注入端口;实现只返回普通网页候选。"""
- async def search(
- self,
- query: str,
- max_results: int,
- ) -> "ValidatorSearchResponse | Sequence[Mapping[str, Any]]":
- ...
- @dataclass(frozen=True)
- class ValidatorSearchResponse:
- """搜索适配器返回的结果和可选 Provider 成本。"""
- results: Sequence[Mapping[str, Any]]
- cost_usd: float = 0.0
- def __post_init__(self) -> None:
- if (
- isinstance(self.cost_usd, bool)
- or not isinstance(self.cost_usd, (int, float))
- or not math.isfinite(float(self.cost_usd))
- or self.cost_usd < 0
- ):
- raise ValueError("Validator search cost must be non-negative")
- class SerperWebSearchProvider:
- """Serper Google Search API 的轻量适配器。"""
- endpoint = "https://google.serper.dev/search"
- def __init__(
- self,
- api_key: str | None = None,
- *,
- timeout_seconds: float = 15.0,
- cost_per_search_usd: float = 0.0,
- client_factory: Callable[[], httpx.AsyncClient] | None = None,
- ) -> None:
- self.api_key = (api_key or os.getenv("SERPER_API_KEY") or "").strip()
- self.timeout_seconds = timeout_seconds
- self.client_factory = client_factory
- self.cost_per_search_usd = ValidatorSearchResponse(
- results=[],
- cost_usd=cost_per_search_usd,
- ).cost_usd
- async def search(self, query: str, max_results: int) -> ValidatorSearchResponse:
- if not self.api_key:
- raise ProviderUnavailable("SERPER_API_KEY is not configured")
- client = (
- self.client_factory()
- if self.client_factory
- else httpx.AsyncClient(
- timeout=self.timeout_seconds,
- trust_env=False,
- follow_redirects=False,
- )
- )
- try:
- async with client:
- async with client.stream(
- "POST",
- self.endpoint,
- headers={
- "X-API-KEY": self.api_key,
- "Content-Type": "application/json",
- },
- json={"q": query, "num": max_results},
- ) as response:
- response.raise_for_status()
- chunks: list[bytes] = []
- size = 0
- async for chunk in response.aiter_bytes():
- size += len(chunk)
- if size > MAX_RESPONSE_BYTES:
- raise ProviderUnavailable(
- "Serper response exceeds the 512 KiB limit"
- )
- chunks.append(chunk)
- payload = json.loads(b"".join(chunks))
- except ProviderUnavailable:
- raise
- except (httpx.HTTPError, ValueError) as exc:
- raise ProviderUnavailable(f"Serper search failed: {exc}") from exc
- organic = payload.get("organic", []) if isinstance(payload, dict) else []
- return ValidatorSearchResponse(
- results=[
- item for item in organic if isinstance(item, Mapping)
- ][:max_results],
- cost_usd=self.cost_per_search_usd,
- )
- class _VisibleTextParser(HTMLParser):
- def __init__(self) -> None:
- super().__init__(convert_charrefs=True)
- self.title_parts: list[str] = []
- self.text_parts: list[str] = []
- self._ignored_depth = 0
- self._in_title = False
- def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
- del attrs
- lowered = tag.lower()
- if lowered in {"script", "style", "noscript", "svg"}:
- self._ignored_depth += 1
- if lowered == "title":
- self._in_title = True
- def handle_endtag(self, tag: str) -> None:
- lowered = tag.lower()
- if lowered in {"script", "style", "noscript", "svg"} and self._ignored_depth:
- self._ignored_depth -= 1
- if lowered == "title":
- self._in_title = False
- def handle_data(self, data: str) -> None:
- text = " ".join(data.split())
- if not text or self._ignored_depth:
- return
- self.text_parts.append(text)
- if self._in_title:
- self.title_parts.append(text)
- @property
- def title(self) -> str:
- return " ".join(self.title_parts).strip()
- @property
- def text(self) -> str:
- return "\n".join(self.text_parts).strip()
- def _utc_now() -> str:
- return datetime.now(timezone.utc).isoformat()
- def _source_id(url: str) -> str:
- return "src_" + sha256(url.encode("utf-8")).hexdigest()[:24]
- def _blocked_ip(address: str) -> bool:
- ip = ipaddress.ip_address(address)
- if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
- ip = ip.ipv4_mapped
- return any((
- ip.is_private,
- ip.is_loopback,
- ip.is_link_local,
- ip.is_multicast,
- ip.is_reserved,
- ip.is_unspecified,
- ))
- def normalize_public_url(url: str) -> str:
- """校验静态 URL 结构;DNS和每次重定向由异步检查继续确认。"""
- if not isinstance(url, str) or not url.strip():
- raise UnsafeUrlError("URL must be a non-empty string")
- parsed = urlsplit(url.strip())
- if parsed.scheme.lower() not in {"http", "https"}:
- raise UnsafeUrlError("Only HTTP(S) URLs are allowed")
- if parsed.username or parsed.password:
- raise UnsafeUrlError("URLs with user information are not allowed")
- host = (parsed.hostname or "").rstrip(".").lower()
- if not host or host == "localhost" or host.endswith(".local"):
- raise UnsafeUrlError("Local host names are not allowed")
- try:
- port = parsed.port
- except ValueError as exc:
- raise UnsafeUrlError(f"Invalid URL port: {exc}") from exc
- if port not in {None, 80, 443}:
- raise UnsafeUrlError("Only ports 80 and 443 are allowed")
- try:
- if _blocked_ip(host):
- raise UnsafeUrlError("Private or special-purpose IPs are not allowed")
- except ValueError:
- pass
- netloc = host
- if port is not None:
- netloc = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
- return urlunsplit((parsed.scheme.lower(), netloc, parsed.path or "/", parsed.query, ""))
- async def _default_resolve(host: str, port: int) -> Sequence[str]:
- loop = asyncio.get_running_loop()
- infos = await loop.getaddrinfo(
- host,
- port,
- family=socket.AF_UNSPEC,
- type=socket.SOCK_STREAM,
- )
- return list({item[4][0] for item in infos})
- async def require_public_dns(
- url: str,
- resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None,
- ) -> str:
- normalized, _host, _port, _addresses = await _resolve_public_url(
- url,
- resolver,
- )
- return normalized
- async def _resolve_public_url(
- url: str,
- resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None,
- ) -> tuple[str, str, int, list[str]]:
- """解析一次公网地址,并返回后续建立连接所需的冻结 IP。"""
- normalized = normalize_public_url(url)
- parsed = urlsplit(normalized)
- host = parsed.hostname or ""
- port = parsed.port or (443 if parsed.scheme == "https" else 80)
- try:
- raw_addresses = await (resolver or _default_resolve)(host, port)
- except (OSError, socket.gaierror) as exc:
- raise UnsafeUrlError(f"URL host could not be resolved: {exc}") from exc
- addresses = list(dict.fromkeys(raw_addresses))
- if not addresses:
- raise UnsafeUrlError("URL host resolved to no addresses")
- for address in addresses:
- if _blocked_ip(address):
- raise UnsafeUrlError(
- f"URL host resolves to a private or special-purpose IP: {address}"
- )
- return normalized, host, port, addresses
- def _pinned_request_url(url: str, address: str) -> str:
- """把逻辑 URL 替换为已校验 IP,防止 DNS 检查后再绑定。"""
- parsed = urlsplit(url)
- host = f"[{address}]" if ":" in address else address
- if parsed.port is not None:
- host = f"{host}:{parsed.port}"
- return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, ""))
- def _host_header(url: str) -> str:
- parsed = urlsplit(url)
- host = parsed.hostname or ""
- rendered = f"[{host}]" if ":" in host else host
- if parsed.port is not None:
- rendered = f"{rendered}:{parsed.port}"
- return rendered
- def _decode_page(body: bytes, content_type: str) -> tuple[str, str]:
- mime = content_type.split(";", 1)[0].strip().lower()
- allowed = {"text/html", "text/plain", "application/json", "text/json"}
- if mime not in allowed and not mime.endswith("+json"):
- raise ValidatorWebError(f"Unsupported page content type: {mime or 'unknown'}")
- text = body.decode("utf-8", errors="replace")
- if mime == "text/html":
- parser = _VisibleTextParser()
- parser.feed(text)
- return parser.title, parser.text
- if "json" in mime:
- try:
- parsed = json.loads(text)
- text = json.dumps(parsed, ensure_ascii=False, sort_keys=True)
- except ValueError:
- pass
- return "", text
- async def fetch_public_page(
- url: str,
- *,
- resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None,
- client_factory: Callable[[], httpx.AsyncClient] | None = None,
- ) -> dict[str, Any]:
- """逐跳校验并读取一个公开文本页面,响应正文严格限长。"""
- current = url
- client = (
- client_factory()
- if client_factory
- else httpx.AsyncClient(
- timeout=20.0,
- trust_env=False,
- follow_redirects=False,
- headers={"Accept": "text/html,text/plain,application/json"},
- )
- )
- async with client:
- for hop in range(MAX_REDIRECTS + 1):
- current, host, _port, addresses = await _resolve_public_url(
- current,
- resolver,
- )
- client.cookies.clear()
- request = client.build_request(
- "GET",
- _pinned_request_url(current, addresses[0]),
- headers={
- "Accept": "text/html,text/plain,application/json",
- "Connection": "close",
- "Host": _host_header(current),
- },
- )
- if urlsplit(current).scheme == "https":
- request.extensions["sni_hostname"] = host
- response = await client.send(request, stream=True)
- try:
- if response.status_code in {301, 302, 303, 307, 308}:
- location = response.headers.get("location")
- if not location:
- raise ValidatorWebError("Redirect response has no Location header")
- if hop >= MAX_REDIRECTS:
- raise ValidatorWebError("Too many redirects")
- current = urljoin(current, location)
- continue
- response.raise_for_status()
- chunks: list[bytes] = []
- size = 0
- async for chunk in response.aiter_bytes():
- size += len(chunk)
- if size > MAX_RESPONSE_BYTES:
- raise ValidatorWebError("Page exceeds the 512 KiB response limit")
- chunks.append(chunk)
- body = b"".join(chunks)
- title, text = _decode_page(
- body,
- response.headers.get("content-type", ""),
- )
- truncated = len(text) > MAX_PAGE_CHARS
- text = text[:MAX_PAGE_CHARS]
- final_url = current
- return {
- "source_id": _source_id(final_url),
- "url": url,
- "final_url": final_url,
- "title": title,
- "content_type": response.headers.get("content-type", ""),
- "retrieved_at": _utc_now(),
- "content_sha256": sha256(body).hexdigest(),
- "text": text,
- "truncated": truncated,
- "untrusted_material": True,
- }
- finally:
- await response.aclose()
- raise ValidatorWebError("Page could not be fetched")
- UsageRecorder = Callable[[int, int, float], Awaitable[None]]
- PageFetcher = Callable[..., Awaitable[dict[str, Any]]]
- @dataclass
- class ValidatorToolLimits:
- max_searches: int
- max_opens: int
- max_tool_calls: int
- @dataclass
- class ValidatorToolSession:
- """单个 Scope Validator Trace 的私有工具、白名单和调用账本。"""
- provider: ValidatorWebSearchProvider | None
- allowed_urls: set[str]
- limits: ValidatorToolLimits
- usage_recorder: UsageRecorder | None = None
- resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None
- page_fetcher: PageFetcher = fetch_public_page
- search_calls: int = 0
- open_calls: int = 0
- tool_calls: int = 0
- page_chars: int = 0
- opened_source_ids: set[str] = field(default_factory=set)
- _search_urls: set[str] = field(default_factory=set)
- def registry(self) -> ToolRegistry:
- registry = ToolRegistry()
- async def validator_web_search(query: str, max_results: int = 5) -> dict[str, Any]:
- return await self.search(query, max_results)
- async def validator_open_url(url: str) -> dict[str, Any]:
- return await self.open(url)
- registry.register(validator_web_search)
- registry.register(validator_open_url)
- return registry
- async def _reserve_call(self) -> None:
- self.tool_calls += 1
- if self.tool_calls > self.limits.max_tool_calls:
- raise ValidatorWebError("Validator tool-call limit reached")
- if self.usage_recorder:
- await self.usage_recorder(1, 0, 0.0)
- async def _record_material(
- self,
- material_chars: int,
- provider_cost_usd: float = 0.0,
- ) -> None:
- if (material_chars or provider_cost_usd) and self.usage_recorder:
- await self.usage_recorder(0, material_chars, provider_cost_usd)
- async def search(self, query: str, max_results: int = 5) -> dict[str, Any]:
- query = query.strip()
- if not query:
- raise ValidatorWebError("Search query must not be empty")
- if isinstance(max_results, bool) or not 1 <= max_results <= MAX_SEARCH_RESULTS:
- raise ValidatorWebError("max_results must be between 1 and 5")
- if self.search_calls >= self.limits.max_searches:
- raise ValidatorWebError("Validator search limit reached")
- self.search_calls += 1
- await self._reserve_call()
- if self.provider is None:
- return {
- "status": "provider_unavailable",
- "query": query,
- "results": [],
- }
- try:
- provider_result = await self.provider.search(query, max_results)
- except ProviderUnavailable as exc:
- return {
- "status": "provider_unavailable",
- "query": query,
- "error": str(exc),
- "results": [],
- }
- if isinstance(provider_result, ValidatorSearchResponse):
- raw_results = provider_result.results
- provider_cost_usd = provider_result.cost_usd
- else:
- raw_results = provider_result
- provider_cost_usd = 0.0
- results = []
- for index, item in enumerate(raw_results[:max_results], start=1):
- raw_url = str(item.get("url") or item.get("link") or "")
- try:
- normalized_url = normalize_public_url(raw_url)
- except ValidatorWebError:
- continue
- self._search_urls.add(normalized_url)
- results.append({
- "source_id": _source_id(normalized_url),
- "title": str(item.get("title") or ""),
- "url": normalized_url,
- "snippet": str(item.get("snippet") or ""),
- "rank": index,
- "retrieved_at": _utc_now(),
- })
- payload = {"status": "completed", "query": query, "results": results}
- await self._record_material(
- len(json.dumps(payload, ensure_ascii=False)),
- provider_cost_usd,
- )
- return payload
- async def open(self, url: str) -> dict[str, Any]:
- if self.open_calls >= self.limits.max_opens:
- raise ValidatorWebError("Validator page-open limit reached")
- normalized = normalize_public_url(url)
- normalized_allowed = {
- normalize_public_url(item) for item in self.allowed_urls
- }
- if normalized not in normalized_allowed and normalized not in self._search_urls:
- raise ValidatorWebError(
- "URL was not declared by the TaskReport or returned by this search session"
- )
- self.open_calls += 1
- await self._reserve_call()
- page = await self.page_fetcher(normalized, resolver=self.resolver)
- chars = len(str(page.get("text") or ""))
- if self.page_chars + chars > MAX_TRACE_PAGE_CHARS:
- raise ValidatorWebError("Validator page-text limit reached")
- self.page_chars += chars
- await self._record_material(chars)
- self.opened_source_ids.add(str(page["source_id"]))
- return page
|