secagentx 3.0.0__py3-none-any.whl

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 (182) hide show
  1. backend/__init__.py +1 -0
  2. backend/__main__.py +3 -0
  3. backend/_assets/config.yaml +195 -0
  4. backend/_assets/frontend/dist/assets/AgentsView-DFqRplI8.css +1 -0
  5. backend/_assets/frontend/dist/assets/AgentsView-DJTpfQ87.js +1 -0
  6. backend/_assets/frontend/dist/assets/AttackChainView-BVfI-SC6.js +1 -0
  7. backend/_assets/frontend/dist/assets/ChatView-Bg4nIlQ0.css +1 -0
  8. backend/_assets/frontend/dist/assets/ChatView-CRndtmEC.js +38 -0
  9. backend/_assets/frontend/dist/assets/DashboardView-BXs4AWv8.js +1 -0
  10. backend/_assets/frontend/dist/assets/DashboardView-WnHWiqBW.css +1 -0
  11. backend/_assets/frontend/dist/assets/EventDetailView-DQk6Qp7-.js +1 -0
  12. backend/_assets/frontend/dist/assets/EventDetailView-Dj-a-zWr.css +1 -0
  13. backend/_assets/frontend/dist/assets/EventsView-8xh8uKO0.js +1 -0
  14. backend/_assets/frontend/dist/assets/EventsView-BHfFn66K.css +1 -0
  15. backend/_assets/frontend/dist/assets/KnowledgeView-C2N3v9bF.css +1 -0
  16. backend/_assets/frontend/dist/assets/KnowledgeView-D54ODoC9.js +11 -0
  17. backend/_assets/frontend/dist/assets/LoginView-CjvFmo7C.js +1 -0
  18. backend/_assets/frontend/dist/assets/LoginView-D14FzcsP.css +1 -0
  19. backend/_assets/frontend/dist/assets/SettingsView-JOJuoABi.js +1 -0
  20. backend/_assets/frontend/dist/assets/UsersView-DhMXZhir.js +1 -0
  21. backend/_assets/frontend/dist/assets/_plugin-vue_export-helper-BDNMzG2s.js +1 -0
  22. backend/_assets/frontend/dist/assets/index-D9Su3fYY.css +1 -0
  23. backend/_assets/frontend/dist/assets/index-DBEjbyJR.js +4 -0
  24. backend/_assets/frontend/dist/assets/rolldown-runtime-DK3Fl9T5.js +1 -0
  25. backend/_assets/frontend/dist/assets/vendor-charts-Dh1nChzO.js +39 -0
  26. backend/_assets/frontend/dist/assets/vendor-icons-BTNgi7vU.js +1 -0
  27. backend/_assets/frontend/dist/assets/vendor-ui-Cdd65KTN.js +7367 -0
  28. backend/_assets/frontend/dist/assets/vendor-vue-CXKcZAmz.js +1 -0
  29. backend/_assets/frontend/dist/index.html +51 -0
  30. backend/_assets/frontend/index.html +46 -0
  31. backend/_assets/frontend/package-lock.json +2983 -0
  32. backend/_assets/frontend/package.json +37 -0
  33. backend/_assets/frontend/src/App.vue +231 -0
  34. backend/_assets/frontend/src/main.js +12 -0
  35. backend/_assets/frontend/src/router/index.js +117 -0
  36. backend/_assets/frontend/src/stores/chat.js +671 -0
  37. backend/_assets/frontend/src/styles/tokens.css +282 -0
  38. backend/_assets/frontend/src/utils/http.js +191 -0
  39. backend/_assets/frontend/src/views/AgentsView.vue +227 -0
  40. backend/_assets/frontend/src/views/AttackChainView.vue +210 -0
  41. backend/_assets/frontend/src/views/ChatView.vue +1107 -0
  42. backend/_assets/frontend/src/views/DashboardView.vue +514 -0
  43. backend/_assets/frontend/src/views/EventDetailView.vue +387 -0
  44. backend/_assets/frontend/src/views/EventsView.vue +253 -0
  45. backend/_assets/frontend/src/views/KnowledgeView.vue +883 -0
  46. backend/_assets/frontend/src/views/LoginView.vue +314 -0
  47. backend/_assets/frontend/src/views/SettingsView.vue +226 -0
  48. backend/_assets/frontend/src/views/UsersView.vue +235 -0
  49. backend/_assets/frontend/vite.config.js +55 -0
  50. backend/_assets/knowledge_data/compliance/regulations.json +383 -0
  51. backend/_assets/knowledge_data/cve/vulnerabilities.json +70720 -0
  52. backend/_assets/knowledge_data/mitre_attack/techniques.json +14097 -0
  53. backend/_assets/knowledge_data/remediation/remediation.json +574 -0
  54. backend/_assets/knowledge_data/threat_intel/actors.json +1602 -0
  55. backend/_assets/knowledge_data/threat_intel/malware.json +971 -0
  56. backend/agents/__init__.py +1 -0
  57. backend/agents/alert_filter/__init__.py +1 -0
  58. backend/agents/alert_filter/alert_filter_agent.py +88 -0
  59. backend/agents/analyst.py +331 -0
  60. backend/agents/base.py +553 -0
  61. backend/agents/classifier_agent.py +134 -0
  62. backend/agents/cot_engine.py +578 -0
  63. backend/agents/intel.py +46 -0
  64. backend/agents/knowledge/__init__.py +2 -0
  65. backend/agents/knowledge/agentic_rag.py +928 -0
  66. backend/agents/knowledge/knowledge_agent.py +47 -0
  67. backend/agents/registry.py +148 -0
  68. backend/agents/responder.py +51 -0
  69. backend/agents/summary_agent.py +219 -0
  70. backend/auto_ingestor.py +948 -0
  71. backend/auto_patrol.py +452 -0
  72. backend/cli_app.py +523 -0
  73. backend/config/__init__.py +20 -0
  74. backend/config/provider_profiles.py +416 -0
  75. backend/config/runtime_settings.py +84 -0
  76. backend/decision_fusion/__init__.py +32 -0
  77. backend/decision_fusion/base.py +127 -0
  78. backend/decision_fusion/dempster_shafer.py +419 -0
  79. backend/decision_fusion/weighted_average.py +141 -0
  80. backend/email_ingestor.py +588 -0
  81. backend/errors.py +243 -0
  82. backend/escalation.py +453 -0
  83. backend/evaluation/__init__.py +7 -0
  84. backend/evaluation/evaluator.py +197 -0
  85. backend/evaluation/metrics.py +210 -0
  86. backend/federation/__init__.py +22 -0
  87. backend/federation/core.py +743 -0
  88. backend/interface/__init__.py +1 -0
  89. backend/interface/api_server.py +1830 -0
  90. backend/interface/cli.py +671 -0
  91. backend/knowledge/__init__.py +11 -0
  92. backend/knowledge/compliance.py +101 -0
  93. backend/knowledge/cve_db.py +100 -0
  94. backend/knowledge/mitre_attack.py +457 -0
  95. backend/knowledge/remediation.py +84 -0
  96. backend/knowledge/threat_intel_kb.py +246 -0
  97. backend/llm/__init__.py +1 -0
  98. backend/llm/anthropic_compatible.py +191 -0
  99. backend/llm/base.py +81 -0
  100. backend/llm/deepseek.py +221 -0
  101. backend/llm/fallback.py +268 -0
  102. backend/llm/mock.py +204 -0
  103. backend/llm/openai_compatible.py +51 -0
  104. backend/llm/provider.py +217 -0
  105. backend/llm/qwen.py +218 -0
  106. backend/main.py +421 -0
  107. backend/memory/__init__.py +1 -0
  108. backend/memory/episodic_memory.py +132 -0
  109. backend/memory/manager.py +204 -0
  110. backend/memory/semantic_memory.py +156 -0
  111. backend/memory/session_memory.py +102 -0
  112. backend/ml_model/__init__.py +23 -0
  113. backend/ml_model/pipeline.py +191 -0
  114. backend/ml_model/trainer.py +627 -0
  115. backend/models/__init__.py +1 -0
  116. backend/models/event.py +62 -0
  117. backend/models/message.py +58 -0
  118. backend/models/output.py +1216 -0
  119. backend/monitoring/metrics.py +184 -0
  120. backend/orchestrator/__init__.py +17 -0
  121. backend/orchestrator/agent_router.py +145 -0
  122. backend/orchestrator/core.py +159 -0
  123. backend/orchestrator/dynamic_weights.py +287 -0
  124. backend/orchestrator/react_loop.py +1895 -0
  125. backend/orchestrator/risk_model.py +494 -0
  126. backend/orchestrator/risk_scorer.py +356 -0
  127. backend/reasoner/__init__.py +19 -0
  128. backend/reasoner/reasoner.py +316 -0
  129. backend/runtime_assets.py +45 -0
  130. backend/security/__init__.py +24 -0
  131. backend/security/adversarial/__init__.py +13 -0
  132. backend/security/adversarial/guard.py +121 -0
  133. backend/security/adversarial/redteam.py +121 -0
  134. backend/security/adversarial/scanner.py +208 -0
  135. backend/security/audit.py +118 -0
  136. backend/security/auth.py +283 -0
  137. backend/security/circuit_breaker.py +212 -0
  138. backend/security/dispatch.py +18 -0
  139. backend/security/ratelimit.py +237 -0
  140. backend/security/rbac.py +431 -0
  141. backend/security/refresh_sessions.py +193 -0
  142. backend/security/sanitizer.py +32 -0
  143. backend/storage/__init__.py +1 -0
  144. backend/storage/chroma_store.py +79 -0
  145. backend/storage/database.py +296 -0
  146. backend/storage/migrations/env.py +65 -0
  147. backend/storage/migrations/versions/0001_initial_schema.py +101 -0
  148. backend/storage/migrations/versions/0002_add_agent_logs_experiences.py +53 -0
  149. backend/storage/migrations/versions/0003_add_conversation_owner.py +30 -0
  150. backend/storage/models.py +194 -0
  151. backend/storage/repositories/__init__.py +1 -0
  152. backend/storage/repositories/asset_repo.py +86 -0
  153. backend/storage/repositories/conversation_repo.py +216 -0
  154. backend/storage/repositories/event_repo.py +76 -0
  155. backend/storage/repositories/trajectory_repo.py +324 -0
  156. backend/storage/retention.py +117 -0
  157. backend/tools/__init__.py +16 -0
  158. backend/tools/alert_filter.py +208 -0
  159. backend/tools/aliyun_firewall.py +237 -0
  160. backend/tools/aws_firewall.py +369 -0
  161. backend/tools/base.py +445 -0
  162. backend/tools/calling.py +207 -0
  163. backend/tools/cve_search.py +77 -0
  164. backend/tools/execution_engine.py +202 -0
  165. backend/tools/firewall.py +387 -0
  166. backend/tools/geoip.py +168 -0
  167. backend/tools/log_analyzer.py +185 -0
  168. backend/tools/ml_detector.py +226 -0
  169. backend/tools/registry.py +38 -0
  170. backend/tools/tencent_firewall.py +300 -0
  171. backend/tools/threat_intel.py +732 -0
  172. backend/tools/trust_checker.py +76 -0
  173. backend/utils/__init__.py +5 -0
  174. backend/utils/config_loader.py +164 -0
  175. backend/utils/env.py +75 -0
  176. backend/utils/logger.py +150 -0
  177. backend/utils/text.py +32 -0
  178. secagentx-3.0.0.dist-info/METADATA +325 -0
  179. secagentx-3.0.0.dist-info/RECORD +182 -0
  180. secagentx-3.0.0.dist-info/WHEEL +5 -0
  181. secagentx-3.0.0.dist-info/entry_points.txt +2 -0
  182. secagentx-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,416 @@
1
+ """模型 Provider 档案与系统凭据存储。
2
+
3
+ 配置文件只保存路由元数据和凭据引用;API Key 由系统 Keyring 或 Windows
4
+ DPAPI 保存。也可通过环境变量提供凭据,适合 Docker、CI 和无桌面的服务器。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import tempfile
13
+ import base64
14
+ import ctypes
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ logger = logging.getLogger("secagentx.provider_profiles")
20
+
21
+ KEYRING_SERVICE = "SecAgentX"
22
+ CONFIG_VERSION = 1
23
+
24
+
25
+ PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
26
+ "deepseek": {
27
+ "label": "DeepSeek",
28
+ "protocol": "openai_compatible",
29
+ "api_base": "https://api.deepseek.com/v1",
30
+ "model": "deepseek-chat",
31
+ "env_key": "DEEPSEEK_API_KEY",
32
+ },
33
+ "qwen": {
34
+ "label": "通义千问 (DashScope)",
35
+ "protocol": "openai_compatible",
36
+ "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1",
37
+ "model": "qwen-max",
38
+ "env_key": "QWEN_API_KEY",
39
+ },
40
+ "openai": {
41
+ "label": "OpenAI",
42
+ "protocol": "openai_compatible",
43
+ "api_base": "https://api.openai.com/v1",
44
+ "model": "gpt-4o-mini",
45
+ "env_key": "OPENAI_API_KEY",
46
+ },
47
+ "anthropic": {
48
+ "label": "Anthropic",
49
+ "protocol": "anthropic_compatible",
50
+ "api_base": "https://api.anthropic.com/v1",
51
+ "model": "claude-sonnet-4-5",
52
+ "env_key": "ANTHROPIC_API_KEY",
53
+ },
54
+ "azure_openai": {
55
+ "label": "Azure OpenAI",
56
+ "protocol": "openai_compatible",
57
+ "api_base": "",
58
+ "model": "",
59
+ "env_key": "AZURE_OPENAI_API_KEY",
60
+ "auth_style": "api-key",
61
+ "api_version": "2024-10-21",
62
+ },
63
+ "gemini": {
64
+ "label": "Google Gemini (OpenAI 兼容接口)",
65
+ "protocol": "openai_compatible",
66
+ "api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
67
+ "model": "gemini-2.5-flash",
68
+ "env_key": "GEMINI_API_KEY",
69
+ },
70
+ "openrouter": {
71
+ "label": "OpenRouter",
72
+ "protocol": "openai_compatible",
73
+ "api_base": "https://openrouter.ai/api/v1",
74
+ "model": "openai/gpt-4o-mini",
75
+ "env_key": "OPENROUTER_API_KEY",
76
+ },
77
+ "xai": {
78
+ "label": "xAI (Grok)",
79
+ "protocol": "openai_compatible",
80
+ "api_base": "https://api.x.ai/v1",
81
+ "model": "grok-3-mini",
82
+ "env_key": "XAI_API_KEY",
83
+ },
84
+ "kimi": {
85
+ "label": "Moonshot / Kimi",
86
+ "protocol": "openai_compatible",
87
+ "api_base": "https://api.moonshot.cn/v1",
88
+ "model": "moonshot-v1-8k",
89
+ "env_key": "MOONSHOT_API_KEY",
90
+ },
91
+ "ollama": {
92
+ "label": "Ollama(本地)",
93
+ "protocol": "openai_compatible",
94
+ "api_base": "http://127.0.0.1:11434/v1",
95
+ "model": "llama3.2",
96
+ "env_key": "",
97
+ "requires_api_key": False,
98
+ },
99
+ "lmstudio": {
100
+ "label": "LM Studio(本地)",
101
+ "protocol": "openai_compatible",
102
+ "api_base": "http://127.0.0.1:1234/v1",
103
+ "model": "local-model",
104
+ "env_key": "",
105
+ "requires_api_key": False,
106
+ },
107
+ "custom_openai": {
108
+ "label": "自定义 OpenAI 兼容接口",
109
+ "protocol": "openai_compatible",
110
+ "api_base": "",
111
+ "model": "",
112
+ "env_key": "SECAGENTX_LLM_API_KEY",
113
+ },
114
+ "custom_anthropic": {
115
+ "label": "自定义 Anthropic 兼容接口",
116
+ "protocol": "anthropic_compatible",
117
+ "api_base": "",
118
+ "model": "",
119
+ "env_key": "SECAGENTX_LLM_API_KEY",
120
+ },
121
+ "mock": {
122
+ "label": "离线 Mock(仅验证流程)",
123
+ "protocol": "mock",
124
+ "api_base": "mock://local",
125
+ "model": "mock-llm",
126
+ "env_key": "",
127
+ "requires_api_key": False,
128
+ },
129
+ }
130
+
131
+
132
+ def get_secagentx_home() -> Path:
133
+ override = os.getenv("SECAGENTX_HOME", "").strip()
134
+ if override:
135
+ return Path(override).expanduser().resolve()
136
+ if os.name == "nt" and os.getenv("APPDATA"):
137
+ return Path(os.environ["APPDATA"]) / "SecAgentX"
138
+ config_home = os.getenv("XDG_CONFIG_HOME", "").strip()
139
+ return (Path(config_home) if config_home else Path.home() / ".config") / "secagentx"
140
+
141
+
142
+ @dataclass
143
+ class ProviderProfile:
144
+ profile_id: str
145
+ provider_id: str
146
+ label: str
147
+ protocol: str
148
+ api_base: str
149
+ model: str
150
+ credential_type: str = "keyring"
151
+ credential_ref: str = ""
152
+ env_key: str = ""
153
+ auth_style: str = "bearer"
154
+ api_version: str = ""
155
+ requires_api_key: bool = True
156
+ timeout_seconds: float = 60.0
157
+ max_tokens: int = 4096
158
+
159
+ @classmethod
160
+ def from_dict(cls, value: dict[str, Any]) -> "ProviderProfile":
161
+ allowed = cls.__dataclass_fields__.keys()
162
+ return cls(**{k: value[k] for k in allowed if k in value})
163
+
164
+ def runtime_config(self, api_key: str = "") -> dict[str, Any]:
165
+ return {
166
+ "api_base": self.api_base,
167
+ "api_key": api_key,
168
+ "model": self.model,
169
+ "timeout_seconds": self.timeout_seconds,
170
+ "max_tokens": self.max_tokens,
171
+ "auth_style": self.auth_style,
172
+ "api_version": self.api_version,
173
+ "allow_no_key": not self.requires_api_key,
174
+ }
175
+
176
+
177
+ class CredentialStoreError(RuntimeError):
178
+ pass
179
+
180
+
181
+ class CredentialStore:
182
+ """系统凭据包装;Windows 可在无第三方包时使用用户级 DPAPI。"""
183
+
184
+ def __init__(self, service: str = KEYRING_SERVICE, dpapi_path: Optional[Path] = None):
185
+ self.service = service
186
+ self.dpapi_path = dpapi_path or (get_secagentx_home() / "credentials.dpapi")
187
+
188
+ @staticmethod
189
+ def _keyring_available() -> bool:
190
+ try:
191
+ import keyring
192
+ backend = keyring.get_keyring()
193
+ return backend is not None and backend.priority > 0
194
+ except Exception:
195
+ return False
196
+
197
+ @staticmethod
198
+ def available() -> bool:
199
+ return CredentialStore._keyring_available() or os.name == "nt"
200
+
201
+ @staticmethod
202
+ def _dpapi_transform(payload: bytes, protect: bool) -> bytes:
203
+ if os.name != "nt":
204
+ raise CredentialStoreError("当前系统无可用的本地安全凭据存储")
205
+
206
+ class DataBlob(ctypes.Structure):
207
+ _fields_ = [("cbData", ctypes.c_uint32), ("pbData", ctypes.POINTER(ctypes.c_ubyte))]
208
+
209
+ buffer = ctypes.create_string_buffer(payload)
210
+ input_blob = DataBlob(len(payload), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte)))
211
+ output_blob = DataBlob()
212
+ crypt32 = ctypes.windll.crypt32
213
+ function = crypt32.CryptProtectData if protect else crypt32.CryptUnprotectData
214
+ if protect:
215
+ success = function(
216
+ ctypes.byref(input_blob), None, None, None, None, 0,
217
+ ctypes.byref(output_blob),
218
+ )
219
+ else:
220
+ success = function(
221
+ ctypes.byref(input_blob), None, None, None, None, 0,
222
+ ctypes.byref(output_blob),
223
+ )
224
+ if not success:
225
+ raise CredentialStoreError("Windows DPAPI 操作失败")
226
+ try:
227
+ return ctypes.string_at(output_blob.pbData, output_blob.cbData)
228
+ finally:
229
+ ctypes.windll.kernel32.LocalFree(output_blob.pbData)
230
+
231
+ def _load_dpapi(self) -> dict[str, str]:
232
+ if not self.dpapi_path.exists():
233
+ return {}
234
+ try:
235
+ encrypted = base64.b64decode(self.dpapi_path.read_bytes(), validate=True)
236
+ raw = self._dpapi_transform(encrypted, protect=False)
237
+ value = json.loads(raw.decode("utf-8"))
238
+ return value if isinstance(value, dict) else {}
239
+ except Exception as exc:
240
+ raise CredentialStoreError("无法读取 Windows DPAPI 凭据库") from exc
241
+
242
+ def _save_dpapi(self, values: dict[str, str]) -> None:
243
+ self.dpapi_path.parent.mkdir(parents=True, exist_ok=True)
244
+ raw = json.dumps(values, ensure_ascii=False).encode("utf-8")
245
+ encoded = base64.b64encode(self._dpapi_transform(raw, protect=True))
246
+ fd, tmp_name = tempfile.mkstemp(prefix="credentials-", suffix=".tmp", dir=self.dpapi_path.parent)
247
+ try:
248
+ with os.fdopen(fd, "wb") as handle:
249
+ handle.write(encoded)
250
+ handle.flush()
251
+ os.fsync(handle.fileno())
252
+ os.chmod(tmp_name, 0o600)
253
+ os.replace(tmp_name, self.dpapi_path)
254
+ finally:
255
+ if os.path.exists(tmp_name):
256
+ os.unlink(tmp_name)
257
+
258
+ def set(self, reference: str, secret: str) -> None:
259
+ if not secret:
260
+ raise CredentialStoreError("拒绝保存空 API Key")
261
+ if self._keyring_available():
262
+ import keyring
263
+ try:
264
+ keyring.set_password(self.service, reference, secret)
265
+ # 安装 Keyring 后迁移并清除可能遗留的 DPAPI 副本。
266
+ if os.name == "nt" and self.dpapi_path.exists():
267
+ values = self._load_dpapi()
268
+ if values.pop(f"{self.service}:{reference}", None) is not None:
269
+ self._save_dpapi(values)
270
+ return
271
+ except Exception:
272
+ if os.name != "nt":
273
+ raise CredentialStoreError("系统 Keyring 写入失败,请改用环境变量")
274
+ values = self._load_dpapi()
275
+ values[f"{self.service}:{reference}"] = secret
276
+ self._save_dpapi(values)
277
+
278
+ def get(self, reference: str) -> str:
279
+ if not reference:
280
+ return ""
281
+ if self._keyring_available():
282
+ import keyring
283
+ try:
284
+ secret = keyring.get_password(self.service, reference) or ""
285
+ if secret:
286
+ return secret
287
+ except Exception:
288
+ pass
289
+ if os.name == "nt":
290
+ try:
291
+ return self._load_dpapi().get(f"{self.service}:{reference}", "")
292
+ except CredentialStoreError:
293
+ return ""
294
+ return ""
295
+
296
+ def delete(self, reference: str) -> None:
297
+ if not reference:
298
+ return
299
+ if self._keyring_available():
300
+ import keyring
301
+ try:
302
+ keyring.delete_password(self.service, reference)
303
+ except Exception:
304
+ pass
305
+ if os.name == "nt":
306
+ values = self._load_dpapi()
307
+ values.pop(f"{self.service}:{reference}", None)
308
+ self._save_dpapi(values)
309
+
310
+
311
+ class ProviderProfileStore:
312
+ def __init__(self, path: Optional[Path] = None, credentials: Optional[CredentialStore] = None):
313
+ self.path = path or (get_secagentx_home() / "providers.json")
314
+ self.credentials = credentials or CredentialStore()
315
+
316
+ def load(self) -> dict[str, Any]:
317
+ if not self.path.exists():
318
+ return {"version": CONFIG_VERSION, "active_profile": "", "profiles": {}}
319
+ try:
320
+ data = json.loads(self.path.read_text(encoding="utf-8"))
321
+ if not isinstance(data.get("profiles"), dict):
322
+ raise ValueError("profiles 必须为对象")
323
+ return data
324
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
325
+ raise ValueError(f"Provider 配置损坏: {self.path}: {exc}") from exc
326
+
327
+ def save(self, data: dict[str, Any]) -> None:
328
+ self.path.parent.mkdir(parents=True, exist_ok=True)
329
+ payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
330
+ fd, tmp_name = tempfile.mkstemp(prefix="providers-", suffix=".tmp", dir=self.path.parent)
331
+ try:
332
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
333
+ handle.write(payload)
334
+ handle.flush()
335
+ os.fsync(handle.fileno())
336
+ os.chmod(tmp_name, 0o600)
337
+ os.replace(tmp_name, self.path)
338
+ finally:
339
+ if os.path.exists(tmp_name):
340
+ os.unlink(tmp_name)
341
+
342
+ def active(self) -> Optional[ProviderProfile]:
343
+ data = self.load()
344
+ profile_id = data.get("active_profile", "")
345
+ raw = data.get("profiles", {}).get(profile_id)
346
+ return ProviderProfile.from_dict(raw) if raw else None
347
+
348
+ def list_profiles(self) -> list[ProviderProfile]:
349
+ return [ProviderProfile.from_dict(v) for v in self.load().get("profiles", {}).values()]
350
+
351
+ def get_secret(self, profile: ProviderProfile) -> str:
352
+ if not profile.requires_api_key:
353
+ return "local-no-key"
354
+ if profile.env_key and os.getenv(profile.env_key):
355
+ return os.environ[profile.env_key]
356
+ if profile.credential_type == "env":
357
+ return os.getenv(profile.credential_ref or profile.env_key, "")
358
+ return self.credentials.get(profile.credential_ref)
359
+
360
+ def put(self, profile: ProviderProfile, api_key: str = "", make_active: bool = True) -> None:
361
+ if profile.requires_api_key and api_key:
362
+ profile.credential_type = "keyring"
363
+ profile.credential_ref = profile.credential_ref or f"provider:{profile.profile_id}"
364
+ self.credentials.set(profile.credential_ref, api_key)
365
+ elif profile.requires_api_key and not (
366
+ (profile.env_key and os.getenv(profile.env_key)) or self.get_secret(profile)
367
+ ):
368
+ raise CredentialStoreError("未提供可用 API Key")
369
+
370
+ data = self.load()
371
+ data["version"] = CONFIG_VERSION
372
+ data.setdefault("profiles", {})[profile.profile_id] = asdict(profile)
373
+ if make_active:
374
+ data["active_profile"] = profile.profile_id
375
+ self.save(data)
376
+
377
+ def set_active(self, profile_id: str) -> ProviderProfile:
378
+ data = self.load()
379
+ raw = data.get("profiles", {}).get(profile_id)
380
+ if not raw:
381
+ raise KeyError(f"Provider 档案不存在: {profile_id}")
382
+ data["active_profile"] = profile_id
383
+ self.save(data)
384
+ return ProviderProfile.from_dict(raw)
385
+
386
+
387
+ def activate_profile(profile: ProviderProfile, api_key: str) -> None:
388
+ os.environ["SECAGENTX_ACTIVE_PROVIDER"] = profile.protocol
389
+ os.environ["LLM_PROVIDER"] = profile.protocol
390
+ os.environ["SECAGENTX_LLM_PROFILE"] = profile.profile_id
391
+ os.environ["SECAGENTX_LLM_PROVIDER_ID"] = profile.provider_id
392
+ os.environ["SECAGENTX_LLM_API_BASE"] = profile.api_base
393
+ os.environ["SECAGENTX_LLM_MODEL"] = profile.model
394
+ os.environ["SECAGENTX_LLM_AUTH_STYLE"] = profile.auth_style
395
+ os.environ["SECAGENTX_LLM_API_VERSION"] = profile.api_version
396
+ os.environ["SECAGENTX_LLM_ALLOW_NO_KEY"] = "true" if not profile.requires_api_key else "false"
397
+ if api_key:
398
+ os.environ["SECAGENTX_LLM_API_KEY"] = api_key
399
+
400
+
401
+ def activate_stored_profile(store: Optional[ProviderProfileStore] = None) -> Optional[ProviderProfile]:
402
+ """加载活动档案到当前进程;无配置时保持现有环境变量兼容行为。"""
403
+ store = store or ProviderProfileStore()
404
+ try:
405
+ profile = store.active()
406
+ if not profile:
407
+ return None
408
+ secret = store.get_secret(profile)
409
+ if profile.requires_api_key and not secret:
410
+ logger.warning("活动 Provider %s 的凭据不可用", profile.profile_id)
411
+ return profile
412
+ activate_profile(profile, secret)
413
+ return profile
414
+ except ValueError as exc:
415
+ logger.error("%s", exc)
416
+ return None
@@ -0,0 +1,84 @@
1
+ """Web 运行设置。
2
+
3
+ 管理员密码仅保存 bcrypt 哈希;JWT secret 保存于系统 Keyring。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import secrets
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ from .provider_profiles import CredentialStore, CredentialStoreError, get_secagentx_home
16
+
17
+
18
+ class RuntimeSettingsStore:
19
+ def __init__(self, path: Optional[Path] = None, credentials: Optional[CredentialStore] = None):
20
+ self.path = path or (get_secagentx_home() / "settings.json")
21
+ self.credentials = credentials or CredentialStore()
22
+
23
+ def load(self) -> dict[str, Any]:
24
+ if not self.path.exists():
25
+ return {"version": 1, "web": {"host": "127.0.0.1", "port": 8000}}
26
+ try:
27
+ return json.loads(self.path.read_text(encoding="utf-8"))
28
+ except Exception as exc:
29
+ raise ValueError(f"运行配置损坏: {self.path}: {exc}") from exc
30
+
31
+ def save(self, data: dict[str, Any]) -> None:
32
+ self.path.parent.mkdir(parents=True, exist_ok=True)
33
+ fd, tmp_name = tempfile.mkstemp(prefix="settings-", suffix=".tmp", dir=self.path.parent)
34
+ try:
35
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
36
+ json.dump(data, handle, ensure_ascii=False, indent=2)
37
+ handle.write("\n")
38
+ handle.flush()
39
+ os.fsync(handle.fileno())
40
+ os.chmod(tmp_name, 0o600)
41
+ os.replace(tmp_name, self.path)
42
+ finally:
43
+ if os.path.exists(tmp_name):
44
+ os.unlink(tmp_name)
45
+
46
+ def configure_web_credentials(self, password: str) -> None:
47
+ if len(password) < 12:
48
+ raise ValueError("管理员密码至少需要 12 个字符")
49
+ import bcrypt
50
+
51
+ data = self.load()
52
+ data["admin_password_hash"] = bcrypt.hashpw(
53
+ password.encode("utf-8"), bcrypt.gensalt(rounds=12)
54
+ ).decode("utf-8")
55
+ jwt_ref = data.get("jwt_secret_ref") or "runtime:jwt-secret"
56
+ self.credentials.set(jwt_ref, secrets.token_urlsafe(48))
57
+ data["jwt_secret_ref"] = jwt_ref
58
+ self.save(data)
59
+
60
+ def activate(self) -> dict[str, Any]:
61
+ data = self.load()
62
+ password_hash = data.get("admin_password_hash", "")
63
+ if password_hash and not os.getenv("SECAGENTX_PASSWORD_HASH") and not os.getenv("SECAGENTX_PASSWORD"):
64
+ os.environ["SECAGENTX_PASSWORD_HASH"] = password_hash
65
+ jwt_ref = data.get("jwt_secret_ref", "")
66
+ if jwt_ref and not os.getenv("SECAGENTX_JWT_SECRET"):
67
+ jwt_secret = self.credentials.get(jwt_ref)
68
+ if jwt_secret:
69
+ os.environ["SECAGENTX_JWT_SECRET"] = jwt_secret
70
+ return data
71
+
72
+ def web_ready(self) -> bool:
73
+ self.activate()
74
+ return bool(
75
+ (os.getenv("SECAGENTX_PASSWORD_HASH") or os.getenv("SECAGENTX_PASSWORD"))
76
+ and os.getenv("SECAGENTX_JWT_SECRET")
77
+ )
78
+
79
+
80
+ def activate_runtime_settings() -> None:
81
+ try:
82
+ RuntimeSettingsStore().activate()
83
+ except (ValueError, CredentialStoreError):
84
+ return
@@ -0,0 +1,32 @@
1
+ """
2
+ Decision Fusion 决策融合模块 — 统一决策框架(Sense-Decide 分离)
3
+
4
+ 职责:
5
+ - 各专业 Agent(感知层)只输出证据包 EvidencePackage
6
+ - Decision Fusion(决策层)统一裁决 → 唯一 final verdict
7
+ - 融合引擎为可替换模块(接口见 base.py)
8
+
9
+ 用法:
10
+ from backend.decision_fusion import FusionEngineFactory
11
+ engine = FusionEngineFactory.get_engine("dempster_shafer", config={...})
12
+ result = engine.fuse(evidence_packages) # -> FusionResult
13
+
14
+ 引擎注册表:
15
+ - dempster_shafer (默认,DS 证据理论,支持未知/冲突)
16
+ - weighted_average (参考实现 / 兜底)
17
+ """
18
+ from .base import DecisionFusionEngine, FusionEngineFactory, DEFAULT_AGENT_WEIGHTS
19
+ from .dempster_shafer import DempsterShaferEngine
20
+ from .weighted_average import WeightedAverageEngine
21
+ from ..models.output import (
22
+ EvidencePackage, EvidenceMass, FusionConflict, FusionResult,
23
+ FusionVerdict, Finding, build_evidence_package, build_fusion_result,
24
+ )
25
+
26
+ __all__ = [
27
+ "DecisionFusionEngine", "FusionEngineFactory", "DEFAULT_AGENT_WEIGHTS",
28
+ "DempsterShaferEngine", "WeightedAverageEngine",
29
+ "EvidencePackage", "EvidenceMass", "FusionConflict", "FusionResult",
30
+ "FusionVerdict", "Finding", "build_evidence_package", "build_fusion_result",
31
+ ]
32
+
@@ -0,0 +1,127 @@
1
+ """
2
+ DecisionFusionEngine — 决策融合引擎抽象接口(可替换模块)
3
+
4
+ 设计目标(Sense-Decide 分离):
5
+ 1. 各专业 Agent 只输出证据包(EvidencePackage),不直接裁决风险
6
+ 2. Decision Fusion 统一裁决 → 唯一 final verdict
7
+ 3. 引擎是可替换模块:通过工厂按名称注册/切换,默认 Dempster-Shafer
8
+
9
+ 接入新引擎步骤:
10
+ 1. 继承 DecisionFusionEngine,实现 fuse()
11
+ 2. 在 FusionEngineFactory._ENGINES 注册(或在运行时 register)
12
+ 3. 配置 config.yaml: decision_fusion.engine = "你的引擎名"
13
+
14
+ 所有引擎输出统一 FusionResult(见 backend/models/output.py),
15
+ 上层(react_loop / RiskScorer / 前端)不感知具体算法。
16
+ """
17
+ from abc import ABC, abstractmethod
18
+ from typing import Optional
19
+
20
+ from ..models.output import (
21
+ EvidencePackage, FusionResult, build_fusion_result,
22
+ )
23
+
24
+ # ── 各 Agent 的固定基础权重(与 react_loop.CONFIDENCE_WEIGHTS 对齐) ──
25
+ DEFAULT_AGENT_WEIGHTS = {
26
+ "analyst-001": 0.35,
27
+ "intel-001": 0.25,
28
+ "responder-001": 0.20,
29
+ "alert-filter-001": 0.10,
30
+ "knowledge-001": 0.10,
31
+ }
32
+
33
+
34
+ class DecisionFusionEngine(ABC):
35
+ """决策融合引擎抽象基类 — 可替换模块接口。
36
+
37
+ 子类只需实现 fuse();其余辅助方法(权重归一化等)可直接复用。
38
+ """
39
+
40
+ #: 引擎唯一标识(config 中 decision_fusion.engine 引用此名称)
41
+ name: str = "base"
42
+
43
+ def __init__(self, config: Optional[dict] = None):
44
+ self.config = config or {}
45
+ self.agent_weights = dict(DEFAULT_AGENT_WEIGHTS)
46
+ self.agent_weights.update(self.config.get("agent_weights", {}) or {})
47
+
48
+ @abstractmethod
49
+ def fuse(self, evidence_packages: list[EvidencePackage]) -> FusionResult:
50
+ """融合所有证据包,产出唯一权威裁决。
51
+
52
+ Args:
53
+ evidence_packages: 各 Agent 证据包(已标准化)
54
+
55
+ Returns:
56
+ FusionResult(verdict/confidence/conflicts/decision_path ...)
57
+ """
58
+ ...
59
+
60
+ # ── 共享辅助 ──
61
+
62
+ def _weight_for(self, agent_id: str, degraded: bool = False) -> float:
63
+ """获取 Agent 基础权重(降级折半,与既有纪律一致)。"""
64
+ w = self.agent_weights.get(agent_id, 0.0)
65
+ if degraded:
66
+ w *= 0.5
67
+ return w
68
+
69
+ def _normalize_weights(self, packages: list[EvidencePackage]) -> list[tuple[EvidencePackage, float]]:
70
+ """为每个证据包计算归一化权重(基础权重 × 证据可靠度)。
71
+
72
+ 规则:
73
+ - failed 包权重 = 0(不参与融合,但记录)
74
+ - 无结构化裁决(evidence_confidence 接近默认且无 findings)→ 权重打折
75
+ """
76
+ scored = []
77
+ for p in packages:
78
+ if p.failed:
79
+ scored.append((p, 0.0))
80
+ continue
81
+ base = self._weight_for(p.agent_id, p.degraded)
82
+ # 证据可靠度:findings 均值 / evidence_confidence
83
+ ev_conf = p.evidence_confidence
84
+ if ev_conf <= 0.0:
85
+ ev_conf = 0.5
86
+ scored.append((p, base * ev_conf))
87
+ total = sum(w for _, w in scored)
88
+ if total <= 0:
89
+ return [(p, 0.0) for p in packages]
90
+ return [(p, w / total) for p, w in scored]
91
+
92
+
93
+ # ═══════════════════════ 引擎工厂(可替换模块注册表) ═══════════════════════
94
+
95
+ class FusionEngineFactory:
96
+ """决策融合引擎工厂 — 按名称获取引擎实例。
97
+
98
+ 默认注册 dempster_shafer;可调用 register() 注册自定义引擎。
99
+ """
100
+
101
+ _ENGINES: dict[str, type] = {}
102
+
103
+ @classmethod
104
+ def register(cls, name: str, engine_cls: type) -> None:
105
+ cls._ENGINES[name] = engine_cls
106
+
107
+ @classmethod
108
+ def get_engine(cls, name: str = "dempster_shafer",
109
+ config: Optional[dict] = None) -> "DecisionFusionEngine":
110
+ """获取引擎实例;未知名称抛 ValueError(配置错误要尽早暴露)。"""
111
+ engine_cls = cls._ENGINES.get(name)
112
+ if engine_cls is None:
113
+ raise ValueError(
114
+ f"未知决策融合引擎: {name!r},可用: {sorted(cls._ENGINES.keys())}"
115
+ )
116
+ return engine_cls(config=config)
117
+
118
+ @classmethod
119
+ def available(cls) -> list[str]:
120
+ return sorted(cls._ENGINES.keys())
121
+
122
+
123
+ __all__ = [
124
+ "DecisionFusionEngine", "FusionEngineFactory",
125
+ "DEFAULT_AGENT_WEIGHTS",
126
+ ]
127
+