validator_web.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526
  1. """Agentic Validator 的受控公开网页搜索与读取适配层。
  2. 本模块只提供两个只读能力:搜索公开页面和读取已授权 URL。它不复用
  3. 业务 Browser,不持有 Cookie,也不提供点击、登录、脚本执行或文件下载。
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. from collections.abc import Awaitable, Callable, Mapping, Sequence
  8. from dataclasses import dataclass, field
  9. from datetime import datetime, timezone
  10. from hashlib import sha256
  11. from html.parser import HTMLParser
  12. import ipaddress
  13. import json
  14. import math
  15. import os
  16. import socket
  17. from typing import Any, Protocol
  18. from urllib.parse import urljoin, urlsplit, urlunsplit
  19. import httpx
  20. from cyber_agent.tools.registry import ToolRegistry
  21. MAX_SEARCH_RESULTS = 5
  22. MAX_REDIRECTS = 3
  23. MAX_RESPONSE_BYTES = 512 * 1024
  24. MAX_PAGE_CHARS = 16_000
  25. MAX_TRACE_PAGE_CHARS = 50_000
  26. class ValidatorWebError(RuntimeError):
  27. """Validator 网页工具的可呈现错误。"""
  28. class ProviderUnavailable(ValidatorWebError):
  29. """搜索 Provider未配置、无密钥或暂不可用。"""
  30. class UnsafeUrlError(ValidatorWebError):
  31. """URL、DNS或重定向越过公开网络只读边界。"""
  32. class ValidatorWebSearchProvider(Protocol):
  33. """搜索服务的可注入端口;实现只返回普通网页候选。"""
  34. async def search(
  35. self,
  36. query: str,
  37. max_results: int,
  38. ) -> "ValidatorSearchResponse | Sequence[Mapping[str, Any]]":
  39. ...
  40. @dataclass(frozen=True)
  41. class ValidatorSearchResponse:
  42. """搜索适配器返回的结果和可选 Provider 成本。"""
  43. results: Sequence[Mapping[str, Any]]
  44. cost_usd: float = 0.0
  45. def __post_init__(self) -> None:
  46. if (
  47. isinstance(self.cost_usd, bool)
  48. or not isinstance(self.cost_usd, (int, float))
  49. or not math.isfinite(float(self.cost_usd))
  50. or self.cost_usd < 0
  51. ):
  52. raise ValueError("Validator search cost must be non-negative")
  53. class SerperWebSearchProvider:
  54. """Serper Google Search API 的轻量适配器。"""
  55. endpoint = "https://google.serper.dev/search"
  56. def __init__(
  57. self,
  58. api_key: str | None = None,
  59. *,
  60. timeout_seconds: float = 15.0,
  61. cost_per_search_usd: float = 0.0,
  62. client_factory: Callable[[], httpx.AsyncClient] | None = None,
  63. ) -> None:
  64. self.api_key = (api_key or os.getenv("SERPER_API_KEY") or "").strip()
  65. self.timeout_seconds = timeout_seconds
  66. self.client_factory = client_factory
  67. self.cost_per_search_usd = ValidatorSearchResponse(
  68. results=[],
  69. cost_usd=cost_per_search_usd,
  70. ).cost_usd
  71. async def search(self, query: str, max_results: int) -> ValidatorSearchResponse:
  72. if not self.api_key:
  73. raise ProviderUnavailable("SERPER_API_KEY is not configured")
  74. client = (
  75. self.client_factory()
  76. if self.client_factory
  77. else httpx.AsyncClient(
  78. timeout=self.timeout_seconds,
  79. trust_env=False,
  80. follow_redirects=False,
  81. )
  82. )
  83. try:
  84. async with client:
  85. async with client.stream(
  86. "POST",
  87. self.endpoint,
  88. headers={
  89. "X-API-KEY": self.api_key,
  90. "Content-Type": "application/json",
  91. },
  92. json={"q": query, "num": max_results},
  93. ) as response:
  94. response.raise_for_status()
  95. chunks: list[bytes] = []
  96. size = 0
  97. async for chunk in response.aiter_bytes():
  98. size += len(chunk)
  99. if size > MAX_RESPONSE_BYTES:
  100. raise ProviderUnavailable(
  101. "Serper response exceeds the 512 KiB limit"
  102. )
  103. chunks.append(chunk)
  104. payload = json.loads(b"".join(chunks))
  105. except ProviderUnavailable:
  106. raise
  107. except (httpx.HTTPError, ValueError) as exc:
  108. raise ProviderUnavailable(f"Serper search failed: {exc}") from exc
  109. organic = payload.get("organic", []) if isinstance(payload, dict) else []
  110. return ValidatorSearchResponse(
  111. results=[
  112. item for item in organic if isinstance(item, Mapping)
  113. ][:max_results],
  114. cost_usd=self.cost_per_search_usd,
  115. )
  116. class _VisibleTextParser(HTMLParser):
  117. def __init__(self) -> None:
  118. super().__init__(convert_charrefs=True)
  119. self.title_parts: list[str] = []
  120. self.text_parts: list[str] = []
  121. self._ignored_depth = 0
  122. self._in_title = False
  123. def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
  124. del attrs
  125. lowered = tag.lower()
  126. if lowered in {"script", "style", "noscript", "svg"}:
  127. self._ignored_depth += 1
  128. if lowered == "title":
  129. self._in_title = True
  130. def handle_endtag(self, tag: str) -> None:
  131. lowered = tag.lower()
  132. if lowered in {"script", "style", "noscript", "svg"} and self._ignored_depth:
  133. self._ignored_depth -= 1
  134. if lowered == "title":
  135. self._in_title = False
  136. def handle_data(self, data: str) -> None:
  137. text = " ".join(data.split())
  138. if not text or self._ignored_depth:
  139. return
  140. self.text_parts.append(text)
  141. if self._in_title:
  142. self.title_parts.append(text)
  143. @property
  144. def title(self) -> str:
  145. return " ".join(self.title_parts).strip()
  146. @property
  147. def text(self) -> str:
  148. return "\n".join(self.text_parts).strip()
  149. def _utc_now() -> str:
  150. return datetime.now(timezone.utc).isoformat()
  151. def _source_id(url: str) -> str:
  152. return "src_" + sha256(url.encode("utf-8")).hexdigest()[:24]
  153. def _blocked_ip(address: str) -> bool:
  154. ip = ipaddress.ip_address(address)
  155. if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
  156. ip = ip.ipv4_mapped
  157. return any((
  158. ip.is_private,
  159. ip.is_loopback,
  160. ip.is_link_local,
  161. ip.is_multicast,
  162. ip.is_reserved,
  163. ip.is_unspecified,
  164. ))
  165. def normalize_public_url(url: str) -> str:
  166. """校验静态 URL 结构;DNS和每次重定向由异步检查继续确认。"""
  167. if not isinstance(url, str) or not url.strip():
  168. raise UnsafeUrlError("URL must be a non-empty string")
  169. parsed = urlsplit(url.strip())
  170. if parsed.scheme.lower() not in {"http", "https"}:
  171. raise UnsafeUrlError("Only HTTP(S) URLs are allowed")
  172. if parsed.username or parsed.password:
  173. raise UnsafeUrlError("URLs with user information are not allowed")
  174. host = (parsed.hostname or "").rstrip(".").lower()
  175. if not host or host == "localhost" or host.endswith(".local"):
  176. raise UnsafeUrlError("Local host names are not allowed")
  177. try:
  178. port = parsed.port
  179. except ValueError as exc:
  180. raise UnsafeUrlError(f"Invalid URL port: {exc}") from exc
  181. if port not in {None, 80, 443}:
  182. raise UnsafeUrlError("Only ports 80 and 443 are allowed")
  183. try:
  184. if _blocked_ip(host):
  185. raise UnsafeUrlError("Private or special-purpose IPs are not allowed")
  186. except ValueError:
  187. pass
  188. netloc = host
  189. if port is not None:
  190. netloc = f"[{host}]:{port}" if ":" in host else f"{host}:{port}"
  191. return urlunsplit((parsed.scheme.lower(), netloc, parsed.path or "/", parsed.query, ""))
  192. async def _default_resolve(host: str, port: int) -> Sequence[str]:
  193. loop = asyncio.get_running_loop()
  194. infos = await loop.getaddrinfo(
  195. host,
  196. port,
  197. family=socket.AF_UNSPEC,
  198. type=socket.SOCK_STREAM,
  199. )
  200. return list({item[4][0] for item in infos})
  201. async def require_public_dns(
  202. url: str,
  203. resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None,
  204. ) -> str:
  205. normalized, _host, _port, _addresses = await _resolve_public_url(
  206. url,
  207. resolver,
  208. )
  209. return normalized
  210. async def _resolve_public_url(
  211. url: str,
  212. resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None,
  213. ) -> tuple[str, str, int, list[str]]:
  214. """解析一次公网地址,并返回后续建立连接所需的冻结 IP。"""
  215. normalized = normalize_public_url(url)
  216. parsed = urlsplit(normalized)
  217. host = parsed.hostname or ""
  218. port = parsed.port or (443 if parsed.scheme == "https" else 80)
  219. try:
  220. raw_addresses = await (resolver or _default_resolve)(host, port)
  221. except (OSError, socket.gaierror) as exc:
  222. raise UnsafeUrlError(f"URL host could not be resolved: {exc}") from exc
  223. addresses = list(dict.fromkeys(raw_addresses))
  224. if not addresses:
  225. raise UnsafeUrlError("URL host resolved to no addresses")
  226. for address in addresses:
  227. if _blocked_ip(address):
  228. raise UnsafeUrlError(
  229. f"URL host resolves to a private or special-purpose IP: {address}"
  230. )
  231. return normalized, host, port, addresses
  232. def _pinned_request_url(url: str, address: str) -> str:
  233. """把逻辑 URL 替换为已校验 IP,防止 DNS 检查后再绑定。"""
  234. parsed = urlsplit(url)
  235. host = f"[{address}]" if ":" in address else address
  236. if parsed.port is not None:
  237. host = f"{host}:{parsed.port}"
  238. return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, ""))
  239. def _host_header(url: str) -> str:
  240. parsed = urlsplit(url)
  241. host = parsed.hostname or ""
  242. rendered = f"[{host}]" if ":" in host else host
  243. if parsed.port is not None:
  244. rendered = f"{rendered}:{parsed.port}"
  245. return rendered
  246. def _decode_page(body: bytes, content_type: str) -> tuple[str, str]:
  247. mime = content_type.split(";", 1)[0].strip().lower()
  248. allowed = {"text/html", "text/plain", "application/json", "text/json"}
  249. if mime not in allowed and not mime.endswith("+json"):
  250. raise ValidatorWebError(f"Unsupported page content type: {mime or 'unknown'}")
  251. text = body.decode("utf-8", errors="replace")
  252. if mime == "text/html":
  253. parser = _VisibleTextParser()
  254. parser.feed(text)
  255. return parser.title, parser.text
  256. if "json" in mime:
  257. try:
  258. parsed = json.loads(text)
  259. text = json.dumps(parsed, ensure_ascii=False, sort_keys=True)
  260. except ValueError:
  261. pass
  262. return "", text
  263. async def fetch_public_page(
  264. url: str,
  265. *,
  266. resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None,
  267. client_factory: Callable[[], httpx.AsyncClient] | None = None,
  268. ) -> dict[str, Any]:
  269. """逐跳校验并读取一个公开文本页面,响应正文严格限长。"""
  270. current = url
  271. client = (
  272. client_factory()
  273. if client_factory
  274. else httpx.AsyncClient(
  275. timeout=20.0,
  276. trust_env=False,
  277. follow_redirects=False,
  278. headers={"Accept": "text/html,text/plain,application/json"},
  279. )
  280. )
  281. async with client:
  282. for hop in range(MAX_REDIRECTS + 1):
  283. current, host, _port, addresses = await _resolve_public_url(
  284. current,
  285. resolver,
  286. )
  287. client.cookies.clear()
  288. request = client.build_request(
  289. "GET",
  290. _pinned_request_url(current, addresses[0]),
  291. headers={
  292. "Accept": "text/html,text/plain,application/json",
  293. "Connection": "close",
  294. "Host": _host_header(current),
  295. },
  296. )
  297. if urlsplit(current).scheme == "https":
  298. request.extensions["sni_hostname"] = host
  299. response = await client.send(request, stream=True)
  300. try:
  301. if response.status_code in {301, 302, 303, 307, 308}:
  302. location = response.headers.get("location")
  303. if not location:
  304. raise ValidatorWebError("Redirect response has no Location header")
  305. if hop >= MAX_REDIRECTS:
  306. raise ValidatorWebError("Too many redirects")
  307. current = urljoin(current, location)
  308. continue
  309. response.raise_for_status()
  310. chunks: list[bytes] = []
  311. size = 0
  312. async for chunk in response.aiter_bytes():
  313. size += len(chunk)
  314. if size > MAX_RESPONSE_BYTES:
  315. raise ValidatorWebError("Page exceeds the 512 KiB response limit")
  316. chunks.append(chunk)
  317. body = b"".join(chunks)
  318. title, text = _decode_page(
  319. body,
  320. response.headers.get("content-type", ""),
  321. )
  322. truncated = len(text) > MAX_PAGE_CHARS
  323. text = text[:MAX_PAGE_CHARS]
  324. final_url = current
  325. return {
  326. "source_id": _source_id(final_url),
  327. "url": url,
  328. "final_url": final_url,
  329. "title": title,
  330. "content_type": response.headers.get("content-type", ""),
  331. "retrieved_at": _utc_now(),
  332. "content_sha256": sha256(body).hexdigest(),
  333. "text": text,
  334. "truncated": truncated,
  335. "untrusted_material": True,
  336. }
  337. finally:
  338. await response.aclose()
  339. raise ValidatorWebError("Page could not be fetched")
  340. UsageRecorder = Callable[[int, int, float], Awaitable[None]]
  341. PageFetcher = Callable[..., Awaitable[dict[str, Any]]]
  342. @dataclass
  343. class ValidatorToolLimits:
  344. max_searches: int
  345. max_opens: int
  346. max_tool_calls: int
  347. @dataclass
  348. class ValidatorToolSession:
  349. """单个 Scope Validator Trace 的私有工具、白名单和调用账本。"""
  350. provider: ValidatorWebSearchProvider | None
  351. allowed_urls: set[str]
  352. limits: ValidatorToolLimits
  353. usage_recorder: UsageRecorder | None = None
  354. resolver: Callable[[str, int], Awaitable[Sequence[str]]] | None = None
  355. page_fetcher: PageFetcher = fetch_public_page
  356. search_calls: int = 0
  357. open_calls: int = 0
  358. tool_calls: int = 0
  359. page_chars: int = 0
  360. opened_source_ids: set[str] = field(default_factory=set)
  361. _search_urls: set[str] = field(default_factory=set)
  362. def registry(self) -> ToolRegistry:
  363. registry = ToolRegistry()
  364. async def validator_web_search(query: str, max_results: int = 5) -> dict[str, Any]:
  365. return await self.search(query, max_results)
  366. async def validator_open_url(url: str) -> dict[str, Any]:
  367. return await self.open(url)
  368. registry.register(validator_web_search)
  369. registry.register(validator_open_url)
  370. return registry
  371. async def _reserve_call(self) -> None:
  372. self.tool_calls += 1
  373. if self.tool_calls > self.limits.max_tool_calls:
  374. raise ValidatorWebError("Validator tool-call limit reached")
  375. if self.usage_recorder:
  376. await self.usage_recorder(1, 0, 0.0)
  377. async def _record_material(
  378. self,
  379. material_chars: int,
  380. provider_cost_usd: float = 0.0,
  381. ) -> None:
  382. if (material_chars or provider_cost_usd) and self.usage_recorder:
  383. await self.usage_recorder(0, material_chars, provider_cost_usd)
  384. async def search(self, query: str, max_results: int = 5) -> dict[str, Any]:
  385. query = query.strip()
  386. if not query:
  387. raise ValidatorWebError("Search query must not be empty")
  388. if isinstance(max_results, bool) or not 1 <= max_results <= MAX_SEARCH_RESULTS:
  389. raise ValidatorWebError("max_results must be between 1 and 5")
  390. if self.search_calls >= self.limits.max_searches:
  391. raise ValidatorWebError("Validator search limit reached")
  392. self.search_calls += 1
  393. await self._reserve_call()
  394. if self.provider is None:
  395. return {
  396. "status": "provider_unavailable",
  397. "query": query,
  398. "results": [],
  399. }
  400. try:
  401. provider_result = await self.provider.search(query, max_results)
  402. except ProviderUnavailable as exc:
  403. return {
  404. "status": "provider_unavailable",
  405. "query": query,
  406. "error": str(exc),
  407. "results": [],
  408. }
  409. if isinstance(provider_result, ValidatorSearchResponse):
  410. raw_results = provider_result.results
  411. provider_cost_usd = provider_result.cost_usd
  412. else:
  413. raw_results = provider_result
  414. provider_cost_usd = 0.0
  415. results = []
  416. for index, item in enumerate(raw_results[:max_results], start=1):
  417. raw_url = str(item.get("url") or item.get("link") or "")
  418. try:
  419. normalized_url = normalize_public_url(raw_url)
  420. except ValidatorWebError:
  421. continue
  422. self._search_urls.add(normalized_url)
  423. results.append({
  424. "source_id": _source_id(normalized_url),
  425. "title": str(item.get("title") or ""),
  426. "url": normalized_url,
  427. "snippet": str(item.get("snippet") or ""),
  428. "rank": index,
  429. "retrieved_at": _utc_now(),
  430. })
  431. payload = {"status": "completed", "query": query, "results": results}
  432. await self._record_material(
  433. len(json.dumps(payload, ensure_ascii=False)),
  434. provider_cost_usd,
  435. )
  436. return payload
  437. async def open(self, url: str) -> dict[str, Any]:
  438. if self.open_calls >= self.limits.max_opens:
  439. raise ValidatorWebError("Validator page-open limit reached")
  440. normalized = normalize_public_url(url)
  441. normalized_allowed = {
  442. normalize_public_url(item) for item in self.allowed_urls
  443. }
  444. if normalized not in normalized_allowed and normalized not in self._search_urls:
  445. raise ValidatorWebError(
  446. "URL was not declared by the TaskReport or returned by this search session"
  447. )
  448. self.open_calls += 1
  449. await self._reserve_call()
  450. page = await self.page_fetcher(normalized, resolver=self.resolver)
  451. chars = len(str(page.get("text") or ""))
  452. if self.page_chars + chars > MAX_TRACE_PAGE_CHARS:
  453. raise ValidatorWebError("Validator page-text limit reached")
  454. self.page_chars += chars
  455. await self._record_material(chars)
  456. self.opened_source_ids.add(str(page["source_id"]))
  457. return page