argo-search 2.9.7 → 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 (154) hide show
  1. package/README.en.md +73 -150
  2. package/README.es.md +73 -150
  3. package/README.ja.md +79 -156
  4. package/README.ko.md +78 -155
  5. package/README.md +80 -159
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +78 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +269 -63
  15. package/docs/ARGO_INTRO.md +5 -3
  16. package/docs/ENGINE_CATALOG.md +50 -39
  17. package/docs/MCP_SETUP.md +3 -1
  18. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  19. package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
  20. package/engines/specs/cfets.yaml +41 -0
  21. package/engines/specs/defillama.yaml +38 -0
  22. package/engines/specs/firecrawl.yaml +1 -1
  23. package/engines/specs/fxtwitter.yaml +5 -0
  24. package/engines/specs/gleif.yaml +55 -0
  25. package/engines/specs/hkex.yaml +40 -0
  26. package/engines/specs/keenable_free.yaml +36 -0
  27. package/engines/specs/shfe.yaml +39 -0
  28. package/engines/specs/stackexchange.yaml +2 -2
  29. package/engines/specs/tavily_free.yaml +56 -0
  30. package/engines/specs/yuanbao.yaml +93 -0
  31. package/mcp/clients.yaml +32 -0
  32. package/package.json +7 -2
  33. package/packages/dsh-plugin/README.md +25 -6
  34. package/packages/dsh-plugin/dsh/client.js +402 -47
  35. package/packages/dsh-plugin/dsh/diagnostics.js +97 -0
  36. package/packages/dsh-plugin/dsh/index.js +135 -1876
  37. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  38. package/packages/dsh-plugin/dsh/runner.js +666 -0
  39. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  40. package/packages/dsh-plugin/package.json +3 -3
  41. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  42. package/packages/dsh-plugin/test/client.test.js +177 -2
  43. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  44. package/packages/dsh-plugin/test/index.test.js +44 -0
  45. package/scripts/adaptive.py +126 -43
  46. package/scripts/article_meta.py +131 -0
  47. package/scripts/bench_tail_ab.py +281 -0
  48. package/scripts/bounded_run.py +91 -49
  49. package/scripts/budget.py +247 -0
  50. package/scripts/budget_cli.py +93 -0
  51. package/scripts/build_release_assets.sh +65 -0
  52. package/scripts/cache.py +60 -72
  53. package/scripts/ci_drift_gate.py +90 -0
  54. package/scripts/clock_bridge.py +22 -0
  55. package/scripts/content_boundary.py +46 -0
  56. package/scripts/content_security.py +119 -4
  57. package/scripts/content_signals.py +26 -5
  58. package/scripts/cross_lingual_trace.py +293 -0
  59. package/scripts/dispatch_result.py +105 -0
  60. package/scripts/engine_add.py +602 -0
  61. package/scripts/engine_dispatch.py +58 -86
  62. package/scripts/engine_env.py +5 -0
  63. package/scripts/engine_families.py +6 -0
  64. package/scripts/engine_policy.py +7 -7
  65. package/scripts/engines.py +21 -0
  66. package/scripts/engines_base.py +3 -3
  67. package/scripts/engines_builders.py +12 -0
  68. package/scripts/engines_builders_batch10.py +48 -3
  69. package/scripts/engines_builders_batch14.py +656 -0
  70. package/scripts/engines_builders_batch15.py +249 -0
  71. package/scripts/engines_builders_keyless.py +111 -0
  72. package/scripts/engines_builders_tech.py +0 -1
  73. package/scripts/evidence.py +31 -3
  74. package/scripts/evidence_loop.py +58 -22
  75. package/scripts/fetch_quality.py +13 -0
  76. package/scripts/fetch_v3.py +28 -3
  77. package/scripts/gen_routing_snapshot.py +11 -0
  78. package/scripts/http_client.py +24 -38
  79. package/scripts/install.ps1 +18 -0
  80. package/scripts/install.sh +16 -1
  81. package/scripts/job.py +3 -3
  82. package/scripts/local_seek.py +41 -5
  83. package/scripts/macro_countries.py +15 -0
  84. package/scripts/matrix_search_eval.py +192 -0
  85. package/scripts/mcp_compact.py +234 -0
  86. package/scripts/mcp_handlers.py +60 -642
  87. package/scripts/mcp_launch.sh +10 -8
  88. package/scripts/mcp_local.py +241 -0
  89. package/scripts/mcp_server.py +19 -14
  90. package/scripts/mcp_setup.py +33 -9
  91. package/scripts/mcp_tools.py +1 -0
  92. package/scripts/mcp_transport.py +1 -1
  93. package/scripts/mcp_warm.py +127 -0
  94. package/scripts/net_proxy.py +27 -0
  95. package/scripts/output_transparency.py +200 -0
  96. package/scripts/paid_policy.py +98 -0
  97. package/scripts/quota.py +158 -13
  98. package/scripts/recovery.py +7 -3
  99. package/scripts/recovery_runner.py +150 -0
  100. package/scripts/replay_eval.py +18 -0
  101. package/scripts/research.py +37 -21
  102. package/scripts/research_expand.py +24 -1
  103. package/scripts/retry_policy.py +76 -0
  104. package/scripts/route.py +6 -6
  105. package/scripts/route_cache.py +18 -0
  106. package/scripts/route_combo.py +26 -5
  107. package/scripts/route_domains.py +170 -31
  108. package/scripts/safe_conn.py +52 -0
  109. package/scripts/search.py +15 -27
  110. package/scripts/search_attribution.py +100 -3
  111. package/scripts/search_cli.py +24 -11
  112. package/scripts/search_entry.py +8 -1
  113. package/scripts/search_output.py +99 -15
  114. package/scripts/search_pipeline.py +62 -195
  115. package/scripts/search_rank.py +6 -0
  116. package/scripts/search_types.py +107 -9
  117. package/scripts/semantic_evidence.py +179 -3
  118. package/scripts/serp_guard.py +138 -7
  119. package/scripts/serp_kill_log.py +25 -2
  120. package/scripts/stats_cli.py +13 -1
  121. package/scripts/tail_completion.py +212 -0
  122. package/scripts/url_safety.py +126 -3
  123. package/scripts/usage_log.py +45 -0
  124. package/scripts/wide_research.py +45 -0
  125. package/scripts/zhihu_quota.py +139 -0
  126. package/sub-skills/local-search/bm25.py +27 -3
  127. package/sub-skills/local-search/cli_runner.py +331 -0
  128. package/sub-skills/local-search/parsers.py +328 -0
  129. package/sub-skills/local-search/search_v3.py +31 -674
  130. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  131. package/assets/readme/settings-keys.png +0 -0
  132. package/assets/readme/settings-tools.png +0 -0
  133. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  134. package/sub-skills/ego-search/references/install.md +0 -67
  135. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  136. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  137. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  138. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  139. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  140. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  141. package/sub-skills/local-seek/references/git-integration.md +0 -54
  142. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  143. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  144. package/sub-skills/local-seek/references/strategies.md +0 -52
  145. package/sub-skills/local-seek/references/structural-search.md +0 -59
  146. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  147. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  148. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  149. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  150. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  151. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  152. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  153. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  154. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """漂移阈值判定:把夜间「重录 vs 基线」的比较结果变成退出码。
3
+
4
+ ## 为什么单独成文件
5
+
6
+ 判定逻辑原来写在 nightly.yml 的 heredoc 里:语法对错只能在推上去之后知道,
7
+ 阈值改松改紧也没人测。抽出来后有两个好处——本地能跑同一套判定,用例能锁住
8
+ 「跌多少算跌」这条契约(tests/test_ci_workflows.py)。
9
+
10
+ ## 判什么、为什么只判这两样
11
+
12
+ - **结果条数**:少 1 条允许(工具源单条抖动很常见),少 2 条以上算漂移;
13
+ - **输出体积**:超过基线 20% 算漂移(体积是 Agent 真金白银付的上下文)。
14
+
15
+ `not_recorded`(重放时要了一个当时没调过的引擎)**不计入**:录制只保留真正
16
+ 被调到的引擎,而重放是瞬时完成,早停时机与真跑不同,这是录制机制的正常边界,
17
+ 当成回归报会制造长期噪音。它仍然会打印出来,供人看。
18
+
19
+ 用法:
20
+ python3 scripts/ci_drift_gate.py --baseline 基线报告.json --current 本次报告.json
21
+ 退出码:0 通过;1 有漂移;2 输入缺失/格式不对(这两件事必须分开,混在一起
22
+ 会让人以为「红了=质量掉了」)。
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ KEPT_SLACK = 1 # 允许少 1 条
34
+ BYTES_GROWTH = 1.2 # 体积上限 = 基线 × 1.2
35
+
36
+
37
+ def _load(path: Path) -> dict[str, Any]:
38
+ doc = json.loads(path.read_text(encoding="utf-8"))
39
+ # save_baseline 存的是 {"_meta":…, "report":{…}};evaluate_all 直接给报告本体
40
+ return doc.get("report", doc)
41
+
42
+
43
+ def violations(base: dict[str, Any], cur: dict[str, Any]) -> list[str]:
44
+ by_id = {c.get("id"): c for c in (base.get("cases") or [])}
45
+ cur_ids = {c.get("id") for c in (cur.get("cases") or [])}
46
+ out: list[str] = []
47
+ for case in cur.get("cases") or []:
48
+ old = by_id.get(case.get("id"))
49
+ if not old:
50
+ continue # 新增的用例没有基线,不算漂移
51
+ cid = case.get("id")
52
+ kept, old_kept = case.get("kept", 0), old.get("kept", 0)
53
+ if kept < old_kept - KEPT_SLACK:
54
+ out.append(f"{cid}: 结果 {kept} 条,基线 {old_kept} 条")
55
+ old_bytes = old.get("bytes", 0)
56
+ if old_bytes and case.get("bytes", 0) > old_bytes * BYTES_GROWTH:
57
+ out.append(
58
+ f"{cid}: 输出 {case.get('bytes')}B,超过基线 {old_bytes}B×{BYTES_GROWTH}")
59
+ # 基线里有、本次没有:不是「通过」,是这条根本没被录到(查询集被改、
60
+ # 或录制整条失败)。2026-10-22 本地实跑踩过:只剩 1 条时判定照样退 0。
61
+ for cid in by_id:
62
+ if cid and cid not in cur_ids:
63
+ out.append(f"{cid}: 本次报告里没有这条(查询集被改了,还是录制失败?)")
64
+ return out
65
+
66
+
67
+ def main() -> int:
68
+ ap = argparse.ArgumentParser(description="夜间漂移阈值判定")
69
+ ap.add_argument("--baseline", required=True)
70
+ ap.add_argument("--current", required=True)
71
+ args = ap.parse_args()
72
+
73
+ try:
74
+ base = _load(Path(args.baseline))
75
+ cur = _load(Path(args.current))
76
+ except (OSError, json.JSONDecodeError) as e:
77
+ print(f"读不到报告:{type(e).__name__}: {e}", file=sys.stderr)
78
+ return 2
79
+
80
+ bad = violations(base, cur)
81
+ if bad:
82
+ print("\n".join(bad), file=sys.stderr)
83
+ return 1
84
+ print(f"通过:{len(cur.get('cases') or [])} 条,漏录 "
85
+ f"{cur.get('total_missing', 0)} 个引擎(信息项,不算漂移)")
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ sys.exit(main())
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env python3
2
+ """clock_bridge.py — 统一时钟的一行入口(2026-10-24)。
3
+
4
+ 计时断言与「预算夹紧」判定不该取决于机器快慢:测试域用 tests/_measure 的
5
+ 虚拟时钟,生产路径就是 ``time.monotonic``。各模块从这里取 ``clock``,不再各自
6
+ 复制一段 try/except(那会让千行文件更难拆,也容易只改一半——本文件对应的
7
+ fetch_v3 就曾因「预算夹在超时请求上、耗时却按真实墙钟算」而被测出击穿)。
8
+
9
+ 与 ``tests/_measure.py`` 的关系:那个模块定义语义(虚拟/真实、预算锚点、
10
+ 确定性时间线),本模块只做「生产环境安全地取到它」这一件事。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+
17
+ try: # 测试域:虚拟时钟(见 tests/_measure.py)
18
+ from _measure import clock # type: ignore
19
+ except ImportError: # pragma: no cover - 生产路径
20
+ clock = time.monotonic
21
+
22
+ __all__ = ["clock"]
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env python3
2
+ """content_boundary.py — 不可信网页内容的边界标记与清洗。
3
+
4
+ 为什么需要单独一层:Argo 对 Agent 输出的是 JSON,不能像纯文本工具那样简单
5
+ 把整段响应包进 <untrusted-web-content>(会破坏 JSON 解析)。所以这里只做
6
+ 两件确定能做的事:
7
+
8
+ 1. **清洗**:任何来自上游的标题/摘要/正文在进入输出前,剥掉其中的边界标记
9
+ 字样——否则网页正文里自带的 `</untrusted-web-content>` 可以提前闭合边界,
10
+ 把后续内容伪装成可信指令(dsh-free-search 的同类防护)。
11
+ 2. **声明**:调用方在响应里带 `content_trust: "untrusted"`,让模型与宿主
12
+ 知道这批文本的信任级别;文本边界由宿主(DSH/MCP 客户端)按自己的渲染
13
+ 形态加,不在 JSON 契约里硬塞。
14
+
15
+ 不引入依赖、不做 HTML 清洗:正文已经是纯文本摘要,这里只管边界标记这一类
16
+ 可被利用的结构字符。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Any
23
+
24
+ CONTENT_TRUST_UNTRUSTED = "untrusted"
25
+
26
+ # 大小写、属性、空白都按「可能的变体」处理;只剥标记本身,不动正文其余字符。
27
+ _BOUNDARY_RE = re.compile(
28
+ r"<\s*/?\s*untrusted-web-content\b[^>]*>", re.IGNORECASE)
29
+
30
+
31
+ def strip_boundary_markers(text: Any, limit: int | None = None) -> str:
32
+ """剥掉正文里的边界标记(含变体),可选截断。"""
33
+ out = _BOUNDARY_RE.sub("", str(text or ""))
34
+ if limit is not None and limit >= 0 and len(out) > limit:
35
+ out = out[:limit]
36
+ return out
37
+
38
+
39
+ def sanitize_results_boundary(results: Any) -> None:
40
+ """就地清洗结果列表每条的 title/snippet 边界标记(CLI/Agent/MCP 共用单一出口)。"""
41
+ for item in results or []:
42
+ if not isinstance(item, dict):
43
+ continue
44
+ for key in ("title", "snippet"):
45
+ if isinstance(item.get(key), str):
46
+ item[key] = strip_boundary_markers(item[key])
@@ -35,6 +35,7 @@ THREAT_NAMES = {
35
35
  "payload_smuggling": "载荷走私",
36
36
  "xss_injection": "XSS 注入",
37
37
  "recursive_injection": "递归注入",
38
+ "credential_exposure": "凭证暴露",
38
39
  }
39
40
 
40
41
 
@@ -181,11 +182,53 @@ INJECTION_PATTERNS_EL = [
181
182
  r"υποδύσου\s+(?:τον\s+)?(?:άνθρωπο|χρήστη|διαχειριστή|σύστημα)",
182
183
  ]
183
184
 
185
+ # 西班牙语注入
186
+ INJECTION_PATTERNS_ES = [
187
+ r"(?i)ignora\s+(?:todas\s+)?(?:las\s+)?(?:instrucciones|órdenes|ordenes|reglas|indicaciones)\s+(?:anteriores|previas)",
188
+ r"(?i)olvida\s+(?:todas\s+)?(?:las\s+)?(?:instrucciones|órdenes|reglas)",
189
+ r"(?i)(?:muéstrame|muestrame|enséñame|ensename|dime|revela|imprime|exhibe)\s+(?:tu\s+)?(?:prompt\s+del\s+sistema|prompt|instrucciones|configuración|configuracion)",
190
+ r"(?i)ya\s+no\s+(?:eres|es)\s+(?:un\s+)?(?:asistente|bot|ia|agente)",
191
+ r"(?i)(?:eres|ahora\s+eres)\s+(?:una\s+)?(?:persona|humano|usuario|administrador|sistema)",
192
+ r"(?i)(?:actúa|actua|comporta|finge|simula)\s+(?:como|de\s+que)\s+(?:una\s+)?(?:persona|humano|usuario|administrador|sistema)",
193
+ r"(?i)(?:elude|evita|ignora)\s+(?:todas\s+)?(?:las\s+)?(?:restricciones|limitaciones|protecciones|reglas)",
194
+ r"(?i)te\s+llamas\s+(?:ChatGPT|Claude|GPT|asistente\s+de\s+ia)",
195
+ ]
196
+
197
+ # 法语注入
198
+ INJECTION_PATTERNS_FR = [
199
+ r"(?i)ignore\s+(?:toutes\s+)?(?:les\s+)?(?:instructions|consignes|règles|regles|ordres)\s+(?:précédentes|precedentes)",
200
+ r"(?i)oublie\s+(?:toutes\s+)?(?:les\s+)?(?:instructions|consignes|règles|regles)",
201
+ r"(?i)(?:montre|affiche|dis|révèle|revel|imprime)\s+(?:ton|votre|ta)?\s*(?:prompt\s+système|prompt|instructions|configuration)",
202
+ r"(?i)tu\s+n'es\s+plus\s+(?:un\s+)?(?:assistant|bot|ia|agent)",
203
+ r"(?i)tu\s+es\s+(?:désormais|desormais\s+)?(?:une\s+)?(?:personne|humain|utilisateur|administrateur|système|systeme)",
204
+ r"(?i)(?:fais\s+semblant|joue|agis)\s+(?:comme|en\s+tant\s+que)\s+(?:une\s+)?(?:personne|humain|utilisateur|administrateur|système|systeme)",
205
+ r"(?i)(?:contourne|évite|evite)\s+(?:toutes\s+)?(?:les\s+)?(?:restrictions|limitations|protections|règles|regles)",
206
+ r"(?i)ton\s+nom\s+est\s+(?:ChatGPT|Claude|GPT|assistant\s+ia)",
207
+ ]
208
+
209
+ # 德语注入
210
+ INJECTION_PATTERNS_DE = [
211
+ r"(?i)ignoriere\s+(?:alle\s+)?(?:vorherigen\s+)?(?:Anweisungen|Regeln|Instruktionen|Befehle)",
212
+ r"(?i)vergiss\s+(?:alle\s+)?(?:vorherigen\s+)?(?:Anweisungen|Regeln|Instruktionen)",
213
+ r"(?i)(?:zeig|zeige|erzähl|erzahl|gib\s+preis|offenbare|drucke)\s+(?:mir\s+)?(?:deinen\s+)?(?:System-?Prompt|Systemprompt|Anweisungen|Einstellungen)",
214
+ r"(?i)du\s+bist\s+(?:nicht\s+mehr|kein\s+mehr)\s+(?:ein\s+)?(?:Assistent|Bot|KI|Agent)",
215
+ r"(?i)du\s+bist\s+(?:nun\s+)?(?:ein\s+)?(?:Mensch|Benutzer|Administrator|System)",
216
+ r"(?i)(?:tu|gib\s+vor|verhalte\s+dich)\s+(?:als|wie)\s+(?:ein\s+)?(?:Mensch|Benutzer|Administrator|System)",
217
+ r"(?i)(?:umgehe|ignoriere)\s+(?:alle\s+)?(?:Einschränkungen|Einschrankungen|Beschränkungen|Beschrankungen|Schutzmaßnahmen|Schutzmassnahmen|Regeln)",
218
+ r"(?i)dein\s+Name\s+ist\s+(?:ChatGPT|Claude|GPT|KI-Assistent)",
219
+ ]
220
+
221
+ # 拉丁语系专用表(不含英语通用,避免 en/latin 两条路重复匹配)
222
+ INJECTION_PATTERNS_LATIN = (
223
+ INJECTION_PATTERNS_ES + INJECTION_PATTERNS_FR + INJECTION_PATTERNS_DE
224
+ )
225
+
184
226
  # 按语系分类的注入模式表(lang_detect 主语言标签 → 模式列表)
185
227
  LANG_INJECTION_PATTERNS: dict[str, list[str]] = {
186
228
  "zh": INJECTION_PATTERNS_ZH,
187
229
  "ja": INJECTION_PATTERNS_JA,
188
230
  "ko": INJECTION_PATTERNS_KO,
231
+ "latin": INJECTION_PATTERNS_LATIN,
189
232
  "cyrillic": INJECTION_PATTERNS_RU,
190
233
  "arabic": INJECTION_PATTERNS_AR,
191
234
  "hebrew": INJECTION_PATTERNS_HE,
@@ -259,6 +302,21 @@ XSS_PATTERNS = [
259
302
  r"(?i)window\.location",
260
303
  ]
261
304
 
305
+ # 凭证形态:出现在抓取正文里的密钥形状串——可能是页面自身的示例,也可能是
306
+ # 被注入/外泄的凭证。标 credential_exposure(权重 0.5,单独命中不触发隔离,
307
+ # 但进 threat_types 供上层判断)。
308
+ SECRET_FORM_PATTERNS = [
309
+ r"sk-[A-Za-z0-9_\-]{16,}",
310
+ r"sk-ant-[A-Za-z0-9_\-]{16,}",
311
+ r"ghp_[A-Za-z0-9]{20,}",
312
+ r"github_pat_[A-Za-z0-9_]{20,}",
313
+ r"AKIA[0-9A-Z]{16}",
314
+ r"ASIA[0-9A-Z]{16}",
315
+ r"xox[baprs]-[A-Za-z0-9\-]{10,}",
316
+ r"AIza[0-9A-Za-z_\-]{35}",
317
+ r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----",
318
+ ]
319
+
262
320
  # 检测但不过度脱敏的文件路径类模式
263
321
  _NO_REDACT_PATTERNS: frozenset = frozenset({
264
322
  r"(?i)/etc/passwd",
@@ -317,6 +375,7 @@ WEIGHTS = {
317
375
  "payload_smuggling": 0.9,
318
376
  "xss_injection": 0.9,
319
377
  "recursive_injection": 1.0,
378
+ "credential_exposure": 0.5,
320
379
  }
321
380
 
322
381
 
@@ -348,6 +407,7 @@ class ContentScrubber:
348
407
  threats.extend(self._detect_exfiltration(normalized))
349
408
  threats.extend(self._detect_impersonation(normalized))
350
409
  threats.extend(self._detect_xss(normalized))
410
+ threats.extend(self._detect_secret_forms(normalized))
351
411
 
352
412
  # 阶段 3:语义意图分析
353
413
  threats.extend(self._semantic_analysis(normalized))
@@ -467,9 +527,10 @@ class ContentScrubber:
467
527
  if lang == "zh" or lang == "mixed":
468
528
  patterns.extend(INJECTION_PATTERNS_ZH)
469
529
  for key, plist in LANG_INJECTION_PATTERNS.items():
470
- if lang == key:
530
+ # en 与 latin 共享拉丁模式:ES/FR/DE 注入多为 ASCII 或带扩展字符,
531
+ # 纯 ASCII 时 lang_detect 判 en,漏掉它们等于给拉丁注入留门
532
+ if lang == key or (lang == "en" and key == "latin"):
471
533
  patterns.extend(plist)
472
- # en/latin 落到英文通用(已有)
473
534
  return tuple(patterns)
474
535
 
475
536
  def _detect_injections(self, text: str, lang: str = "other") -> list[ThreatDetection]:
@@ -485,6 +546,11 @@ class ContentScrubber:
485
546
  def _detect_xss(self, text: str) -> list[ThreatDetection]:
486
547
  return self._match_patterns(text, XSS_PATTERNS, "xss_injection", 0.9)
487
548
 
549
+ def _detect_secret_forms(self, text: str) -> list[ThreatDetection]:
550
+ """凭证形态检测(输出侧防护):正文里的密钥形状串。"""
551
+ return self._match_patterns(text, SECRET_FORM_PATTERNS,
552
+ "credential_exposure", 0.7)
553
+
488
554
  @staticmethod
489
555
  def _match_patterns(text: str, patterns: list[str],
490
556
  threat_type: str, confidence: float) -> list[ThreatDetection]:
@@ -629,10 +695,51 @@ def scrub_content(content: str) -> ScrubResult:
629
695
  return get_scrubber().scrub(content)
630
696
 
631
697
 
698
+ # ── 风险门禁(v2.9.8)──────────────────────────────────────────────────────
699
+ #
700
+ # 此前 scrub_to_dict 只回传元数据(risk_score 等),**清洗后的正文被丢弃**——
701
+ # 安全引擎只「评分」不「门禁」,高风险内容照样进证据流水线与 Agent 上下文。
702
+ # 门禁把「评分」变成「动作」:risk_score ≥ 阈值时,fetch 层用清洗后正文替换
703
+ # 原始正文并标 content_quarantined,使被注入污染的正文不进证据分、不进上下文。
704
+ #
705
+ # 开关与阈值都可配(见 references/usage.md 功能开关总表):
706
+ # ARGO_CONTENT_RISK_GATE 默认开;关掉 = 放行高风险内容,仅应急用
707
+ # ARGO_CONTENT_RISK_THRESHOLD 默认 0.5,与历史 content_clean 阈值一致
708
+ RISK_GATE_THRESHOLD = 0.5
709
+
710
+
711
+ def risk_gate_enabled() -> bool:
712
+ """内容风险门禁总开关(默认开)。"""
713
+ try:
714
+ from engine_env import env_flag
715
+ return env_flag("ARGO_CONTENT_RISK_GATE", default=True)
716
+ except Exception:
717
+ return True
718
+
719
+
720
+ def risk_gate_threshold() -> float:
721
+ """门禁阈值:risk_score ≥ 此值即隔离。默认 0.5,越界回落默认。"""
722
+ try:
723
+ from engine_env import get_env
724
+ val = float(str(get_env(["ARGO_CONTENT_RISK_THRESHOLD"])).strip())
725
+ except (TypeError, ValueError):
726
+ return RISK_GATE_THRESHOLD
727
+ if val <= 0.0 or val > 1.0:
728
+ return RISK_GATE_THRESHOLD
729
+ return val
730
+
731
+
632
732
  def scrub_to_dict(content: str) -> dict:
633
- """清洗入口(dict 形态,供 fetch 层直接合并)。"""
733
+ """清洗入口(dict 形态,供 fetch 层直接合并)。
734
+
735
+ 除历史字段外,回传门禁三件套:gate_applied(门禁是否生效)、
736
+ quarantined(是否隔离)、cleaned_content(仅隔离时回传,供替换原始正文)。
737
+ """
634
738
  r = scrub_content(content)
635
- return {
739
+ gate_on = risk_gate_enabled()
740
+ threshold = risk_gate_threshold()
741
+ quarantined = gate_on and r.risk_score >= threshold
742
+ out = {
636
743
  "content_clean": r.clean,
637
744
  "risk_score": r.risk_score,
638
745
  "threat_count": len(r.threats),
@@ -640,7 +747,15 @@ def scrub_to_dict(content: str) -> dict:
640
747
  "redactions": r.redactions,
641
748
  "encoding_layers_stripped": r.encoding_layers_stripped,
642
749
  "content_lang": r.content_lang,
750
+ "gate_applied": gate_on,
751
+ "gate_threshold": threshold,
752
+ "quarantined": quarantined,
643
753
  }
754
+ # 只有被隔离时才回传清洗后正文:它替换原始正文,是门禁的「动作」部分;
755
+ # 未命中时多带一份正文副本只是白占内存。
756
+ if quarantined:
757
+ out["cleaned_content"] = r.content
758
+ return out
644
759
 
645
760
 
646
761
  # ── CLI ────────────────────────────────────────────────────────────────────
@@ -492,9 +492,16 @@ def score_clickbait(title: str) -> dict:
492
492
  score += 0.08
493
493
  hits.append("year_prefix")
494
494
  # 利益承诺:「看完…你就会」「建议收藏」「一篇搞懂」
495
- if re.search(r"(看完|建议收藏|建议收藏|一篇(?:讲|说|搞懂)|全解析|必看|建议收藏)", t):
495
+ if re.search(r"(看完|建议收藏|一篇(?:讲|说|搞懂)|全解析|必看)", t):
496
496
  score += 0.10
497
497
  hits.append("benefit_promise")
498
+ # 虚假实测 / 承诺尾钩
499
+ if re.search(r"自费\s*\d+\s*元?实测", t):
500
+ score += 0.25
501
+ hits.append("fake_testimony")
502
+ if re.search(r"看完.*(?:你就懂了|你就会)", t):
503
+ score += 0.15
504
+ hits.append("clickbait_promise")
498
505
 
499
506
  # 标点:仅在已有命中时作**放大器**(PACLIC 的误杀教训)
500
507
  if score > 0:
@@ -595,13 +602,27 @@ def score_template_repetition(content: str) -> dict:
595
602
  这里是接入点,且标定集能立刻给出 before/after。
596
603
  """
597
604
  text = (content or "").strip()
598
- if len(text) < 120:
605
+ # ── 序数连接词与流水线套话检测(对抗现代批量模板/AI农场)──
606
+ connectors = ("首先", "其次", "然后", "接下来", "最后", "综上所述")
607
+ hit_conn = sum(1 for w in connectors if w in text)
608
+ ai_phrases = ("业内人士表示", "专家分析", "消费者认为", "数据显示", "有望成为")
609
+ hit_ai = sum(1 for w in ai_phrases if w in text)
610
+ conn_boost = 0.0
611
+ if hit_conn >= 4:
612
+ conn_boost = 0.85
613
+ elif hit_conn >= 3 and hit_ai >= 1:
614
+ conn_boost = 0.80
615
+ elif hit_ai >= 3:
616
+ conn_boost = 0.78
617
+
618
+ if len(text) < 120 and conn_boost == 0.0:
599
619
  return {"score": 0.0, "templates": 0, "sentences": 0, "repetition": 0.0}
600
620
  # 分句:中英标点 + 换行
601
621
  sents = [s.strip() for s in re.split(r"[。!?!?;;\n]+", text) if len(s.strip()) >= 6]
602
622
  if len(sents) < 4:
603
- return {"score": 0.0, "templates": len(sents), "sentences": len(sents),
604
- "repetition": 0.0}
623
+ base_rep = conn_boost
624
+ return {"score": round(base_rep, 3), "templates": len(sents), "sentences": len(sents),
625
+ "repetition": round(base_rep, 3)}
605
626
  sigs: list[str] = []
606
627
  for s in sents:
607
628
  head = s[:2]
@@ -610,7 +631,7 @@ def score_template_repetition(content: str) -> dict:
610
631
  bucket = "S" if len(s) < 25 else ("M" if len(s) < 60 else "L")
611
632
  sigs.append(f"{head}|{tail}|{bucket}")
612
633
  uniq = len(set(sigs))
613
- repetition = 1.0 - uniq / len(sigs)
634
+ repetition = max(1.0 - uniq / len(sigs), conn_boost)
614
635
  return {
615
636
  "score": round(min(repetition, 1.0), 3),
616
637
  "templates": uniq,