foxclaw 0.1.2 → 0.1.4

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 (368) hide show
  1. package/index.js +1 -8
  2. package/package.json +1 -1
  3. package/python/.env.example +8 -0
  4. package/python/.github/workflows/ci.yml +47 -2
  5. package/python/.github/workflows/release.yml +5 -0
  6. package/python/CHANGELOG.md +100 -0
  7. package/python/CHANGELOG_CN.md +100 -0
  8. package/python/README.md +17 -2
  9. package/python/README_CN.md +38 -20
  10. package/python/bench/bm25_audit.py +1 -14
  11. package/python/bench/bm25_regression.py +0 -1
  12. package/python/bench/bm25_stress.py +0 -4
  13. package/python/bench/cd_audit.py +0 -23
  14. package/python/bench/chaos.py +6 -8
  15. package/python/bench/e2e_real_models.py +0 -1
  16. package/python/bench/llm_retry_test.py +2 -9
  17. package/python/bench/load_test.py +5 -8
  18. package/python/bench/vector_ivf_upgrade.py +1 -7
  19. package/python/docs/CONTENT_GUARD.md +189 -0
  20. package/python/docs/CONTENT_GUARD_CN.md +189 -0
  21. package/python/docs/RELEASE.md +78 -37
  22. package/python/docs/RUN_CN.md +2 -2
  23. package/python/evals/governance/cases.py +0 -37
  24. package/python/evals/governance/run_evals.py +5 -37
  25. package/python/examples/governance_middleware_demo.py +1 -6
  26. package/python/examples/langgraph_governance_demo.py +7 -11
  27. package/python/foxclaw/__init__.py +74 -152
  28. package/python/foxclaw/agents/base.py +82 -136
  29. package/python/foxclaw/agents/impl/_helpers.py +2 -10
  30. package/python/foxclaw/agents/impl/finance.py +4 -21
  31. package/python/foxclaw/agents/impl/office.py +7 -24
  32. package/python/foxclaw/agents/impl/supervisor.py +12 -79
  33. package/python/foxclaw/agents/mixins/_context.py +0 -23
  34. package/python/foxclaw/agents/mixins/augment.py +10 -16
  35. package/python/foxclaw/agents/mixins/llm.py +7 -6
  36. package/python/foxclaw/agents/mixins/memory.py +23 -3
  37. package/python/foxclaw/agents/mixins/preference.py +58 -8
  38. package/python/foxclaw/agents/mixins/prompt.py +59 -12
  39. package/python/foxclaw/agents/mixins/skillbox.py +6 -6
  40. package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
  41. package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
  42. package/python/foxclaw/api/__init__.py +18 -77
  43. package/python/foxclaw/api/_state.py +43 -0
  44. package/python/foxclaw/api/bootstrap.py +114 -0
  45. package/python/foxclaw/api/middleware.py +5 -28
  46. package/python/foxclaw/api/routes/ahs.py +60 -73
  47. package/python/foxclaw/api/routes/attachments.py +5 -6
  48. package/python/foxclaw/api/routes/browser.py +7 -11
  49. package/python/foxclaw/api/routes/domain.py +18 -26
  50. package/python/foxclaw/api/routes/knowledge.py +3 -21
  51. package/python/foxclaw/api/routes/llm_admin.py +21 -33
  52. package/python/foxclaw/api/routes/memory.py +50 -12
  53. package/python/foxclaw/api/routes/orchestration.py +3 -5
  54. package/python/foxclaw/api/routes/recovery.py +4 -3
  55. package/python/foxclaw/api/routes/review.py +2 -11
  56. package/python/foxclaw/api/routes/search.py +3 -7
  57. package/python/foxclaw/api/routes/system.py +33 -49
  58. package/python/foxclaw/api/routes/task.py +31 -49
  59. package/python/foxclaw/api/routes/ws.py +2 -6
  60. package/python/foxclaw/api/spine.py +1 -4
  61. package/python/foxclaw/behavior/__init__.py +20 -0
  62. package/python/foxclaw/behavior/behavior.yaml +62 -0
  63. package/python/foxclaw/behavior/spec.py +287 -0
  64. package/python/foxclaw/browser/action/action_executor.py +1 -5
  65. package/python/foxclaw/browser/action/action_types.py +0 -1
  66. package/python/foxclaw/browser/action/url_policy.py +1 -5
  67. package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
  68. package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
  69. package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
  70. package/python/foxclaw/browser/perception/element_indexer.py +1 -2
  71. package/python/foxclaw/browser/service.py +7 -11
  72. package/python/foxclaw/capability/embedding_service.py +4 -6
  73. package/python/foxclaw/channels/__init__.py +9 -11
  74. package/python/foxclaw/channels/discord.py +4 -13
  75. package/python/foxclaw/channels/dispatcher.py +2 -27
  76. package/python/foxclaw/channels/feishu.py +10 -20
  77. package/python/foxclaw/channels/openclaw_bridge.py +4 -9
  78. package/python/foxclaw/channels/signing.py +9 -9
  79. package/python/foxclaw/channels/telegram.py +3 -4
  80. package/python/foxclaw/channels/weixin.py +7 -17
  81. package/python/foxclaw/cli.py +42 -13
  82. package/python/foxclaw/cognitive/reasoner.py +9 -10
  83. package/python/foxclaw/cognitive/state.py +18 -24
  84. package/python/foxclaw/core/audit_chain.py +1 -3
  85. package/python/foxclaw/core/audit_sink.py +3 -3
  86. package/python/foxclaw/core/compaction.py +8 -26
  87. package/python/foxclaw/core/config.py +2 -2
  88. package/python/foxclaw/core/confirm_nonce.py +6 -12
  89. package/python/foxclaw/core/content_guard.py +341 -0
  90. package/python/foxclaw/core/envfile.py +2 -2
  91. package/python/foxclaw/core/failure_policy.py +17 -26
  92. package/python/foxclaw/core/gateway.py +6 -10
  93. package/python/foxclaw/core/governance_audit.py +3 -7
  94. package/python/foxclaw/core/idempotency.py +15 -22
  95. package/python/foxclaw/core/injection_guard.py +10 -29
  96. package/python/foxclaw/core/memory_adapter.py +53 -60
  97. package/python/foxclaw/core/metrics.py +4 -5
  98. package/python/foxclaw/core/model_router.py +74 -150
  99. package/python/foxclaw/core/paths.py +1 -1
  100. package/python/foxclaw/core/recovery.py +21 -34
  101. package/python/foxclaw/core/scheduler.py +23 -26
  102. package/python/foxclaw/core/secrets.py +0 -3
  103. package/python/foxclaw/core/sensitive_write.py +283 -0
  104. package/python/foxclaw/core/sessions.py +4 -6
  105. package/python/foxclaw/core/settings.py +16 -32
  106. package/python/foxclaw/core/shared_write.py +11 -23
  107. package/python/foxclaw/core/steering_queue.py +12 -11
  108. package/python/foxclaw/core/system_bus.py +1 -1
  109. package/python/foxclaw/core/thought_chain.py +30 -32
  110. package/python/foxclaw/core/token_meter.py +9 -13
  111. package/python/foxclaw/core/tools.py +4 -12
  112. package/python/foxclaw/core/tracing.py +25 -40
  113. package/python/foxclaw/core/truth_pipeline.py +5 -9
  114. package/python/foxclaw/core/update_check.py +149 -0
  115. package/python/foxclaw/core/vec_recall_adapter.py +11 -37
  116. package/python/foxclaw/core/verify.py +6 -14
  117. package/python/foxclaw/core/web_search.py +4 -28
  118. package/python/foxclaw/finance/evidence.py +14 -18
  119. package/python/foxclaw/finance/fx.py +1 -3
  120. package/python/foxclaw/finance/graph.py +9 -18
  121. package/python/foxclaw/finance/models.py +20 -24
  122. package/python/foxclaw/finance/nlparse.py +12 -72
  123. package/python/foxclaw/finance/policy.py +8 -36
  124. package/python/foxclaw/finance/reasoner.py +3 -11
  125. package/python/foxclaw/finance/reasoning.py +13 -34
  126. package/python/foxclaw/finance/reconcile.py +5 -10
  127. package/python/foxclaw/finance/runtime.py +19 -46
  128. package/python/foxclaw/governance/middleware.py +13 -21
  129. package/python/foxclaw/harness/_base.py +2 -8
  130. package/python/foxclaw/harness/_policy.py +19 -38
  131. package/python/foxclaw/harness/drift.py +1 -13
  132. package/python/foxclaw/harness/event_bus.py +2 -5
  133. package/python/foxclaw/harness/harness.py +9 -97
  134. package/python/foxclaw/harness/loop_detection.py +11 -18
  135. package/python/foxclaw/itinerary/amap_client.py +2 -5
  136. package/python/foxclaw/itinerary/models.py +7 -7
  137. package/python/foxclaw/itinerary/reasoner.py +1 -6
  138. package/python/foxclaw/itinerary/reasoning.py +15 -21
  139. package/python/foxclaw/itinerary/runtime.py +8 -12
  140. package/python/foxclaw/kernel/capabilities.py +5 -6
  141. package/python/foxclaw/kernel/policy_gate.py +4 -10
  142. package/python/foxclaw/kernel/process_manager.py +17 -36
  143. package/python/foxclaw/kernel/scheduler.py +3 -9
  144. package/python/foxclaw/kernel/sentinel.py +13 -23
  145. package/python/foxclaw/kernel/sentinel_client.py +12 -15
  146. package/python/foxclaw/kernel/spine.py +9 -9
  147. package/python/foxclaw/knowledge/__init__.py +4 -20
  148. package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
  149. package/python/foxclaw/learning/knowledge_graph.py +4 -9
  150. package/python/foxclaw/learning/models.py +13 -13
  151. package/python/foxclaw/learning/reasoner.py +1 -4
  152. package/python/foxclaw/learning/runtime.py +19 -31
  153. package/python/foxclaw/learning/spaced_repetition.py +2 -7
  154. package/python/foxclaw/mcp_server.py +2 -3
  155. package/python/foxclaw/orchestration/__init__.py +16 -33
  156. package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
  157. package/python/foxclaw/pipeline/stage_meta.py +21 -0
  158. package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
  159. package/python/foxclaw/planning/llm_planner.py +3 -3
  160. package/python/foxclaw/planning/planner.py +6 -11
  161. package/python/foxclaw/search/agent_port.py +7 -11
  162. package/python/foxclaw/search/evidence.py +0 -4
  163. package/python/foxclaw/search/internal_search.py +3 -5
  164. package/python/foxclaw/search/models.py +11 -13
  165. package/python/foxclaw/search/planner.py +3 -3
  166. package/python/foxclaw/search/retriever.py +1 -1
  167. package/python/foxclaw/search/runtime.py +1 -8
  168. package/python/foxclaw/skills/__init__.py +46 -100
  169. package/python/foxclaw/skills/archive.py +111 -0
  170. package/python/foxclaw/skills/fetch.py +226 -0
  171. package/python/foxclaw/skills/hooks_spec.py +21 -25
  172. package/python/foxclaw/skills/install_cmd.py +216 -0
  173. package/python/foxclaw/skills/lifecycle.py +16 -42
  174. package/python/foxclaw/skills/markdown_skill.py +10 -9
  175. package/python/foxclaw/skills/mcp_client.py +5 -11
  176. package/python/foxclaw/skills/mcp_store.py +0 -3
  177. package/python/foxclaw/skills/permission.py +13 -48
  178. package/python/foxclaw/skills/pipeline.py +21 -53
  179. package/python/foxclaw/skills/policy.py +4 -10
  180. package/python/foxclaw/skills/review.py +3 -3
  181. package/python/frontend/deck/auth.js +0 -7
  182. package/python/frontend/deck/chat_model_picker.js +0 -54
  183. package/python/frontend/deck/domain.js +0 -20
  184. package/python/frontend/deck/main.js +0 -144
  185. package/python/frontend/deck/markdown.js +0 -4
  186. package/python/frontend/deck/settings.js +1 -18
  187. package/python/frontend/deck/state.js +0 -1
  188. package/python/frontend/deck/util.js +0 -2
  189. package/python/frontend/deck/visuals.js +0 -22
  190. package/python/frontend/planet/planet.js +18 -201
  191. package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
  192. package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
  193. package/python/frontend/v3/index.html +3 -3
  194. package/python/frontend/widget_runtime.js +0 -43
  195. package/python/frontend/widgets.js +0 -57
  196. package/python/frontend_src/index.html +1 -1
  197. package/python/frontend_src/package-lock.json +2 -2
  198. package/python/frontend_src/package.json +1 -1
  199. package/python/frontend_src/src/App.vue +3 -7
  200. package/python/frontend_src/src/api.js +0 -7
  201. package/python/frontend_src/src/components/ChatDock.vue +27 -39
  202. package/python/frontend_src/src/components/DocViewer.vue +0 -9
  203. package/python/frontend_src/src/components/Inspector.vue +0 -6
  204. package/python/frontend_src/src/components/LeftRail.vue +60 -39
  205. package/python/frontend_src/src/components/MenuPopover.vue +9 -11
  206. package/python/frontend_src/src/components/MessageStream.vue +18 -30
  207. package/python/frontend_src/src/components/ModelPicker.vue +0 -5
  208. package/python/frontend_src/src/components/QuickIngest.vue +0 -6
  209. package/python/frontend_src/src/components/Stage.vue +71 -25
  210. package/python/frontend_src/src/components/ToolMenu.vue +0 -3
  211. package/python/frontend_src/src/components/TopBar.vue +38 -10
  212. package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
  213. package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
  214. package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
  215. package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -3
  216. package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
  217. package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
  218. package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
  219. package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
  220. package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
  221. package/python/frontend_src/src/lib/fmt.js +0 -4
  222. package/python/frontend_src/src/lib/i18n.js +53 -0
  223. package/python/frontend_src/src/lib/locales/en.js +173 -0
  224. package/python/frontend_src/src/lib/locales/zh.js +174 -0
  225. package/python/frontend_src/src/lib/md.js +0 -14
  226. package/python/frontend_src/src/main.js +2 -0
  227. package/python/frontend_src/src/stores/app.js +17 -32
  228. package/python/frontend_src/src/stores/chat.js +0 -34
  229. package/python/frontend_src/src/stores/model.js +0 -8
  230. package/python/frontend_src/src/stores/panels.js +16 -44
  231. package/python/frontend_src/src/stores/profile.js +31 -0
  232. package/python/frontend_src/src/stores/system.js +25 -0
  233. package/python/frontend_src/vite.config.js +0 -7
  234. package/python/pyproject.toml +17 -3
  235. package/python/requirements.txt +311 -0
  236. package/python/tests/conftest.py +0 -22
  237. package/python/tests/run_all.py +4 -66
  238. package/python/tests/test_agent_lifecycle.py +4 -38
  239. package/python/tests/test_agent_rules.py +81 -0
  240. package/python/tests/test_ahs_p2.py +4 -25
  241. package/python/tests/test_anthropic_tools.py +1 -15
  242. package/python/tests/test_api.py +19 -9
  243. package/python/tests/test_api_auth.py +1 -5
  244. package/python/tests/test_attachments.py +9 -25
  245. package/python/tests/test_audit_chain.py +2 -7
  246. package/python/tests/test_audit_sink.py +0 -6
  247. package/python/tests/test_behavior_spec.py +198 -0
  248. package/python/tests/test_browser.py +2 -6
  249. package/python/tests/test_browser_request_guard.py +0 -4
  250. package/python/tests/test_capabilities.py +0 -6
  251. package/python/tests/test_channel_signing.py +2 -9
  252. package/python/tests/test_channels.py +1 -11
  253. package/python/tests/test_config_layering.py +0 -4
  254. package/python/tests/test_confirm_nonce.py +1 -12
  255. package/python/tests/test_conflict_dismiss.py +143 -0
  256. package/python/tests/test_content_guard.py +177 -0
  257. package/python/tests/test_context_compaction_wiring.py +1 -7
  258. package/python/tests/test_dispatch.py +0 -6
  259. package/python/tests/test_domain_evals.py +11 -80
  260. package/python/tests/test_domain_import.py +0 -6
  261. package/python/tests/test_domain_reasoners.py +0 -14
  262. package/python/tests/test_e2e.py +0 -17
  263. package/python/tests/test_emb_cache.py +7 -17
  264. package/python/tests/test_embedding_singleton.py +1 -3
  265. package/python/tests/test_evals_smoke.py +0 -24
  266. package/python/tests/test_event_spine.py +10 -18
  267. package/python/tests/test_evidence_semantic.py +1 -14
  268. package/python/tests/test_evidence_sse.py +3 -6
  269. package/python/tests/test_file_ingest.py +0 -11
  270. package/python/tests/test_finance.py +0 -9
  271. package/python/tests/test_finance_ledger.py +5 -12
  272. package/python/tests/test_finance_persist.py +8 -12
  273. package/python/tests/test_gateway.py +3 -22
  274. package/python/tests/test_governance.py +5 -52
  275. package/python/tests/test_governance_audit.py +4 -39
  276. package/python/tests/test_governance_middleware.py +1 -7
  277. package/python/tests/test_hkc.py +6 -24
  278. package/python/tests/test_hkc_index_build_manager.py +0 -33
  279. package/python/tests/test_hkc_manifest_reuse.py +4 -27
  280. package/python/tests/test_hkc_search_event_sync.py +3 -54
  281. package/python/tests/test_import_path_guard.py +0 -6
  282. package/python/tests/test_injection_guard.py +18 -36
  283. package/python/tests/test_integration.py +5 -29
  284. package/python/tests/test_itinerary.py +0 -3
  285. package/python/tests/test_kee_semantic.py +0 -5
  286. package/python/tests/test_kernel.py +1 -11
  287. package/python/tests/test_learning.py +3 -12
  288. package/python/tests/test_llm_admin.py +4 -19
  289. package/python/tests/test_llm_planner.py +1 -23
  290. package/python/tests/test_markdown_skills.py +3 -14
  291. package/python/tests/test_markdown_skills_openclaw.py +4 -9
  292. package/python/tests/test_mcp_connectors.py +3 -15
  293. package/python/tests/test_mcp_guardian_discovery.py +2 -6
  294. package/python/tests/test_mcp_no_memory.py +3 -3
  295. package/python/tests/test_mcp_policy_gate.py +0 -1
  296. package/python/tests/test_mcp_server.py +6 -9
  297. package/python/tests/test_memory_recall.py +1 -4
  298. package/python/tests/test_metrics.py +0 -8
  299. package/python/tests/test_multi_chain.py +1 -13
  300. package/python/tests/test_multimodal.py +5 -18
  301. package/python/tests/test_negotiation.py +3 -13
  302. package/python/tests/test_orchestration.py +1 -11
  303. package/python/tests/test_orchestration_decoupled.py +0 -6
  304. package/python/tests/test_p3_p5_hardening.py +3 -17
  305. package/python/tests/test_parallel_dispatch.py +4 -12
  306. package/python/tests/test_pipeline.py +17 -58
  307. package/python/tests/test_plan_visible.py +4 -4
  308. package/python/tests/test_planner.py +0 -7
  309. package/python/tests/test_policy_gate.py +2 -3
  310. package/python/tests/test_profile.py +67 -0
  311. package/python/tests/test_readme_claims.py +163 -0
  312. package/python/tests/test_reasoner.py +2 -17
  313. package/python/tests/test_release_check.py +2 -13
  314. package/python/tests/test_resume_checkpoint.py +7 -15
  315. package/python/tests/test_routing.py +2 -12
  316. package/python/tests/test_scheduler.py +7 -10
  317. package/python/tests/test_search.py +1 -11
  318. package/python/tests/test_secrets.py +4 -10
  319. package/python/tests/test_semantic.py +1 -10
  320. package/python/tests/test_sensitive_write.py +181 -0
  321. package/python/tests/test_sessions.py +3 -4
  322. package/python/tests/test_settings.py +2 -18
  323. package/python/tests/test_shadow_learning.py +3 -43
  324. package/python/tests/test_shared_write.py +0 -7
  325. package/python/tests/test_skill_taint_gate.py +1 -10
  326. package/python/tests/test_skills.py +2 -18
  327. package/python/tests/test_skills_fetch.py +282 -0
  328. package/python/tests/test_stats_and_events.py +2 -5
  329. package/python/tests/test_streaming.py +2 -7
  330. package/python/tests/test_supervisor_pipeline.py +4 -5
  331. package/python/tests/test_supervisor_triage.py +4 -10
  332. package/python/tests/test_taint.py +2 -7
  333. package/python/tests/test_task_stream.py +1 -12
  334. package/python/tests/test_thinking_sse.py +0 -1
  335. package/python/tests/test_tool_center.py +1 -17
  336. package/python/tests/test_tool_parallel.py +2 -2
  337. package/python/tests/test_tool_params.py +1 -5
  338. package/python/tests/test_tool_use.py +0 -13
  339. package/python/tests/test_toplevel_concurrency.py +3 -4
  340. package/python/tests/test_tracing.py +7 -41
  341. package/python/tests/test_truth_crystallizer.py +4 -6
  342. package/python/tests/test_truth_flow.py +2 -7
  343. package/python/tests/test_update_check.py +95 -0
  344. package/python/tests/test_verify_evidence.py +8 -21
  345. package/python/tests/test_version_sync.py +35 -6
  346. package/python/tests/test_web_trigger.py +0 -17
  347. package/python/tools/build_content_lexicon.py +218 -0
  348. package/python/tools/bundle_web.py +98 -0
  349. package/python/tools/check_faiss_type.py +0 -1
  350. package/python/tools/check_log.ps1 +0 -8
  351. package/python/tools/mypy_baseline.txt +12 -4
  352. package/python/tools/mypy_ratchet.py +52 -6
  353. package/python/tools/pack.py +0 -5
  354. package/python/tools/recheck_conflicts.py +156 -0
  355. package/python/tools/release_check.py +24 -15
  356. package/python/tools/restart_foxclaw.ps1 +5 -8
  357. package/python/tools/shared_write_audit.py +1 -15
  358. package/python/tools/size_check.py +1 -1
  359. package/python/tools/strip_comments.py +228 -0
  360. package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
  361. package/python/vendor/hkc/hkc_api/schemas.py +11 -0
  362. package/python/vendor/hkc/hkc_kee/kee.py +124 -3
  363. package/python/vendor/hkc/hkc_sdk/base.py +22 -0
  364. package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
  365. package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
  366. package/python/foxclaw/api/app.py +0 -123
  367. package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
  368. package/python/frontend/v3/assets/index-CAvnYWGH.css +0 -1
@@ -0,0 +1,341 @@
1
+ """内容安全闸门 (ContentGuard):合规红线的确定性拦截,输入与输出两端。
2
+
3
+ 为什么要有这一层
4
+ ────────────────
5
+ KERNEL_PROMPT 的【五、安全】管的是「指令 vs 数据」(别被外部内容骗),behavior.yaml 的
6
+ confirm_* 管的是「别乱动高危动作」。两者都不管「这话题本身不能碰」:模型仍可能被诱导
7
+ 生成违规内容,或用户直接索要。提示词只能降低概率,对外交付的产品需要可复现的兜底。
8
+
9
+ 所以这里只做确定性判定:**不做语义理解、不调模型**。命中就按固定话术切断,
10
+ 不解释、不延伸、不反问 —— 拒绝本身不能变成信息泄漏或继续纠缠的入口。
11
+
12
+ 三层词表来源
13
+ ────────────
14
+ 1. 内置规则(本文件 `_BUILTIN_RULES`):只放**请求型教唆**的高置信度模式 ——「怎么制作
15
+ 炸药」「教我自杀方法」这类。措辞窄,误伤率极低,开箱即用。
16
+ 2. 外部词表(`FOXCLAW_CONTENT_LEXICON`,逗号分隔多个路径):一行一词,`#` 注释,
17
+ 可选 `词<TAB>类别`。平台审核要求的大类词表(政治/色情/暴力)体量在数万条量级,
18
+ 硬编码进代码既不现实也无法随监管口径更新,交由部署方挂载。
19
+ 3. 自定义类别:外部词表里的类别名会原样出现在命中结果里,便于审计归类。
20
+
21
+ 为什么不内置大词表
22
+ ────────────────
23
+ 六万条量级的词表进仓库,一是体积与构建成本,二是监管口径变化时改代码而非改配置。
24
+ 更重要的是:知识库导入、文档解析属于**用户自己的资料**,不在这里过滤 —— 过滤器只挂在
25
+ 「用户请求」与「模型输出」两端,避免把历史书、法律条文、医学教材的内容当成违规请求。
26
+
27
+ 三档模式 (FOXCLAW_CONTENT_GUARD_MODE)
28
+ ────────────────────────────────────
29
+ enforce 真正拦截:输入命中直接回固定话术、不进模型;输出命中整段替换。
30
+ audit 只记录不拦截(默认之外的灰度档,用于观察误伤率)。
31
+ off 完全关闭。
32
+
33
+ 默认 enforce:这一层是给对外产品兜底的,默认不生效等于没做。内置规则范围窄,
34
+ 所以默认强制不会误伤日常对话;是否挂平台大类词表由 `FOXCLAW_CONTENT_LEXICON` 决定。
35
+
36
+ 归一化对抗
37
+ ──────────
38
+ 全角、零宽字符、空格插字、大小写是最常见的绕过手法,扫描前统一归一化,
39
+ 并对「去掉空白标点后的紧凑串」再扫一遍。拼音/谐音/拆字改写不在确定性层面处理 ——
40
+ 那是提示词与人工审核的职责,这里明确只做限速带,不做墙。
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ import re
46
+ import unicodedata
47
+ from dataclasses import dataclass
48
+ from pathlib import Path
49
+ from typing import Iterable
50
+
51
+ MODE_ENV = "FOXCLAW_CONTENT_GUARD_MODE"
52
+ LEXICON_ENV = "FOXCLAW_CONTENT_LEXICON"
53
+ VALID_MODES = ("off", "audit", "enforce")
54
+
55
+ REFUSAL_ZH = "我们换个话题聊聊吧。"
56
+ REFUSAL_EN = "Let's talk about something else."
57
+
58
+ # 内置规则只覆盖「请求型教唆」:有明确动作词 + 明确对象,措辞窄、误伤低。
59
+ _BUILTIN_RULES: dict[str, tuple[str, ...]] = {
60
+ "illegal_solicit": (
61
+ r"(?:怎么|如何|教我|教我如何|帮我|求|来个|写个|写一个).{0,12}"
62
+ r"(?:制作|合成|提炼|配制|自制).{0,8}(?:毒品|冰毒|海洛因|摇头丸|炸弹|炸药|雷管|枪支)",
63
+ r"(?:毒品|冰毒|海洛因|炸弹|炸药).{0,8}(?:制作|合成|提炼|配方|教程|方法)",
64
+ r"(?:洗钱|跑分|杀猪盘|电信诈骗).{0,8}(?:教程|方法|流程|怎么|如何|话术)",
65
+ # 负向前瞻:排除「如何防止/避免入侵」这类防御性提问,那是正常需求。
66
+ r"(?:怎么|如何|教我|帮我|求)(?!.{0,6}(?:防止|避免|防范|抵御|修复)).{0,12}"
67
+ r"(?:入侵|攻击|破解).{0,10}(?:别人|他人|网站|服务器|系统|账号)",
68
+ r"(?:伪造|办假).{0,6}(?:身份证|护照|证件|发票|公章)",
69
+ ),
70
+ "self_harm": (
71
+ r"(?:怎么|如何|教我|最好的方式|最不痛|无痛).{0,10}(?:自杀|轻生|结束生命)",
72
+ r"(?:自杀|自残|割腕).{0,8}(?:方法|教程|最不痛|无痛|怎么才)",
73
+ ),
74
+ "minor_sexual": (
75
+ r"(?:未成年|儿童|幼女|幼童|萝莉).{0,8}(?:色情|裸照|裸体|性行为|做爱|性交)",
76
+ r"(?:色情|裸照).{0,8}(?:未成年|儿童|幼女)",
77
+ ),
78
+ }
79
+
80
+ _BUILTIN_COMPILED = {cat: [re.compile(p, re.IGNORECASE) for p in pats]
81
+ for cat, pats in _BUILTIN_RULES.items()}
82
+
83
+ _ZERO_WIDTH = dict.fromkeys(map(ord, "\u200b\u200c\u200d\u2060\ufeff\u00ad"))
84
+ # 紧凑串去掉的字符:空白 + 常见中英标点,用来对抗「敏 感 词」「敏*感*词」这类插字绕过。
85
+ _STRIP_RX = re.compile(r"[\s\u3000·・\-_*~,.;:!?/\\|'\"`()\[\]{}<>@#$%^&+=、。,;:!?…—~《》“”‘’()【】]")
86
+
87
+
88
+ def _has_cjk(text: str) -> bool:
89
+ return any("\u4e00" <= ch <= "\u9fff" for ch in text)
90
+
91
+
92
+ def refusal_for(text: str) -> str:
93
+ """固定话术:跟随输入语种,只有中英两种 —— 拒绝语本身不该携带任何额外信息。"""
94
+ return REFUSAL_ZH if _has_cjk(text) else REFUSAL_EN
95
+
96
+
97
+ def normalize(text: str) -> str:
98
+ """NFKC(全半角/兼容字符)+ 去零宽 + 小写。"""
99
+ s = unicodedata.normalize("NFKC", str(text or "")).translate(_ZERO_WIDTH)
100
+ return s.lower()
101
+
102
+
103
+ def compact(text: str) -> str:
104
+ """归一化后再去掉空白与标点,用于抗插字绕过。"""
105
+ return _STRIP_RX.sub("", normalize(text))
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class Hit:
110
+ category: str
111
+ word: str
112
+ source: str # builtin / lexicon
113
+
114
+ def to_dict(self) -> dict:
115
+ return {"category": self.category, "word": self.word, "source": self.source}
116
+
117
+
118
+ class _Trie:
119
+ """Aho-Corasick:一次性建表,之后线性扫描。
120
+
121
+ 词表可能上万条,逐词 `in text` 是不可接受的;纯 Python 实现避免为此引入新依赖。
122
+ """
123
+
124
+ __slots__ = ("goto", "fail", "out")
125
+
126
+ def __init__(self, entries: Iterable[tuple[str, str, str]]):
127
+ self.goto: list[dict[str, int]] = [{}]
128
+ self.fail: list[int] = [0]
129
+ self.out: list[list[tuple[str, str, str]]] = [[]]
130
+ for word, cat, src in entries:
131
+ self._add(word, cat, src)
132
+ self._build()
133
+
134
+ def _add(self, word: str, cat: str, src: str) -> None:
135
+ node = 0
136
+ for ch in word:
137
+ nxt = self.goto[node].get(ch)
138
+ if nxt is None:
139
+ nxt = len(self.goto)
140
+ self.goto[node][ch] = nxt
141
+ self.goto.append({})
142
+ self.fail.append(0)
143
+ self.out.append([])
144
+ node = nxt
145
+ self.out[node].append((word, cat, src))
146
+
147
+ def _build(self) -> None:
148
+ queue = list(self.goto[0].values())
149
+ for n in queue:
150
+ self.fail[n] = 0
151
+ head = 0
152
+ while head < len(queue):
153
+ node = queue[head]
154
+ head += 1
155
+ for ch, nxt in self.goto[node].items():
156
+ queue.append(nxt)
157
+ f = self.fail[node]
158
+ while f and ch not in self.goto[f]:
159
+ f = self.fail[f]
160
+ self.fail[nxt] = self.goto[f].get(ch, 0)
161
+ self.out[nxt].extend(self.out[self.fail[nxt]])
162
+
163
+ def find(self, text: str) -> list[tuple[str, str, str]]:
164
+ node, found = 0, []
165
+ for ch in text:
166
+ while node and ch not in self.goto[node]:
167
+ node = self.fail[node]
168
+ node = self.goto[node].get(ch, 0)
169
+ if self.out[node]:
170
+ found.extend(self.out[node])
171
+ return found
172
+
173
+
174
+ def load_lexicon(paths: Iterable[str | Path]) -> list[tuple[str, str, str]]:
175
+ """读取外部词表。一行一词:`词` 或 `词<TAB>类别`;`#` 开头为注释。
176
+
177
+ 读不到或格式不对的词直接跳过 —— 词表是部署方维护的外部输入,
178
+ 不能因为一个坏行让整个守卫启动失败(那样只会被关掉)。
179
+ """
180
+ entries: list[tuple[str, str, str]] = []
181
+ for raw in paths:
182
+ p = Path(raw).expanduser()
183
+ try:
184
+ text = p.read_text(encoding="utf-8", errors="ignore")
185
+ except OSError:
186
+ continue
187
+ for line in text.splitlines():
188
+ s = line.strip()
189
+ if not s or s.startswith("#"):
190
+ continue
191
+ word, _, cat = s.partition("\t")
192
+ word = compact(word)
193
+ if len(word) < 2:
194
+ continue
195
+ entries.append((word, (cat.strip() or "platform"), "lexicon"))
196
+ return entries
197
+
198
+
199
+ @dataclass
200
+ class Decision:
201
+ blocked: bool
202
+ mode: str
203
+ hit: Hit | None = None
204
+ text: str = ""
205
+
206
+ def to_dict(self) -> dict:
207
+ return {"blocked": self.blocked, "mode": self.mode,
208
+ "hit": self.hit.to_dict() if self.hit else None}
209
+
210
+
211
+ class ContentGuard:
212
+ """输入/输出两端的内容安全闸门。无状态判定 + 可挂载,与 SensitiveWritePolicy 同构。"""
213
+
214
+ def __init__(self, mode: str | None = None, lexicon_paths: Iterable[str] | None = None,
215
+ builtin: bool = True):
216
+ self.mode = self._resolve_mode(mode)
217
+ self._builtin = _BUILTIN_COMPILED if builtin else {}
218
+ entries = load_lexicon(lexicon_paths if lexicon_paths is not None
219
+ else self._env_lexicons())
220
+ self._trie = _Trie(entries) if entries else None
221
+ self.lexicon_size = len(entries)
222
+
223
+ @staticmethod
224
+ def _resolve_mode(mode: str | None = None) -> str:
225
+ m = (mode or os.environ.get(MODE_ENV, "") or "enforce").strip().lower()
226
+ return m if m in VALID_MODES else "enforce"
227
+
228
+ @staticmethod
229
+ def _env_lexicons() -> list[str]:
230
+ raw = os.environ.get(LEXICON_ENV, "").strip()
231
+ return [x.strip() for x in raw.split(",") if x.strip()]
232
+
233
+ def enabled(self) -> bool:
234
+ return self.mode != "off"
235
+
236
+ def scan(self, text: str) -> tuple[Hit, ...]:
237
+ """返回命中项(去重,保序)。文本为空或未启用时返回空。"""
238
+ if not self.enabled() or not text:
239
+ return ()
240
+ hits: list[Hit] = []
241
+ seen: set = set()
242
+
243
+ def _push(word: str, cat: str, src: str) -> None:
244
+ key = (cat, word)
245
+ if key not in seen:
246
+ seen.add(key)
247
+ hits.append(Hit(cat, word, src))
248
+
249
+ # 原文 + 紧凑串两遍:原文保住命中片段可读,紧凑串对付全角与插字绕过。
250
+ variants = (text, compact(text))
251
+ for cat, patterns in self._builtin.items():
252
+ for rx in patterns:
253
+ for v in variants:
254
+ m = rx.search(v)
255
+ if m:
256
+ _push(m.group(0), cat, "builtin")
257
+ break
258
+ else:
259
+ continue
260
+ break
261
+
262
+ if self._trie is not None:
263
+ for word, cat, src in self._trie.find(compact(text)):
264
+ _push(word, cat, src)
265
+ return tuple(hits)
266
+
267
+ def check(self, text: str) -> Decision:
268
+ """判定一段文本。enforce 命中 → blocked=True;audit 只记录。"""
269
+ hits = self.scan(text)
270
+ if not hits:
271
+ return Decision(False, self.mode)
272
+ hit = hits[0]
273
+ if self.mode == "enforce":
274
+ return Decision(True, self.mode, hit, refusal_for(text))
275
+ print(f"[Foxclaw] ⚠ 内容命中(未拦截): category={hit.category} word={hit.word!r}")
276
+ return Decision(False, self.mode, hit)
277
+
278
+ def guard_input(self, text: str) -> Decision:
279
+ return self.check(text)
280
+
281
+ def sanitize_output(self, text: str) -> Decision:
282
+ """输出端:命中则整段替换为固定话术(不保留任何原文片段)。"""
283
+ d = self.check(text)
284
+ if d.blocked:
285
+ d.text = refusal_for(text)
286
+ return d
287
+
288
+
289
+ class StreamFilter:
290
+ """流式输出的增量过滤。
291
+
292
+ 词可能跨 chunk,所以保留尾部 `window` 字符不立即下发,命中即中止整段输出。
293
+ 这只能保证「命中点之后不再吐字」,已经流出去的片段收不回 —— 流式的固有代价,
294
+ 换来的是不在每个 token 上做全量重扫。
295
+ """
296
+
297
+ def __init__(self, guard: ContentGuard, window: int = 24):
298
+ self.guard = guard
299
+ self.window = max(4, int(window))
300
+ self.buf = ""
301
+ self.tripped = False
302
+ self.decision: Decision | None = None
303
+
304
+ def feed(self, chunk: str) -> str:
305
+ """喂一个增量,返回可以安全下发的部分。命中后恒返回空串。"""
306
+ if not self.guard.enabled():
307
+ return chunk
308
+ if self.tripped:
309
+ return ""
310
+ self.buf += chunk or ""
311
+ d = self.guard.check(self.buf)
312
+ if d.blocked:
313
+ self.tripped = True
314
+ self.decision = d
315
+ self.buf = ""
316
+ return ""
317
+ if len(self.buf) <= self.window:
318
+ return ""
319
+ out, self.buf = self.buf[:-self.window], self.buf[-self.window:]
320
+ return out
321
+
322
+ def finish(self) -> str:
323
+ """收尾:吐出缓冲;若已命中则改用固定话术。"""
324
+ if self.tripped:
325
+ return self.decision.text if self.decision else REFUSAL_ZH
326
+ tail, self.buf = self.buf, ""
327
+ return tail
328
+
329
+
330
+ def attach(router, guard: ContentGuard | None = None):
331
+ """挂到 ModelRouter(LLM 唯一咽喉),输出端过滤对所有 Agent 一次性生效。"""
332
+ router.content_guard = guard or ContentGuard()
333
+ return router.content_guard
334
+
335
+
336
+ def guard_input(router, text: str) -> Decision:
337
+ """对话入口的统一闸门:未挂守卫时一律放行(向后兼容)。"""
338
+ g = getattr(router, "content_guard", None)
339
+ if g is None:
340
+ return Decision(False, "off")
341
+ return g.guard_input(text)
@@ -37,10 +37,10 @@ def load_env_file(path: str | Path) -> List[str]:
37
37
  shadows: List[str] = []
38
38
  for key, value in (dotenv_values(target) or {}).items():
39
39
  if not key:
40
- continue # 文件里的注释行会解出 None 键
40
+ continue
41
41
  value = (value or "").strip()
42
42
  if not value:
43
- continue # 空值 = 未配置,不覆盖系统环境
43
+ continue
44
44
  current = os.environ.get(key, "").strip()
45
45
  if current and current != value:
46
46
  shadows.append(key)
@@ -16,29 +16,24 @@ from __future__ import annotations
16
16
 
17
17
  from dataclasses import dataclass
18
18
 
19
- # ── 失败类型 ────────────────────────────────────────────────────────────
20
19
  OK = "ok"
21
- TIMEOUT = "timeout" # 单工具硬超时(工具轮主动放弃)
22
- TRANSIENT = "transient" # 网络抖动/限流/5xx —— 重试有意义
23
- NOT_CONFIGURED = "not_configured" # 没配 key/服务 —— 重试一万次也没用
24
- EMPTY = "empty" # 调通了但没内容(搜不到/没召回)
25
- INVALID_ARGS = "invalid_args" # 模型给的参数不合法 —— 让模型自己改
26
- DENIED = "denied" # 策略禁用/未知工具 —— 不许绕过
27
- NEEDS_HUMAN = "needs_human" # 红线动作需确认 —— 只能人来做
28
- EXCEPTION = "exception" # 工具内部抛异常
29
- UNKNOWN = "unknown" # 认不出来:保守(不重试、交人)
30
-
31
- #: 动作
20
+ TIMEOUT = "timeout"
21
+ TRANSIENT = "transient"
22
+ NOT_CONFIGURED = "not_configured"
23
+ EMPTY = "empty"
24
+ INVALID_ARGS = "invalid_args"
25
+ DENIED = "denied"
26
+ NEEDS_HUMAN = "needs_human"
27
+ EXCEPTION = "exception"
28
+ UNKNOWN = "unknown"
29
+
32
30
  NONE = "none"
33
- RETRY = "retry" # 自动再试一次(仅只读)
34
- REPARAM = "reparam" # 让模型换参数/换查询词
35
- DEGRADE = "degrade" # 降级如实告知,不重试
36
- HAND_TO_HUMAN = "hand_to_human" # 交人
37
- STOP = "stop" # 终止,不许再试
38
-
39
- #: 特征表。**顺序敏感**:先命中先返回。顺序上的两个坑都踩过:
40
- #: · "未返回结果"(empty)不能先被 "未返回"(timeout) 吃掉;
41
- #: · "不合法"(invalid_args)要在 "拒绝"(denied) 之前,否则参数错误被当成越权。
31
+ RETRY = "retry"
32
+ REPARAM = "reparam"
33
+ DEGRADE = "degrade"
34
+ HAND_TO_HUMAN = "hand_to_human"
35
+ STOP = "stop"
36
+
42
37
  _RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
43
38
  (DENIED, ("已被策略禁用", "被权限策略拒绝", "未知工具")),
44
39
  (NEEDS_HUMAN, ("需确认的动作", "需要你确认", "请用户确认", "只能由你亲自确认")),
@@ -50,7 +45,6 @@ _RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
50
45
  (EXCEPTION, ("执行异常",)),
51
46
  )
52
47
 
53
- #: 类型 → (动作, 是否可重试, 给模型的一句硬提示)。提示写进回注文本,模型没法装作没看见。
54
48
  _ACTIONS: dict[str, tuple[str, bool, str]] = {
55
49
  TIMEOUT: (RETRY, True,
56
50
  "这一步超时未完成,已自动重试一次仍有问题。请如实说明这一步没做成,不要编造结果。"),
@@ -94,9 +88,6 @@ class Decision:
94
88
  "retryable": self.retryable, "hint": self.hint}
95
89
 
96
90
 
97
- #: 失败回注的形态标记。工具执行侧的失败文本统一以 "(" 开头(见 tool_exec 的兜底),
98
- #: 另有少数异常文本不带括号。**不能**用"含 未/失败 就算失败"这种宽松判据:
99
- #: 搜索正文里"未来""失败案例"都会误伤,把成功结果标成失败。
100
91
  _FAILURE_MARKERS = ("执行异常", "入参不合法", "被权限策略拒绝", "缺少必填")
101
92
 
102
93
 
@@ -111,7 +102,7 @@ def classify(text: str) -> str:
111
102
  """把回注文本归类:``ok`` / 具体失败类型 / ``unknown``(认不出来,保守交人)。"""
112
103
  t = (text or "").strip()
113
104
  if not t:
114
- return UNKNOWN # 空结果:不能当成功,也不确定成因
105
+ return UNKNOWN
115
106
  if not looks_like_failure(t):
116
107
  return OK
117
108
  low = t.lower()
@@ -19,15 +19,15 @@ class Gateway:
19
19
  def __init__(self, rate_per_min: int = 600, quota_per_hour: int = 20000,
20
20
  max_clients: int = 4096):
21
21
  self.rate = max(1, int(rate_per_min))
22
- self.capacity = float(self.rate) # 桶容量 = 每分钟额度(允许突发到满桶)
23
- self.refill = self.capacity / 60.0 # 令牌/秒
22
+ self.capacity = float(self.rate)
23
+ self.refill = self.capacity / 60.0
24
24
  self.quota = max(1, int(quota_per_hour))
25
25
  self.max_clients = max(64, int(max_clients))
26
26
  self._lock = threading.Lock()
27
- self._buckets: dict = {} # client -> [tokens, last_ts]
28
- self._quota: dict = {} # client -> [count, window_start]
29
- self._seen: dict = {} # client -> last_ts(用于空闲淘汰)
30
- self.rejected = 0 # 累计拒绝数(可观测)
27
+ self._buckets: dict = {}
28
+ self._quota: dict = {}
29
+ self._seen: dict = {}
30
+ self.rejected = 0
31
31
 
32
32
  def check(self, client: str):
33
33
  """判定一次请求。返回 (allowed: bool, retry_after: int, reason: str)。
@@ -37,7 +37,6 @@ class Gateway:
37
37
  self._evict_locked(now)
38
38
  self._seen[client] = now
39
39
 
40
- # ① 配额(固定 1 小时窗口)
41
40
  q = self._quota.get(client)
42
41
  if q is None or now - q[1] >= 3600.0:
43
42
  q = [0, now]
@@ -46,7 +45,6 @@ class Gateway:
46
45
  self.rejected += 1
47
46
  return False, int(3600.0 - (now - q[1])) + 1, "quota"
48
47
 
49
- # ② 令牌桶(突发限流)
50
48
  b = self._buckets.get(client)
51
49
  if b is None:
52
50
  b = [self.capacity, now]
@@ -57,7 +55,6 @@ class Gateway:
57
55
  self.rejected += 1
58
56
  return False, int((1.0 - b[0]) / self.refill) + 1, "rate"
59
57
 
60
- # 放行:扣 1 令牌 + 计 1 配额
61
58
  b[0] -= 1.0
62
59
  q[0] += 1
63
60
  return True, 0, ""
@@ -67,7 +64,6 @@ class Gateway:
67
64
  if len(self._seen) <= self.max_clients:
68
65
  return
69
66
  n_drop = len(self._seen) - self.max_clients + max(1, self.max_clients // 10)
70
- # 按 last_ts 升序取最旧的 n_drop 个
71
67
  victims = sorted(self._seen.items(), key=lambda kv: kv[1])[:n_drop]
72
68
  for c, _ in victims:
73
69
  self._seen.pop(c, None)
@@ -46,11 +46,9 @@ from . import audit_chain
46
46
  ENV_SWITCH = "FOXCLAW_GOVERNANCE_AUDIT"
47
47
  TOPIC = "governance.decision"
48
48
 
49
- # verdict 合法取值(文档用,不做硬校验——审计不该因词汇笔误崩掉主流程)
50
- VERDICTS_RULING = ("allow", "warn", "deny", "confirm") # 裁决类
51
- VERDICTS_EXECUTION = ("success", "failed") # 执行类
49
+ VERDICTS_RULING = ("allow", "warn", "deny", "confirm")
50
+ VERDICTS_EXECUTION = ("success", "failed")
52
51
 
53
- # 已知层名(同上,仅文档)
54
52
  LAYERS = ("harness", "permission", "orchestration", "skill")
55
53
 
56
54
 
@@ -71,7 +69,6 @@ class GovernanceAuditor:
71
69
  self._spine = spine
72
70
  self._path = Path(sink_path) if sink_path else None
73
71
  self._lock = threading.Lock()
74
- # 链尾 hash:首次落盘时从文件尾部补齐(重启后接着上一条链,而不是重开 GENESIS)
75
72
  self._last_hash: str | None = None
76
73
 
77
74
  @property
@@ -95,7 +92,7 @@ class GovernanceAuditor:
95
92
  "actor": actor,
96
93
  "action": action,
97
94
  "verdict": verdict,
98
- "reason": (reason or "")[:200], # 理由截断,防巨型异常串灌爆审计
95
+ "reason": (reason or "")[:200],
99
96
  "detail": detail or {},
100
97
  "ts": datetime.now(timezone.utc).isoformat(),
101
98
  }
@@ -103,7 +100,6 @@ class GovernanceAuditor:
103
100
  self._persist(rec)
104
101
  return did
105
102
 
106
- # ── 内部:两个通道,各自独立 fail-safe ─────────────────────
107
103
 
108
104
  def _emit(self, rec: dict) -> None:
109
105
  if self._spine is None:
@@ -27,7 +27,7 @@ from dataclasses import dataclass, field
27
27
  class _Record:
28
28
  key: str
29
29
  created: float
30
- result: dict = field(default_factory=dict) # 上次执行的结果(重发时原样返回)
30
+ result: dict = field(default_factory=dict)
31
31
 
32
32
 
33
33
  def make_key(domain: str, *parts: str, version: str = "v1") -> str:
@@ -52,16 +52,14 @@ class IdempotencyGuard:
52
52
  """
53
53
 
54
54
  def __init__(self, ttl_seconds: float = 3600, max_size: int = 10000,
55
- persist_path: str = None, spine=None,
55
+ persist_path: str | None = None, spine=None,
56
56
  compact_every: int = 1000):
57
57
  self.ttl = ttl_seconds
58
58
  self.max_size = max_size
59
59
  self.persist_path = persist_path
60
60
  self._store: dict[str, _Record] = {}
61
61
  self._lock = threading.RLock()
62
- # 【P2-3】审计脊柱(可空,None 时静默)
63
62
  self._spine = spine
64
- # 【P2-3】追加日志 + 定期 compact
65
63
  self._log_path = (persist_path + ".log") if persist_path else None
66
64
  self._compact_every = compact_every
67
65
  self._ops_since_compact = 0
@@ -82,7 +80,7 @@ class IdempotencyGuard:
82
80
  rec = self._store.get(key)
83
81
  return dict(rec.result) if rec else None
84
82
 
85
- def mark(self, key: str, result: dict = None):
83
+ def mark(self, key: str, result: dict | None = None):
86
84
  """标记 key 已执行,记录结果。"""
87
85
  with self._lock:
88
86
  self._store[key] = _Record(key=key, created=time.time(),
@@ -92,9 +90,9 @@ class IdempotencyGuard:
92
90
  result=self._store[key].result)
93
91
  need_compact = self._ops_since_compact >= self._compact_every
94
92
  if need_compact:
95
- self._try_compact() # 全量写盘放主锁外(见 _try_compact)
93
+ self._try_compact()
96
94
 
97
- def check_and_mark(self, key: str, result: dict = None) -> bool:
95
+ def check_and_mark(self, key: str, result: dict | None = None) -> bool:
98
96
  """原子操作:若 key 未见过 → 标记并返回 True(应执行);
99
97
  若已见过 → 返回 False(应跳过)。
100
98
 
@@ -104,7 +102,7 @@ class IdempotencyGuard:
104
102
  with self._lock:
105
103
  self._evict_expired()
106
104
  if key in self._store:
107
- return False # 已处理过,跳过
105
+ return False
108
106
  self._store[key] = _Record(key=key, created=time.time(),
109
107
  result=dict(result or {}))
110
108
  self._enforce_capacity()
@@ -128,7 +126,6 @@ class IdempotencyGuard:
128
126
  if need_compact:
129
127
  self._try_compact()
130
128
 
131
- # ── 内部:过期/容量/持久化 ──────────────────────────────
132
129
  def _evict_expired(self):
133
130
  if self.ttl <= 0:
134
131
  return
@@ -138,9 +135,6 @@ class IdempotencyGuard:
138
135
  del self._store[k]
139
136
 
140
137
  def _enforce_capacity(self):
141
- # 超容量 → 淘汰最旧的(按 created)。
142
- # 【P2-3】淘汰必须落 delete 日志:否则重启回放追加日志时,被淘汰的 key
143
- # 会"复活"(mark 日志还在,没有对应的删除记录)。锁内调用。
144
138
  if len(self._store) <= self.max_size:
145
139
  return
146
140
  items = sorted(self._store.items(), key=lambda kv: kv[1].created)
@@ -148,7 +142,6 @@ class IdempotencyGuard:
148
142
  del self._store[k]
149
143
  self._persist_op("delete", k)
150
144
 
151
- # ── 内部:持久化(追加日志 + 定期 compact)─────────────────
152
145
  def _persist_op(self, event: str, key: str, **fields):
153
146
  """【P2-3】追加一条日志(O(1) 写,不写全量)。锁内调用(append 很快,
154
147
  且在锁内可避免并发写同一文件交错)。失败计数 + 发事件,不影响主流程。"""
@@ -196,11 +189,10 @@ class IdempotencyGuard:
196
189
  for k, r in self._store.items()
197
190
  if self.ttl <= 0 or now - r.created <= self.ttl}
198
191
  self._ops_since_compact = 0
199
- # ── 以下为主锁外的慢 IO ──
200
192
  tmp = self.persist_path + ".tmp"
201
193
  with open(tmp, "w", encoding="utf-8") as f:
202
194
  json.dump(snapshot, f, ensure_ascii=False)
203
- os.replace(tmp, self.persist_path) # 原子替换,防写一半损坏
195
+ os.replace(tmp, self.persist_path)
204
196
  if rotated and os.path.exists(rotated):
205
197
  os.remove(rotated)
206
198
  except Exception:
@@ -222,7 +214,7 @@ class IdempotencyGuard:
222
214
  tmp = self.persist_path + ".tmp"
223
215
  with open(tmp, "w", encoding="utf-8") as f:
224
216
  json.dump(data, f, ensure_ascii=False)
225
- os.replace(tmp, self.persist_path) # 原子替换,防写一半损坏
217
+ os.replace(tmp, self.persist_path)
226
218
  if self._log_path and os.path.exists(self._log_path):
227
219
  os.remove(self._log_path)
228
220
  self._ops_since_compact = 0
@@ -254,8 +246,9 @@ class IdempotencyGuard:
254
246
  一份全量快照,失去"追加日志 O(1)"的意义。"""
255
247
  try:
256
248
  now = time.time()
257
- if os.path.exists(self.persist_path):
258
- with open(self.persist_path, encoding="utf-8") as f:
249
+ snapshot = self.persist_path
250
+ if snapshot is not None and os.path.exists(snapshot):
251
+ with open(snapshot, encoding="utf-8") as f:
259
252
  data = json.load(f)
260
253
  for k, v in data.items():
261
254
  created = v.get("created", now)
@@ -272,18 +265,18 @@ class IdempotencyGuard:
272
265
  try:
273
266
  ev = json.loads(line)
274
267
  except Exception:
275
- continue # 跳过损坏行(append 末行可能写一半)
268
+ continue
276
269
  self._apply_log_event(ev, now)
277
270
  replayed = True
278
271
  if replayed:
279
272
  self._compact()
280
273
  except Exception:
281
- pass # 加载失败 → 从空开始,不影响启动
274
+ pass
282
275
 
283
276
  def stats(self) -> dict:
284
277
  with self._lock:
285
278
  self._evict_expired()
286
279
  return {"size": len(self._store), "max_size": self.max_size,
287
280
  "ttl_seconds": self.ttl, "persisted": bool(self.persist_path),
288
- "persist_failures": self._persist_failures, # 【P2-3】
289
- "ops_since_compact": self._ops_since_compact} # 【P2-3】
281
+ "persist_failures": self._persist_failures,
282
+ "ops_since_compact": self._ops_since_compact}