argo-search 2.9.8 → 2.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.en.md +30 -20
  2. package/README.es.md +30 -20
  3. package/README.ja.md +30 -20
  4. package/README.ko.md +30 -20
  5. package/README.md +32 -22
  6. package/SKILL.md +1 -1
  7. package/bin/argo +4 -0
  8. package/bin/argo.js +1 -1
  9. package/config.yaml +52 -5
  10. package/docs/ARGO_INTRO.md +5 -3
  11. package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
  12. package/package.json +3 -2
  13. package/packages/dsh-plugin/README.md +3 -2
  14. package/packages/dsh-plugin/dsh/client.js +98 -32
  15. package/packages/dsh-plugin/dsh/diagnostics.js +43 -0
  16. package/packages/dsh-plugin/dsh/index.js +23 -1
  17. package/packages/dsh-plugin/package.json +2 -2
  18. package/packages/dsh-plugin/test/client.test.js +2 -1
  19. package/scripts/adaptive.py +102 -42
  20. package/scripts/bench_tail_ab.py +281 -0
  21. package/scripts/bounded_run.py +91 -49
  22. package/scripts/clock_bridge.py +22 -0
  23. package/scripts/content_security.py +119 -4
  24. package/scripts/dispatch_result.py +9 -2
  25. package/scripts/engine_add.py +602 -0
  26. package/scripts/engine_dispatch.py +9 -0
  27. package/scripts/evidence_loop.py +4 -0
  28. package/scripts/fetch_quality.py +13 -0
  29. package/scripts/fetch_v3.py +3 -3
  30. package/scripts/http_client.py +24 -38
  31. package/scripts/image_vision/vision_probe +0 -0
  32. package/scripts/local_engines.py +321 -0
  33. package/scripts/matrix_search_eval.py +111 -1
  34. package/scripts/mcp_transport.py +1 -1
  35. package/scripts/net_proxy.py +27 -0
  36. package/scripts/recovery.py +7 -3
  37. package/scripts/route_combo.py +19 -5
  38. package/scripts/route_domains.py +138 -12
  39. package/scripts/safe_conn.py +52 -0
  40. package/scripts/search_entry.py +1 -0
  41. package/scripts/search_pipeline.py +20 -2
  42. package/scripts/search_rank.py +6 -0
  43. package/scripts/serp_guard.py +23 -0
  44. package/scripts/tail_completion.py +212 -0
  45. package/scripts/url_safety.py +126 -3
  46. package/scripts/usage_log.py +45 -0
@@ -23,6 +23,8 @@ import ipaddress
23
23
  import os
24
24
  import re
25
25
  import socket
26
+ import time
27
+ import urllib.parse
26
28
  from urllib.parse import urlparse
27
29
 
28
30
 
@@ -184,9 +186,10 @@ def host_is_private(host: str) -> bool:
184
186
  if host_is_private_name(host):
185
187
  return True
186
188
  norm = _normalize_numeric_host(host)
187
- if norm is not None and norm != host:
188
- # 数字字面量变体:直接按规范化结果查表,不依赖各系统对
189
- # 八进制/十六进制/单段整数解析行为的一致性
189
+ if norm is not None:
190
+ # 数字字面量:直接按规范化结果查表,不依赖 getaddrinfo。
191
+ # 标准点分形式(norm == host)此前会落到 getaddrinfo——既多一次系统
192
+ # 调用,又让「被 mock/劫持的解析器」能影响 SSRF 判定。
190
193
  return is_private_ip(norm)
191
194
  try:
192
195
  infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
@@ -234,3 +237,123 @@ def is_safe_fetch_url(url: str) -> bool:
234
237
  """便捷布尔封装:仅 http/https 且目标非私有地址。"""
235
238
  ok, _ = check_url(url)
236
239
  return ok
240
+
241
+
242
+ def max_redirects() -> int:
243
+ """最大重定向跳数(默认 5,合法 1-20,越界回落默认)。"""
244
+ try:
245
+ from engine_env import get_env
246
+ val = int(str(get_env(["ARGO_MAX_REDIRECTS"])).strip())
247
+ except (TypeError, ValueError):
248
+ return 5
249
+ return val if 0 < val <= 20 else 5
250
+
251
+
252
+ def check_redirect_target(url: str, start: float) -> dict | None:
253
+ """校验重定向目标是否可安全请求;被拦截返回统一错误 dict,放行返回 None。
254
+
255
+ 与 resolve_redirect_chain 的逐跳校验对齐——此前 _do_get/_do_post 的
256
+ 内联重定向循环只校验首跳,重定向到内网即可绕过 SSRF。
257
+ """
258
+ ok, reason = check_url(url)
259
+ if ok:
260
+ return None
261
+ return {
262
+ "status": 0, "headers": {}, "text": "",
263
+ "url": url,
264
+ "elapsed_ms": int((time.time() - start) * 1000),
265
+ "error": f"重定向目标被 SSRF 防护拦截: {reason}",
266
+ }
267
+
268
+
269
+ def resolve_redirect_chain(url: str, headers: dict, timeout: float,
270
+ limit: int | None = None) -> str | None:
271
+ """逐跳安全跟随重定向:每跳目标都过 SSRF 校验(curl 通道用)。
272
+
273
+ 返回最终 URL;任一跳被拦截或超过跳数返回 None。
274
+ """
275
+ if limit is None:
276
+ limit = max_redirects()
277
+ current = url
278
+ for _ in range(limit):
279
+ ok, _reason = check_url(current)
280
+ if not ok:
281
+ return None
282
+ parsed = urlparse(current)
283
+ if parsed.scheme not in ("http", "https"):
284
+ return None
285
+ try:
286
+ from net_proxy import open_connection, request_selector, resolve_proxy
287
+ proxy_url = resolve_proxy(current)
288
+ conn, via_proxy = open_connection(parsed, timeout, proxy_url)
289
+ path = parsed.path or "/"
290
+ if parsed.query:
291
+ path += "?" + parsed.query
292
+ conn.request("HEAD", request_selector(parsed, path, via_proxy),
293
+ headers=headers)
294
+ resp = conn.getresponse()
295
+ status = resp.status
296
+ location = resp.getheader("Location")
297
+ conn.close()
298
+ except Exception:
299
+ return None
300
+ if status not in (301, 302, 303, 307, 308) or not location:
301
+ return current
302
+ current = urllib.parse.urljoin(current, location)
303
+ return None
304
+
305
+
306
+ # ── 解析即锁定(DNS rebinding / TOCTOU 防护)─────────────────────────────────
307
+ #
308
+ # check_url() 在请求前解析一次 DNS,http.client 在 connect() 时再解析一次——
309
+ # 两次之间域名可改指内网(DNS rebinding)。下面两个连接类在 connect() 里
310
+ # 解析一次、过滤私网、把结果钉给本次 TCP 连接,窗口关闭;SNI/Host 仍用原域名。
311
+
312
+
313
+ def _is_ip_literal(host: str) -> bool:
314
+ """主机名是否为 IP 字面量(v4 / v6,含 [v6] 与 IPv4-mapped)。"""
315
+ h = (host or "").strip().strip("[]")
316
+ if not h:
317
+ return False
318
+ if ":" in h: # IPv6(含 ::ffff:a.b.c.d)
319
+ return True
320
+ parts = h.split(".")
321
+ return len(parts) == 4 and all(p.isdigit() for p in parts)
322
+
323
+
324
+ def resolve_public_addresses(host: str) -> list[str]:
325
+ """解析主机名 → 公网地址名单(过滤私网/保留段;fake-ip 占位段放行)。
326
+
327
+ 返回空列表表示「无可连的公网地址」——调用方应拒绝连接(fail-closed)。
328
+ """
329
+ try:
330
+ infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
331
+ except OSError:
332
+ return []
333
+ out: list[str] = []
334
+ seen: set[str] = set()
335
+ for info in infos:
336
+ ip = info[4][0]
337
+ if ip in seen:
338
+ continue
339
+ seen.add(ip)
340
+ if is_fake_ip_address(ip): # 代理接管域名(占位段),不可直达内网
341
+ out.append(ip)
342
+ continue
343
+ if is_private_ip(ip):
344
+ continue
345
+ out.append(ip)
346
+ return out
347
+
348
+
349
+ def pin_host(host: str) -> str | None:
350
+ """返回要钉扎的公网 IP;IP 字面量或显式放行私网时返回 None。
351
+
352
+ 无可连的公网地址则抛错(fail-closed)。供 safe_conn 的连接类使用。
353
+ """
354
+ if allow_private() or _is_ip_literal(host):
355
+ return None
356
+ addrs = resolve_public_addresses(host)
357
+ if not addrs:
358
+ raise OSError(f"SSRF 防护:{host} 无可连的公网地址")
359
+ return addrs[0]
@@ -6,6 +6,7 @@
6
6
  - 失败静默:任何异常都吞掉返回 False,绝不拖累搜索主路径
7
7
  - 目录可注入:ARGO_USAGE_LOG_DIR 覆盖(测试隔离),默认 <状态目录>/usage_log/
8
8
  - 总开关:ARGO_USAGE_LOG=0 / false 完全关闭
9
+ - 夹具守卫:query 流 engines_used 出现注册表外引擎名即判测试夹具,整条拒写
9
10
  - 脱敏:query 等敏感字段由调用方截断,本模块不放大
10
11
 
11
12
  Schema(每条一行 JSON,UTF-8):
@@ -68,6 +69,40 @@ def _enabled() -> bool:
68
69
  return env_flag("ARGO_USAGE_LOG")
69
70
 
70
71
 
72
+ # 引擎改名前的历史别名(表外但真实):与 usage_log_clean._ALIAS_KEEP 同一账。
73
+ _RENAMED_ALIAS_KEEP = frozenset({"openlibrary", "bing"})
74
+
75
+
76
+ def _canonical_engines() -> frozenset[str]:
77
+ """注册表引擎名全集(含历史别名豁免)。注册表加载失败返回空集——
78
+ 守卫随之失效放行(fail-open):守卫是防脏数据的,不能反过来丢真日志。
79
+
80
+ 不做外层缓存:available_engines 走 get_registry 的 mtime 指纹自愈,每次
81
+ 现取才能看见「daemon 常驻期间新增的本地引擎」;外层冻结只会把新引擎的
82
+ 真日志误杀成夹具。每次构建 frozenset 是微秒级,query 流每搜索只写一条。
83
+ """
84
+ try:
85
+ from engines import available_engines
86
+ return frozenset(available_engines()) | _RENAMED_ALIAS_KEEP
87
+ except Exception:
88
+ return frozenset()
89
+
90
+
91
+ def _fixture_engines(record: dict[str, Any]) -> list[str]:
92
+ """query 记录里的注册表外引擎名(别名豁免后)。
93
+
94
+ engines_used 缺失/非列表/为空一律视为无夹具信号:真实搜索的该字段
95
+ 永远是引擎 id 列表,守卫只对「有列表且含表外名」的形态做判定。
96
+ """
97
+ used = record.get("engines_used")
98
+ if not isinstance(used, list) or not used:
99
+ return []
100
+ canon = _canonical_engines()
101
+ if not canon:
102
+ return []
103
+ return [e for e in used if not isinstance(e, str) or e not in canon]
104
+
105
+
71
106
  def emit(stream: str, record: dict[str, Any]) -> bool:
72
107
  """追加一条日志记录到 <usage_log_dir>/<stream>.jsonl。
73
108
 
@@ -75,6 +110,16 @@ def emit(stream: str, record: dict[str, Any]) -> bool:
75
110
  """
76
111
  if not _enabled():
77
112
  return False
113
+ if stream == "query" and _fixture_engines(record):
114
+ # 夹具守卫(2026-10-25):测试夹具引擎名(fast / slow / quick_primary
115
+ # 这类注册表外名)屡次写进生产 query.jsonl(conftest 的
116
+ # ARGO_USAGE_LOG=0 与 is_synthetic_run 的黑名单各漏过一批,最近一次
117
+ # 2026-10-24),stats 的均值与最慢/高频引擎榜随之失真。黑名单永远
118
+ # 追不全新夹具名,这里在唯一落盘口用注册表白名单反向判定:表外名
119
+ # 即夹具,整条拒写。存量清理走 usage_log_clean.py(隔离不删除)。
120
+ # 只拦 query 流:route/recovery 的字段形态与测试惯例不同(route 的
121
+ # 格式测试本身用 e0..e14 假名),且实测污染只发生在 query 流。
122
+ return False
78
123
  try:
79
124
  line = json.dumps(
80
125
  {