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.
- package/index.js +1 -8
- package/package.json +1 -1
- package/python/.env.example +8 -0
- package/python/.github/workflows/ci.yml +47 -2
- package/python/.github/workflows/release.yml +5 -0
- package/python/CHANGELOG.md +100 -0
- package/python/CHANGELOG_CN.md +100 -0
- package/python/README.md +17 -2
- package/python/README_CN.md +38 -20
- package/python/bench/bm25_audit.py +1 -14
- package/python/bench/bm25_regression.py +0 -1
- package/python/bench/bm25_stress.py +0 -4
- package/python/bench/cd_audit.py +0 -23
- package/python/bench/chaos.py +6 -8
- package/python/bench/e2e_real_models.py +0 -1
- package/python/bench/llm_retry_test.py +2 -9
- package/python/bench/load_test.py +5 -8
- package/python/bench/vector_ivf_upgrade.py +1 -7
- package/python/docs/CONTENT_GUARD.md +189 -0
- package/python/docs/CONTENT_GUARD_CN.md +189 -0
- package/python/docs/RELEASE.md +78 -37
- package/python/docs/RUN_CN.md +2 -2
- package/python/evals/governance/cases.py +0 -37
- package/python/evals/governance/run_evals.py +5 -37
- package/python/examples/governance_middleware_demo.py +1 -6
- package/python/examples/langgraph_governance_demo.py +7 -11
- package/python/foxclaw/__init__.py +74 -152
- package/python/foxclaw/agents/base.py +82 -136
- package/python/foxclaw/agents/impl/_helpers.py +2 -10
- package/python/foxclaw/agents/impl/finance.py +4 -21
- package/python/foxclaw/agents/impl/office.py +7 -24
- package/python/foxclaw/agents/impl/supervisor.py +12 -79
- package/python/foxclaw/agents/mixins/_context.py +0 -23
- package/python/foxclaw/agents/mixins/augment.py +10 -16
- package/python/foxclaw/agents/mixins/llm.py +7 -6
- package/python/foxclaw/agents/mixins/memory.py +23 -3
- package/python/foxclaw/agents/mixins/preference.py +58 -8
- package/python/foxclaw/agents/mixins/prompt.py +59 -12
- package/python/foxclaw/agents/mixins/skillbox.py +6 -6
- package/python/foxclaw/agents/mixins/tool_exec.py +16 -42
- package/python/foxclaw/agents/rules/finance/RULES.md +131 -0
- package/python/foxclaw/api/__init__.py +18 -77
- package/python/foxclaw/api/_state.py +43 -0
- package/python/foxclaw/api/bootstrap.py +114 -0
- package/python/foxclaw/api/middleware.py +5 -28
- package/python/foxclaw/api/routes/ahs.py +60 -73
- package/python/foxclaw/api/routes/attachments.py +5 -6
- package/python/foxclaw/api/routes/browser.py +7 -11
- package/python/foxclaw/api/routes/domain.py +18 -26
- package/python/foxclaw/api/routes/knowledge.py +3 -21
- package/python/foxclaw/api/routes/llm_admin.py +21 -33
- package/python/foxclaw/api/routes/memory.py +50 -12
- package/python/foxclaw/api/routes/orchestration.py +3 -5
- package/python/foxclaw/api/routes/recovery.py +4 -3
- package/python/foxclaw/api/routes/review.py +2 -11
- package/python/foxclaw/api/routes/search.py +3 -7
- package/python/foxclaw/api/routes/system.py +33 -49
- package/python/foxclaw/api/routes/task.py +31 -49
- package/python/foxclaw/api/routes/ws.py +2 -6
- package/python/foxclaw/api/spine.py +1 -4
- package/python/foxclaw/behavior/__init__.py +20 -0
- package/python/foxclaw/behavior/behavior.yaml +62 -0
- package/python/foxclaw/behavior/spec.py +287 -0
- package/python/foxclaw/browser/action/action_executor.py +1 -5
- package/python/foxclaw/browser/action/action_types.py +0 -1
- package/python/foxclaw/browser/action/url_policy.py +1 -5
- package/python/foxclaw/browser/adapter/web_adapter.py +1 -1
- package/python/foxclaw/browser/driver/playwright_driver.py +20 -16
- package/python/foxclaw/browser/perception/dom_extractor.py +0 -1
- package/python/foxclaw/browser/perception/element_indexer.py +1 -2
- package/python/foxclaw/browser/service.py +7 -11
- package/python/foxclaw/capability/embedding_service.py +4 -6
- package/python/foxclaw/channels/__init__.py +9 -11
- package/python/foxclaw/channels/discord.py +4 -13
- package/python/foxclaw/channels/dispatcher.py +2 -27
- package/python/foxclaw/channels/feishu.py +10 -20
- package/python/foxclaw/channels/openclaw_bridge.py +4 -9
- package/python/foxclaw/channels/signing.py +9 -9
- package/python/foxclaw/channels/telegram.py +3 -4
- package/python/foxclaw/channels/weixin.py +7 -17
- package/python/foxclaw/cli.py +42 -13
- package/python/foxclaw/cognitive/reasoner.py +9 -10
- package/python/foxclaw/cognitive/state.py +18 -24
- package/python/foxclaw/core/audit_chain.py +1 -3
- package/python/foxclaw/core/audit_sink.py +3 -3
- package/python/foxclaw/core/compaction.py +8 -26
- package/python/foxclaw/core/config.py +2 -2
- package/python/foxclaw/core/confirm_nonce.py +6 -12
- package/python/foxclaw/core/content_guard.py +341 -0
- package/python/foxclaw/core/envfile.py +2 -2
- package/python/foxclaw/core/failure_policy.py +17 -26
- package/python/foxclaw/core/gateway.py +6 -10
- package/python/foxclaw/core/governance_audit.py +3 -7
- package/python/foxclaw/core/idempotency.py +15 -22
- package/python/foxclaw/core/injection_guard.py +10 -29
- package/python/foxclaw/core/memory_adapter.py +53 -60
- package/python/foxclaw/core/metrics.py +4 -5
- package/python/foxclaw/core/model_router.py +74 -150
- package/python/foxclaw/core/paths.py +1 -1
- package/python/foxclaw/core/recovery.py +21 -34
- package/python/foxclaw/core/scheduler.py +23 -26
- package/python/foxclaw/core/secrets.py +0 -3
- package/python/foxclaw/core/sensitive_write.py +283 -0
- package/python/foxclaw/core/sessions.py +4 -6
- package/python/foxclaw/core/settings.py +16 -32
- package/python/foxclaw/core/shared_write.py +11 -23
- package/python/foxclaw/core/steering_queue.py +12 -11
- package/python/foxclaw/core/system_bus.py +1 -1
- package/python/foxclaw/core/thought_chain.py +30 -32
- package/python/foxclaw/core/token_meter.py +9 -13
- package/python/foxclaw/core/tools.py +4 -12
- package/python/foxclaw/core/tracing.py +25 -40
- package/python/foxclaw/core/truth_pipeline.py +5 -9
- package/python/foxclaw/core/update_check.py +149 -0
- package/python/foxclaw/core/vec_recall_adapter.py +11 -37
- package/python/foxclaw/core/verify.py +6 -14
- package/python/foxclaw/core/web_search.py +4 -28
- package/python/foxclaw/finance/evidence.py +14 -18
- package/python/foxclaw/finance/fx.py +1 -3
- package/python/foxclaw/finance/graph.py +9 -18
- package/python/foxclaw/finance/models.py +20 -24
- package/python/foxclaw/finance/nlparse.py +12 -72
- package/python/foxclaw/finance/policy.py +8 -36
- package/python/foxclaw/finance/reasoner.py +3 -11
- package/python/foxclaw/finance/reasoning.py +13 -34
- package/python/foxclaw/finance/reconcile.py +5 -10
- package/python/foxclaw/finance/runtime.py +19 -46
- package/python/foxclaw/governance/middleware.py +13 -21
- package/python/foxclaw/harness/_base.py +2 -8
- package/python/foxclaw/harness/_policy.py +19 -38
- package/python/foxclaw/harness/drift.py +1 -13
- package/python/foxclaw/harness/event_bus.py +2 -5
- package/python/foxclaw/harness/harness.py +9 -97
- package/python/foxclaw/harness/loop_detection.py +11 -18
- package/python/foxclaw/itinerary/amap_client.py +2 -5
- package/python/foxclaw/itinerary/models.py +7 -7
- package/python/foxclaw/itinerary/reasoner.py +1 -6
- package/python/foxclaw/itinerary/reasoning.py +15 -21
- package/python/foxclaw/itinerary/runtime.py +8 -12
- package/python/foxclaw/kernel/capabilities.py +5 -6
- package/python/foxclaw/kernel/policy_gate.py +4 -10
- package/python/foxclaw/kernel/process_manager.py +17 -36
- package/python/foxclaw/kernel/scheduler.py +3 -9
- package/python/foxclaw/kernel/sentinel.py +13 -23
- package/python/foxclaw/kernel/sentinel_client.py +12 -15
- package/python/foxclaw/kernel/spine.py +9 -9
- package/python/foxclaw/knowledge/__init__.py +4 -20
- package/python/foxclaw/knowledge/truth_crystallizer.py +5 -8
- package/python/foxclaw/learning/knowledge_graph.py +4 -9
- package/python/foxclaw/learning/models.py +13 -13
- package/python/foxclaw/learning/reasoner.py +1 -4
- package/python/foxclaw/learning/runtime.py +19 -31
- package/python/foxclaw/learning/spaced_repetition.py +2 -7
- package/python/foxclaw/mcp_server.py +2 -3
- package/python/foxclaw/orchestration/__init__.py +16 -33
- package/python/foxclaw/pipeline/cognitive_pipeline.py +45 -97
- package/python/foxclaw/pipeline/stage_meta.py +21 -0
- package/python/foxclaw/pipeline/supervisor_stages.py +13 -42
- package/python/foxclaw/planning/llm_planner.py +3 -3
- package/python/foxclaw/planning/planner.py +6 -11
- package/python/foxclaw/search/agent_port.py +7 -11
- package/python/foxclaw/search/evidence.py +0 -4
- package/python/foxclaw/search/internal_search.py +3 -5
- package/python/foxclaw/search/models.py +11 -13
- package/python/foxclaw/search/planner.py +3 -3
- package/python/foxclaw/search/retriever.py +1 -1
- package/python/foxclaw/search/runtime.py +1 -8
- package/python/foxclaw/skills/__init__.py +46 -100
- package/python/foxclaw/skills/archive.py +111 -0
- package/python/foxclaw/skills/fetch.py +226 -0
- package/python/foxclaw/skills/hooks_spec.py +21 -25
- package/python/foxclaw/skills/install_cmd.py +216 -0
- package/python/foxclaw/skills/lifecycle.py +16 -42
- package/python/foxclaw/skills/markdown_skill.py +10 -9
- package/python/foxclaw/skills/mcp_client.py +5 -11
- package/python/foxclaw/skills/mcp_store.py +0 -3
- package/python/foxclaw/skills/permission.py +13 -48
- package/python/foxclaw/skills/pipeline.py +21 -53
- package/python/foxclaw/skills/policy.py +4 -10
- package/python/foxclaw/skills/review.py +3 -3
- package/python/frontend/deck/auth.js +0 -7
- package/python/frontend/deck/chat_model_picker.js +0 -54
- package/python/frontend/deck/domain.js +0 -20
- package/python/frontend/deck/main.js +0 -144
- package/python/frontend/deck/markdown.js +0 -4
- package/python/frontend/deck/settings.js +1 -18
- package/python/frontend/deck/state.js +0 -1
- package/python/frontend/deck/util.js +0 -2
- package/python/frontend/deck/visuals.js +0 -22
- package/python/frontend/planet/planet.js +18 -201
- package/python/frontend/v3/assets/index-JKfHlb-a.css +1 -0
- package/python/frontend/v3/assets/index-tN90dpUH.js +34 -0
- package/python/frontend/v3/index.html +3 -3
- package/python/frontend/widget_runtime.js +0 -43
- package/python/frontend/widgets.js +0 -57
- package/python/frontend_src/index.html +1 -1
- package/python/frontend_src/package-lock.json +2 -2
- package/python/frontend_src/package.json +1 -1
- package/python/frontend_src/src/App.vue +3 -7
- package/python/frontend_src/src/api.js +0 -7
- package/python/frontend_src/src/components/ChatDock.vue +27 -39
- package/python/frontend_src/src/components/DocViewer.vue +0 -9
- package/python/frontend_src/src/components/Inspector.vue +0 -6
- package/python/frontend_src/src/components/LeftRail.vue +60 -39
- package/python/frontend_src/src/components/MenuPopover.vue +9 -11
- package/python/frontend_src/src/components/MessageStream.vue +18 -30
- package/python/frontend_src/src/components/ModelPicker.vue +0 -5
- package/python/frontend_src/src/components/QuickIngest.vue +0 -6
- package/python/frontend_src/src/components/Stage.vue +71 -25
- package/python/frontend_src/src/components/ToolMenu.vue +0 -3
- package/python/frontend_src/src/components/TopBar.vue +38 -10
- package/python/frontend_src/src/components/panels/ActivityPanel.vue +0 -2
- package/python/frontend_src/src/components/panels/AgentsPanel.vue +2 -5
- package/python/frontend_src/src/components/panels/KnowledgePanel.vue +0 -16
- package/python/frontend_src/src/components/panels/MemoryPanel.vue +42 -3
- package/python/frontend_src/src/components/panels/PanelBox.vue +6 -7
- package/python/frontend_src/src/components/panels/ReviewPanel.vue +0 -16
- package/python/frontend_src/src/components/panels/SettingsPanel.vue +142 -38
- package/python/frontend_src/src/components/panels/TasksPanel.vue +0 -1
- package/python/frontend_src/src/components/panels/ToolsPanel.vue +125 -19
- package/python/frontend_src/src/lib/fmt.js +0 -4
- package/python/frontend_src/src/lib/i18n.js +53 -0
- package/python/frontend_src/src/lib/locales/en.js +173 -0
- package/python/frontend_src/src/lib/locales/zh.js +174 -0
- package/python/frontend_src/src/lib/md.js +0 -14
- package/python/frontend_src/src/main.js +2 -0
- package/python/frontend_src/src/stores/app.js +17 -32
- package/python/frontend_src/src/stores/chat.js +0 -34
- package/python/frontend_src/src/stores/model.js +0 -8
- package/python/frontend_src/src/stores/panels.js +16 -44
- package/python/frontend_src/src/stores/profile.js +31 -0
- package/python/frontend_src/src/stores/system.js +25 -0
- package/python/frontend_src/vite.config.js +0 -7
- package/python/pyproject.toml +17 -3
- package/python/requirements.txt +311 -0
- package/python/tests/conftest.py +0 -22
- package/python/tests/run_all.py +4 -66
- package/python/tests/test_agent_lifecycle.py +4 -38
- package/python/tests/test_agent_rules.py +81 -0
- package/python/tests/test_ahs_p2.py +4 -25
- package/python/tests/test_anthropic_tools.py +1 -15
- package/python/tests/test_api.py +19 -9
- package/python/tests/test_api_auth.py +1 -5
- package/python/tests/test_attachments.py +9 -25
- package/python/tests/test_audit_chain.py +2 -7
- package/python/tests/test_audit_sink.py +0 -6
- package/python/tests/test_behavior_spec.py +198 -0
- package/python/tests/test_browser.py +2 -6
- package/python/tests/test_browser_request_guard.py +0 -4
- package/python/tests/test_capabilities.py +0 -6
- package/python/tests/test_channel_signing.py +2 -9
- package/python/tests/test_channels.py +1 -11
- package/python/tests/test_config_layering.py +0 -4
- package/python/tests/test_confirm_nonce.py +1 -12
- package/python/tests/test_conflict_dismiss.py +143 -0
- package/python/tests/test_content_guard.py +177 -0
- package/python/tests/test_context_compaction_wiring.py +1 -7
- package/python/tests/test_dispatch.py +0 -6
- package/python/tests/test_domain_evals.py +11 -80
- package/python/tests/test_domain_import.py +0 -6
- package/python/tests/test_domain_reasoners.py +0 -14
- package/python/tests/test_e2e.py +0 -17
- package/python/tests/test_emb_cache.py +7 -17
- package/python/tests/test_embedding_singleton.py +1 -3
- package/python/tests/test_evals_smoke.py +0 -24
- package/python/tests/test_event_spine.py +10 -18
- package/python/tests/test_evidence_semantic.py +1 -14
- package/python/tests/test_evidence_sse.py +3 -6
- package/python/tests/test_file_ingest.py +0 -11
- package/python/tests/test_finance.py +0 -9
- package/python/tests/test_finance_ledger.py +5 -12
- package/python/tests/test_finance_persist.py +8 -12
- package/python/tests/test_gateway.py +3 -22
- package/python/tests/test_governance.py +5 -52
- package/python/tests/test_governance_audit.py +4 -39
- package/python/tests/test_governance_middleware.py +1 -7
- package/python/tests/test_hkc.py +6 -24
- package/python/tests/test_hkc_index_build_manager.py +0 -33
- package/python/tests/test_hkc_manifest_reuse.py +4 -27
- package/python/tests/test_hkc_search_event_sync.py +3 -54
- package/python/tests/test_import_path_guard.py +0 -6
- package/python/tests/test_injection_guard.py +18 -36
- package/python/tests/test_integration.py +5 -29
- package/python/tests/test_itinerary.py +0 -3
- package/python/tests/test_kee_semantic.py +0 -5
- package/python/tests/test_kernel.py +1 -11
- package/python/tests/test_learning.py +3 -12
- package/python/tests/test_llm_admin.py +4 -19
- package/python/tests/test_llm_planner.py +1 -23
- package/python/tests/test_markdown_skills.py +3 -14
- package/python/tests/test_markdown_skills_openclaw.py +4 -9
- package/python/tests/test_mcp_connectors.py +3 -15
- package/python/tests/test_mcp_guardian_discovery.py +2 -6
- package/python/tests/test_mcp_no_memory.py +3 -3
- package/python/tests/test_mcp_policy_gate.py +0 -1
- package/python/tests/test_mcp_server.py +6 -9
- package/python/tests/test_memory_recall.py +1 -4
- package/python/tests/test_metrics.py +0 -8
- package/python/tests/test_multi_chain.py +1 -13
- package/python/tests/test_multimodal.py +5 -18
- package/python/tests/test_negotiation.py +3 -13
- package/python/tests/test_orchestration.py +1 -11
- package/python/tests/test_orchestration_decoupled.py +0 -6
- package/python/tests/test_p3_p5_hardening.py +3 -17
- package/python/tests/test_parallel_dispatch.py +4 -12
- package/python/tests/test_pipeline.py +17 -58
- package/python/tests/test_plan_visible.py +4 -4
- package/python/tests/test_planner.py +0 -7
- package/python/tests/test_policy_gate.py +2 -3
- package/python/tests/test_profile.py +67 -0
- package/python/tests/test_readme_claims.py +163 -0
- package/python/tests/test_reasoner.py +2 -17
- package/python/tests/test_release_check.py +2 -13
- package/python/tests/test_resume_checkpoint.py +7 -15
- package/python/tests/test_routing.py +2 -12
- package/python/tests/test_scheduler.py +7 -10
- package/python/tests/test_search.py +1 -11
- package/python/tests/test_secrets.py +4 -10
- package/python/tests/test_semantic.py +1 -10
- package/python/tests/test_sensitive_write.py +181 -0
- package/python/tests/test_sessions.py +3 -4
- package/python/tests/test_settings.py +2 -18
- package/python/tests/test_shadow_learning.py +3 -43
- package/python/tests/test_shared_write.py +0 -7
- package/python/tests/test_skill_taint_gate.py +1 -10
- package/python/tests/test_skills.py +2 -18
- package/python/tests/test_skills_fetch.py +282 -0
- package/python/tests/test_stats_and_events.py +2 -5
- package/python/tests/test_streaming.py +2 -7
- package/python/tests/test_supervisor_pipeline.py +4 -5
- package/python/tests/test_supervisor_triage.py +4 -10
- package/python/tests/test_taint.py +2 -7
- package/python/tests/test_task_stream.py +1 -12
- package/python/tests/test_thinking_sse.py +0 -1
- package/python/tests/test_tool_center.py +1 -17
- package/python/tests/test_tool_parallel.py +2 -2
- package/python/tests/test_tool_params.py +1 -5
- package/python/tests/test_tool_use.py +0 -13
- package/python/tests/test_toplevel_concurrency.py +3 -4
- package/python/tests/test_tracing.py +7 -41
- package/python/tests/test_truth_crystallizer.py +4 -6
- package/python/tests/test_truth_flow.py +2 -7
- package/python/tests/test_update_check.py +95 -0
- package/python/tests/test_verify_evidence.py +8 -21
- package/python/tests/test_version_sync.py +35 -6
- package/python/tests/test_web_trigger.py +0 -17
- package/python/tools/build_content_lexicon.py +218 -0
- package/python/tools/bundle_web.py +98 -0
- package/python/tools/check_faiss_type.py +0 -1
- package/python/tools/check_log.ps1 +0 -8
- package/python/tools/mypy_baseline.txt +12 -4
- package/python/tools/mypy_ratchet.py +52 -6
- package/python/tools/pack.py +0 -5
- package/python/tools/recheck_conflicts.py +156 -0
- package/python/tools/release_check.py +24 -15
- package/python/tools/restart_foxclaw.ps1 +5 -8
- package/python/tools/shared_write_audit.py +1 -15
- package/python/tools/size_check.py +1 -1
- package/python/tools/strip_comments.py +228 -0
- package/python/vendor/hkc/hkc_api/routes/ability.py +31 -0
- package/python/vendor/hkc/hkc_api/schemas.py +11 -0
- package/python/vendor/hkc/hkc_kee/kee.py +124 -3
- package/python/vendor/hkc/hkc_sdk/base.py +22 -0
- package/python/vendor/hkc/hkc_sdk/direct_client.py +10 -0
- package/python/vendor/hkc/hkc_sdk/http_client.py +19 -0
- package/python/foxclaw/api/app.py +0 -123
- package/python/frontend/v3/assets/index-BD7VPbMV.js +0 -33
- 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
|
|
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"
|
|
23
|
-
NOT_CONFIGURED = "not_configured"
|
|
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 = {}
|
|
28
|
-
self._quota: dict = {}
|
|
29
|
-
self._seen: dict = {}
|
|
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
|
-
|
|
50
|
-
|
|
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()
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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,
|
|
289
|
-
"ops_since_compact": self._ops_since_compact}
|
|
281
|
+
"persist_failures": self._persist_failures,
|
|
282
|
+
"ops_since_compact": self._ops_since_compact}
|