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.
- package/README.en.md +30 -20
- package/README.es.md +30 -20
- package/README.ja.md +30 -20
- package/README.ko.md +30 -20
- package/README.md +32 -22
- package/SKILL.md +1 -1
- package/bin/argo +4 -0
- package/bin/argo.js +1 -1
- package/config.yaml +52 -5
- package/docs/ARGO_INTRO.md +5 -3
- package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
- package/package.json +3 -2
- package/packages/dsh-plugin/README.md +3 -2
- package/packages/dsh-plugin/dsh/client.js +98 -32
- package/packages/dsh-plugin/dsh/diagnostics.js +43 -0
- package/packages/dsh-plugin/dsh/index.js +23 -1
- package/packages/dsh-plugin/package.json +2 -2
- package/packages/dsh-plugin/test/client.test.js +2 -1
- package/scripts/adaptive.py +102 -42
- package/scripts/bench_tail_ab.py +281 -0
- package/scripts/bounded_run.py +91 -49
- package/scripts/clock_bridge.py +22 -0
- package/scripts/content_security.py +119 -4
- package/scripts/dispatch_result.py +9 -2
- package/scripts/engine_add.py +602 -0
- package/scripts/engine_dispatch.py +9 -0
- package/scripts/evidence_loop.py +4 -0
- package/scripts/fetch_quality.py +13 -0
- package/scripts/fetch_v3.py +3 -3
- package/scripts/http_client.py +24 -38
- package/scripts/image_vision/vision_probe +0 -0
- package/scripts/local_engines.py +321 -0
- package/scripts/matrix_search_eval.py +111 -1
- package/scripts/mcp_transport.py +1 -1
- package/scripts/net_proxy.py +27 -0
- package/scripts/recovery.py +7 -3
- package/scripts/route_combo.py +19 -5
- package/scripts/route_domains.py +138 -12
- package/scripts/safe_conn.py +52 -0
- package/scripts/search_entry.py +1 -0
- package/scripts/search_pipeline.py +20 -2
- package/scripts/search_rank.py +6 -0
- package/scripts/serp_guard.py +23 -0
- package/scripts/tail_completion.py +212 -0
- package/scripts/url_safety.py +126 -3
- package/scripts/usage_log.py +45 -0
package/scripts/url_safety.py
CHANGED
|
@@ -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
|
|
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]
|
package/scripts/usage_log.py
CHANGED
|
@@ -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
|
{
|