admina-framework 0.9.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 (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,925 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Dashboard backend API endpoints.
16
+
17
+ Provides the governance score, live event feed, compliance gap
18
+ summary, data sovereignty statistics, and OISG adequacy assessment
19
+ for the Admina dashboard.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import csv as _csv
26
+ import io as _io
27
+ import json
28
+ import logging
29
+ import secrets
30
+ import time
31
+ from datetime import UTC, datetime
32
+ from typing import Any
33
+
34
+ from fastapi import APIRouter, Query, Response, WebSocket, WebSocketDisconnect
35
+
36
+ from admina.core.event_bus import GovernanceEvent, bus
37
+ from admina.domains.compliance.oisg import PILLAR_COLORS, compute_oisg_score
38
+
39
+ logger = logging.getLogger("admina.api.dashboard")
40
+
41
+
42
+ def _suggestions_to_csv(payload: dict) -> Response:
43
+ """Render the suggestions payload as CSV for spreadsheet consumption."""
44
+ buf = _io.StringIO()
45
+ w = _csv.writer(buf)
46
+ w.writerow(
47
+ [
48
+ "type",
49
+ "category",
50
+ "severity",
51
+ "count",
52
+ "blocked",
53
+ "would_blocked",
54
+ "previous",
55
+ "current",
56
+ "ratio",
57
+ "share_pct",
58
+ "message",
59
+ ]
60
+ )
61
+ for s in payload.get("suggestions", []):
62
+ w.writerow(
63
+ [
64
+ s.get("type", ""),
65
+ s.get("category", ""),
66
+ s.get("severity", ""),
67
+ s.get("count", ""),
68
+ s.get("blocked", ""),
69
+ s.get("would_blocked", ""),
70
+ s.get("previous", ""),
71
+ s.get("current", ""),
72
+ s.get("ratio", ""),
73
+ s.get("share_pct", ""),
74
+ (s.get("message") or "").replace("\n", " "),
75
+ ]
76
+ )
77
+ return Response(content=buf.getvalue(), media_type="text/csv")
78
+
79
+
80
+ # ── Governance score ─────────────────────────────────────────
81
+ def _compute_governance_score(
82
+ *,
83
+ metrics: dict[str, Any],
84
+ forensic_box: Any | None,
85
+ compliance_engine: Any,
86
+ ) -> dict[str, Any]:
87
+ """Compute weighted governance score (0-100).
88
+
89
+ Formula (from roadmap section 3.2):
90
+ - Data residency 100% enforced? +25
91
+ - All interactions audited? +25
92
+ - EU AI Act gap coverage (% articles covered x 25) +25
93
+ - No blocked attacks in last 24h? +15
94
+ - Forensic chain valid? +10
95
+ """
96
+ breakdown: dict[str, int] = {}
97
+
98
+ # Data residency — enforced if proxy is running (always true in proxy mode)
99
+ breakdown["data_residency"] = 25
100
+
101
+ # All interactions audited — true if forensic box is active
102
+ audited = forensic_box is not None and forensic_box.record_count > 0
103
+ breakdown["interactions_audited"] = 25 if audited else 0
104
+
105
+ # EU AI Act gap coverage — use last assessment if available
106
+ gap_score = 0
107
+ assessments = getattr(compliance_engine, "assessments", [])
108
+ if assessments:
109
+ latest = assessments[-1]
110
+ coverage_pct = latest.get("compliance_score", 0) / 100
111
+ gap_score = round(coverage_pct * 25)
112
+ breakdown["eu_ai_act_coverage"] = gap_score
113
+
114
+ # No blocked attacks in last 24h
115
+ blocked = metrics.get("requests_blocked", 0)
116
+ breakdown["no_recent_attacks"] = 15 if blocked == 0 else 0
117
+
118
+ # Forensic chain valid
119
+ chain_valid = forensic_box is not None and forensic_box.chain_head != "GENESIS"
120
+ breakdown["forensic_chain_valid"] = 10 if chain_valid else 0
121
+
122
+ total = sum(breakdown.values())
123
+ return {
124
+ "score": total,
125
+ "max_score": 100,
126
+ "breakdown": breakdown,
127
+ "computed_at": datetime.now(UTC).isoformat(),
128
+ }
129
+
130
+
131
+ # ── WebSocket live feed ──────────────────────────────────────
132
+ _ws_clients: set[WebSocket] = set()
133
+
134
+
135
+ async def _broadcast_event(event: GovernanceEvent) -> None:
136
+ """Forward event bus events to all connected WebSocket clients."""
137
+ payload = json.dumps(
138
+ {
139
+ "event_type": event.event_type.value,
140
+ "timestamp": event.timestamp.isoformat() if event.timestamp else None,
141
+ "action": event.action,
142
+ "risk_level": event.risk_level,
143
+ "domain": event.domain,
144
+ "metadata": event.metadata,
145
+ }
146
+ )
147
+ dead: list[WebSocket] = []
148
+ for ws in list(_ws_clients):
149
+ try:
150
+ await ws.send_text(payload)
151
+ except (OSError, RuntimeError):
152
+ dead.append(ws)
153
+ for ws in dead:
154
+ _ws_clients.discard(ws)
155
+
156
+
157
+ # Subscribe the broadcaster to the event bus at import time.
158
+ bus.subscribe_all(_broadcast_event)
159
+
160
+
161
+ # ── Factory for stateful endpoints ───────────────────────────
162
+ def create_dashboard_endpoints(
163
+ *,
164
+ get_metrics: Any,
165
+ get_forensic_box: Any,
166
+ get_compliance: Any,
167
+ get_clickhouse: Any,
168
+ get_settings: Any,
169
+ get_redis: Any = None,
170
+ get_minio: Any = None,
171
+ get_engine_status: Any = None,
172
+ get_http_client: Any = None,
173
+ get_firewall: Any = None,
174
+ get_pii_redactor: Any = None,
175
+ get_loop_breaker: Any = None,
176
+ get_otel_exporter: Any = None,
177
+ get_governance_guards: Any = None,
178
+ get_config: Any = None,
179
+ ) -> APIRouter:
180
+ """Create a new APIRouter with dashboard endpoints.
181
+
182
+ A fresh router is created on each call so that closures bind
183
+ to the correct proxy state (important for test isolation).
184
+
185
+ Args:
186
+ get_metrics: Callable returning the proxy metrics dict.
187
+ get_forensic_box: Callable returning ForensicBlackBox | None.
188
+ get_compliance: Callable returning EUAIActCompliance.
189
+ get_clickhouse: Callable returning ClickHouse client | None.
190
+ get_settings: Callable returning the Settings object.
191
+ get_redis: Callable returning the async Redis client | None.
192
+ get_minio: Callable returning the MinIO client | None.
193
+ get_engine_status: Callable returning engine status dict.
194
+ get_http_client: Callable returning httpx.AsyncClient | None.
195
+ get_firewall: Callable returning the firewall engine | None.
196
+ get_pii_redactor: Callable returning the PII redactor | None.
197
+ get_loop_breaker: Callable returning the loop breaker | None.
198
+ get_otel_exporter: Callable returning OTEL exporter | None.
199
+ get_governance_guards: Callable returning list of guards.
200
+ get_config: Callable returning AdminaConfig | None.
201
+
202
+ Returns:
203
+ The configured APIRouter.
204
+ """
205
+ router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
206
+
207
+ # ClickHouse client does not support concurrent queries within the
208
+ # same session. The dashboard JS fires all endpoints in parallel
209
+ # via Promise.all, so we serialise ClickHouse access with a lock.
210
+ _ch_lock = asyncio.Lock()
211
+
212
+ @router.websocket("/live")
213
+ async def dashboard_live(websocket: WebSocket) -> None:
214
+ """WebSocket endpoint for live governance event feed.
215
+
216
+ Requires the same credential as the HTTP endpoints. Browsers
217
+ cannot set custom headers on the ``WebSocket`` constructor, so
218
+ the API key is also accepted via the ``api_key`` query
219
+ parameter; the dashboard nginx forwards the static
220
+ ``X-API-Key`` header transparently.
221
+ """
222
+ settings = get_settings()
223
+ expected = getattr(settings, "ADMINA_API_KEY", "") or ""
224
+ if expected:
225
+ # Accept the credential from the X-API-Key header, the
226
+ # ?api_key=... query param, or the admina_session cookie
227
+ # set by the bundled dashboard at GET /.
228
+ provided = (
229
+ websocket.headers.get("X-API-Key")
230
+ or websocket.query_params.get("api_key")
231
+ or websocket.cookies.get("admina_session")
232
+ or ""
233
+ )
234
+ if not provided or not secrets.compare_digest(provided, expected):
235
+ await websocket.close(code=1008)
236
+ return
237
+ elif not getattr(settings, "ALLOW_UNAUTHENTICATED", False):
238
+ await websocket.close(code=1008)
239
+ return
240
+
241
+ await websocket.accept()
242
+ _ws_clients.add(websocket)
243
+ try:
244
+ while True:
245
+ await websocket.receive_text()
246
+ except WebSocketDisconnect:
247
+ pass
248
+ finally:
249
+ _ws_clients.discard(websocket)
250
+
251
+ @router.get("/score")
252
+ async def dashboard_score() -> dict[str, Any]:
253
+ """Governance score (weighted composite 0-100)."""
254
+ return _compute_governance_score(
255
+ metrics=get_metrics(),
256
+ forensic_box=get_forensic_box(),
257
+ compliance_engine=get_compliance(),
258
+ )
259
+
260
+ @router.get("/feed")
261
+ async def dashboard_feed(
262
+ limit: int = Query(50, ge=1, le=1000),
263
+ offset: int = Query(0, ge=0),
264
+ ) -> dict[str, Any]:
265
+ """Recent governance events (paginated)."""
266
+ ch = get_clickhouse()
267
+ if not ch:
268
+ return {"events": [], "count": 0, "error": "ClickHouse not available"}
269
+ try:
270
+ loop = asyncio.get_running_loop()
271
+ db = get_settings().CLICKHOUSE_DB
272
+ async with _ch_lock:
273
+ result = await loop.run_in_executor(
274
+ None,
275
+ lambda: ch.query(
276
+ f"SELECT * FROM {db}.governance_events "
277
+ f"ORDER BY timestamp DESC LIMIT {int(limit)} OFFSET {int(offset)}"
278
+ ),
279
+ )
280
+ events = [dict(zip(result.column_names, row)) for row in result.result_rows]
281
+ return {"events": events, "count": len(events)}
282
+ except (OSError, RuntimeError, Exception) as exc:
283
+ logger.warning("ClickHouse feed query failed: %s", exc)
284
+ return {"events": [], "count": 0, "error": str(exc)}
285
+
286
+ @router.get("/compliance")
287
+ async def dashboard_compliance() -> dict[str, Any]:
288
+ """EU AI Act gap analysis summary for the dashboard.
289
+
290
+ IMPORTANT: this endpoint is read-only. It does NOT create an
291
+ assessment when none exists — that would pollute the engine's
292
+ ``assessments`` list and make ``has_assessment`` always-true after
293
+ the first dashboard load. Instead we synthesise a placeholder
294
+ ``latest`` whose ``gaps`` list covers every Art. 9-15 check, so
295
+ the UI renders the same article grid (all at 0%) it would render
296
+ for a real assessment with no evidence declared.
297
+ """
298
+ comp = get_compliance()
299
+ from admina.domains.compliance.eu_ai_act import (
300
+ EU_AI_ACT_DEADLINES,
301
+ EU_AI_ACT_ENFORCEMENT_DEADLINE,
302
+ HIGH_RISK_REQUIREMENTS,
303
+ )
304
+
305
+ assessments = getattr(comp, "assessments", [])
306
+ if not assessments:
307
+ gaps = [
308
+ {
309
+ "requirement": req["name"],
310
+ "article": req["article"],
311
+ "check": check,
312
+ "status": "NOT_MET",
313
+ }
314
+ for req in HIGH_RISK_REQUIREMENTS.values()
315
+ for check in req["checks"]
316
+ ]
317
+ placeholder = {
318
+ "applicable": True,
319
+ "compliance_score": 0,
320
+ "total_checks": len(gaps),
321
+ "passed_checks": 0,
322
+ "gaps": gaps,
323
+ "gap_count": len(gaps),
324
+ "status": "NO_ASSESSMENT",
325
+ "enforcement_deadline": EU_AI_ACT_ENFORCEMENT_DEADLINE,
326
+ "deadlines": EU_AI_ACT_DEADLINES,
327
+ "assessed_at": None,
328
+ }
329
+ return {
330
+ "has_assessment": False,
331
+ "latest": placeholder,
332
+ "enforcement_deadline": EU_AI_ACT_ENFORCEMENT_DEADLINE,
333
+ "deadlines": EU_AI_ACT_DEADLINES,
334
+ }
335
+
336
+ return {
337
+ "has_assessment": True,
338
+ "latest": assessments[-1],
339
+ "total_assessments": len(assessments),
340
+ "enforcement_deadline": EU_AI_ACT_ENFORCEMENT_DEADLINE,
341
+ "deadlines": EU_AI_ACT_DEADLINES,
342
+ }
343
+
344
+ @router.get("/sovereignty")
345
+ async def dashboard_sovereignty() -> dict[str, Any]:
346
+ """Data zone statistics for the sovereignty map."""
347
+ zones = {
348
+ "local": {
349
+ "name": "Local (on-premise)",
350
+ "status": "enforced",
351
+ "description": "All data processed locally through Admina proxy",
352
+ },
353
+ }
354
+ ch = get_clickhouse()
355
+ event_count = 0
356
+ if ch:
357
+ try:
358
+ loop = asyncio.get_running_loop()
359
+ db = get_settings().CLICKHOUSE_DB
360
+ async with _ch_lock:
361
+ result = await loop.run_in_executor(
362
+ None,
363
+ lambda: ch.query(f"SELECT count() FROM {db}.governance_events"),
364
+ )
365
+ event_count = result.result_rows[0][0] if result.result_rows else 0
366
+ except (OSError, RuntimeError, Exception):
367
+ pass
368
+
369
+ return {
370
+ "zones": zones,
371
+ "total_governed_events": event_count,
372
+ "data_residency_enforced": True,
373
+ }
374
+
375
+ @router.get("/infra")
376
+ async def dashboard_infra() -> dict[str, Any]:
377
+ """Infrastructure health: container status, response times, disk."""
378
+ services: dict[str, dict[str, Any]] = {}
379
+
380
+ # Proxy — always running if we can serve this request
381
+ services["proxy"] = {"status": "healthy", "port": 8080}
382
+
383
+ # Redis
384
+ redis = get_redis() if get_redis else None
385
+ if redis is not None:
386
+ try:
387
+ t0 = time.perf_counter()
388
+ await redis.ping()
389
+ latency = round((time.perf_counter() - t0) * 1000, 2)
390
+ info = await redis.info("memory")
391
+ services["redis"] = {
392
+ "status": "healthy",
393
+ "latency_ms": latency,
394
+ "used_memory_human": info.get("used_memory_human", "—"),
395
+ "used_memory_bytes": info.get("used_memory", 0),
396
+ }
397
+ except (OSError, RuntimeError) as exc:
398
+ services["redis"] = {"status": "unhealthy", "error": str(exc)}
399
+ else:
400
+ services["redis"] = {"status": "not_configured"}
401
+
402
+ # ClickHouse
403
+ ch = get_clickhouse()
404
+ if ch is not None:
405
+ try:
406
+ loop = asyncio.get_running_loop()
407
+ async with _ch_lock:
408
+ t0 = time.perf_counter()
409
+ await loop.run_in_executor(None, lambda: ch.query("SELECT 1"))
410
+ latency = round((time.perf_counter() - t0) * 1000, 2)
411
+ db = get_settings().CLICKHOUSE_DB
412
+ row_result = await loop.run_in_executor(
413
+ None,
414
+ lambda: ch.query(f"SELECT count() FROM {db}.governance_events"),
415
+ )
416
+ row_count = row_result.result_rows[0][0] if row_result.result_rows else 0
417
+ services["clickhouse"] = {
418
+ "status": "healthy",
419
+ "latency_ms": latency,
420
+ "event_count": row_count,
421
+ }
422
+ except (OSError, RuntimeError, Exception) as exc:
423
+ services["clickhouse"] = {
424
+ "status": "unhealthy",
425
+ "error": str(exc),
426
+ }
427
+ else:
428
+ services["clickhouse"] = {"status": "not_configured"}
429
+
430
+ # MinIO
431
+ minio = get_minio() if get_minio else None
432
+ if minio is not None:
433
+ try:
434
+ loop = asyncio.get_running_loop()
435
+ t0 = time.perf_counter()
436
+ buckets = await loop.run_in_executor(None, minio.list_buckets)
437
+ latency = round((time.perf_counter() - t0) * 1000, 2)
438
+ services["minio"] = {
439
+ "status": "healthy",
440
+ "latency_ms": latency,
441
+ "bucket_count": len(buckets),
442
+ }
443
+ except (OSError, RuntimeError) as exc:
444
+ services["minio"] = {
445
+ "status": "unhealthy",
446
+ "error": str(exc),
447
+ }
448
+ else:
449
+ services["minio"] = {"status": "not_configured"}
450
+
451
+ # Upstream MCP
452
+ http = get_http_client() if get_http_client else None
453
+ if http is not None:
454
+ upstream = get_settings().UPSTREAM_MCP_URL
455
+ try:
456
+ t0 = time.perf_counter()
457
+ resp = await http.get(f"{upstream}/health", timeout=3.0)
458
+ latency = round((time.perf_counter() - t0) * 1000, 2)
459
+ services["upstream_mcp"] = {
460
+ "status": "healthy" if resp.status_code < 500 else "degraded",
461
+ "latency_ms": latency,
462
+ "url": upstream,
463
+ }
464
+ except (OSError, RuntimeError):
465
+ services["upstream_mcp"] = {
466
+ "status": "unreachable",
467
+ "url": upstream,
468
+ }
469
+ else:
470
+ services["upstream_mcp"] = {"status": "not_configured"}
471
+
472
+ healthy_count = sum(1 for s in services.values() if s.get("status") == "healthy")
473
+ return {
474
+ "services": services,
475
+ "healthy_count": healthy_count,
476
+ "total_count": len(services),
477
+ "overall": "healthy" if healthy_count == len(services) else "degraded",
478
+ "checked_at": datetime.now(UTC).isoformat(),
479
+ }
480
+
481
+ @router.get("/models")
482
+ async def dashboard_models() -> dict[str, Any]:
483
+ """Model status: active models, engine info, GPU utilization."""
484
+ engine = get_engine_status() if get_engine_status else {}
485
+
486
+ models: list[dict[str, Any]] = []
487
+
488
+ # The governance engine itself is always "active"
489
+ models.append(
490
+ {
491
+ "name": "admina-governance-engine",
492
+ "type": "governance",
493
+ "backend": engine.get("engine", "python"),
494
+ "version": engine.get("rust_version") or "pure-python",
495
+ "status": "active",
496
+ }
497
+ )
498
+
499
+ # The ai_infra domain is opt-in; check whether it is configured.
500
+ ai_infra_enabled = False
501
+ try:
502
+ cfg = get_settings()
503
+ ai_infra_enabled = getattr(cfg, "AI_INFRA_LLM_ENABLED", False)
504
+ except (AttributeError, ValueError):
505
+ pass
506
+
507
+ gpu_info: dict[str, Any] | None = None
508
+ if ai_infra_enabled:
509
+ # Probe Ollama if configured
510
+ http = get_http_client() if get_http_client else None
511
+ if http is not None:
512
+ try:
513
+ ollama_url = getattr(get_settings(), "OLLAMA_URL", "http://ollama:11434")
514
+ resp = await http.get(f"{ollama_url}/api/tags", timeout=3.0)
515
+ if resp.status_code == 200:
516
+ for m in resp.json().get("models", []):
517
+ models.append(
518
+ {
519
+ "name": m.get("name", "unknown"),
520
+ "type": "llm",
521
+ "backend": "ollama",
522
+ "size": m.get("size"),
523
+ "status": "loaded",
524
+ }
525
+ )
526
+ except (OSError, RuntimeError, ValueError):
527
+ pass
528
+
529
+ return {
530
+ "models": models,
531
+ "model_count": len(models),
532
+ "engine": engine,
533
+ "ai_infra_enabled": ai_infra_enabled,
534
+ "gpu": gpu_info,
535
+ }
536
+
537
+ @router.get("/suggestions")
538
+ async def dashboard_suggestions(
539
+ window_hours: int = Query(24, ge=1, le=720),
540
+ min_count: int = Query(5, ge=1, le=10000),
541
+ format: str = Query("json", pattern="^(json|csv)$"),
542
+ ) -> Any:
543
+ """Statistical, no-LLM policy suggestions based on the recent feed.
544
+
545
+ Looks at the last `window_hours` of governance events, aggregates
546
+ block/allow counts per pattern category, and produces actionable
547
+ recommendations: categories to silence, mode toggles to consider,
548
+ custom patterns to harden. The output is consumed by the dashboard
549
+ and surfaced as-is.
550
+
551
+ Args:
552
+ window_hours: lookback window in hours (default 24, max 720 = 30d).
553
+ min_count: skip categories with fewer than this many events.
554
+ """
555
+ ch = get_clickhouse()
556
+ if not ch:
557
+ return {
558
+ "window_hours": window_hours,
559
+ "events_analyzed": 0,
560
+ "suggestions": [],
561
+ "error": "ClickHouse not available",
562
+ }
563
+
564
+ try:
565
+ loop = asyncio.get_running_loop()
566
+ db = get_settings().CLICKHOUSE_DB
567
+ mode = getattr(get_settings(), "GOVERNANCE_MODE", "enforce")
568
+
569
+ # Aggregate by action + risk + a flat category extracted from details.
570
+ # Schema: (event_id, timestamp, event_type, agent_id, session_id,
571
+ # method, tool_name, action, risk_level, details, ...)
572
+ query = (
573
+ f"SELECT action, risk_level, details "
574
+ f"FROM {db}.governance_events "
575
+ f"WHERE timestamp >= now() - INTERVAL {int(window_hours)} HOUR"
576
+ )
577
+ async with _ch_lock:
578
+ result = await loop.run_in_executor(None, lambda: ch.query(query))
579
+
580
+ total = len(result.result_rows)
581
+ if total == 0:
582
+ return {
583
+ "window_hours": window_hours,
584
+ "events_analyzed": 0,
585
+ "mode": mode,
586
+ "suggestions": [
587
+ {
588
+ "type": "no_data",
589
+ "severity": "info",
590
+ "message": (
591
+ f"No events in the last {window_hours}h. Nothing to "
592
+ "suggest yet — generate some traffic first."
593
+ ),
594
+ "actions": [],
595
+ }
596
+ ],
597
+ }
598
+
599
+ # Per-category counters
600
+ cat_total: dict[str, int] = {}
601
+ cat_blocked: dict[str, int] = {}
602
+ cat_would_blocked: dict[str, int] = {}
603
+ for row in result.result_rows:
604
+ action, _risk, details_raw = row[0], row[1], row[2]
605
+ # Action is stored lowercase in ClickHouse — normalise once.
606
+ action_upper = (action or "").upper()
607
+ try:
608
+ d = json.loads(details_raw) if details_raw else {}
609
+ except (TypeError, ValueError):
610
+ d = {}
611
+ # The pipeline stores firewall patterns under
612
+ # details.firewall.patterns: [{pattern, risk_level}, ...]
613
+ # (also nested under fast_path.patterns when only the fast
614
+ # path matched).
615
+ fw = d.get("firewall", {}) or {}
616
+ patterns = list(fw.get("patterns") or [])
617
+ if not patterns and isinstance(fw.get("fast_path"), dict):
618
+ patterns = list(fw["fast_path"].get("patterns") or [])
619
+ cats = {p.get("pattern") for p in patterns if p.get("pattern")}
620
+ # No firewall patterns: still count loop_breaker / pii / guard categories
621
+ if (d.get("loop_breaker", {}) or {}).get("is_loop"):
622
+ cats.add("loop_breaker")
623
+ if d.get("would_action") and not cats:
624
+ cats.add("(unknown)")
625
+ would = (d.get("would_action") or "").upper()
626
+ for cat in cats or {"(none)"}:
627
+ cat_total[cat] = cat_total.get(cat, 0) + 1
628
+ if action_upper in ("BLOCK", "CIRCUIT_BREAK"):
629
+ cat_blocked[cat] = cat_blocked.get(cat, 0) + 1
630
+ if would in ("BLOCK", "CIRCUIT_BREAK"):
631
+ cat_would_blocked[cat] = cat_would_blocked.get(cat, 0) + 1
632
+
633
+ # Build suggestions
634
+ suggestions: list[dict[str, Any]] = []
635
+
636
+ # 1. High-volume categories — candidate for review
637
+ for cat, count in sorted(cat_total.items(), key=lambda kv: -kv[1]):
638
+ if count < min_count or cat in ("(none)", "(unknown)"):
639
+ continue
640
+ share = round(count * 100 / total, 1)
641
+ blocked = cat_blocked.get(cat, 0)
642
+ would = cat_would_blocked.get(cat, 0)
643
+ if mode == "enforce" and share >= 30:
644
+ suggestions.append(
645
+ {
646
+ "type": "high_block_share",
647
+ "category": cat,
648
+ "count": count,
649
+ "blocked": blocked,
650
+ "share_pct": share,
651
+ "severity": "info" if share < 60 else "warn",
652
+ "message": (
653
+ f"Category '{cat}' accounts for {share}% of traffic in "
654
+ f"the last {window_hours}h ({blocked} blocked). If many "
655
+ "are false positives, consider tuning."
656
+ ),
657
+ "actions": [
658
+ "Inspect samples: GET /api/dashboard/feed?limit=100",
659
+ "Switch to observe mode for tuning: ADMINA_GOVERNANCE_MODE=observe",
660
+ f"Disable temporarily via admina.yaml: "
661
+ f"agent_security.firewall.disabled_categories: ['{cat}']",
662
+ ],
663
+ }
664
+ )
665
+ if mode in ("observe", "dry-run") and would >= min_count:
666
+ suggestions.append(
667
+ {
668
+ "type": "would_block_in_enforce",
669
+ "category": cat,
670
+ "count": count,
671
+ "would_blocked": would,
672
+ "share_pct": share,
673
+ "severity": "info",
674
+ "message": (
675
+ f"In enforce mode, category '{cat}' would have blocked "
676
+ f"{would} request(s) in the last {window_hours}h. "
677
+ "Review the samples before flipping the mode."
678
+ ),
679
+ "actions": [
680
+ "Inspect: GET /api/dashboard/feed?limit=100",
681
+ "Once tuned, switch to: ADMINA_GOVERNANCE_MODE=enforce",
682
+ ],
683
+ }
684
+ )
685
+
686
+ # 2. Mode-specific guidance
687
+ if mode == "enforce" and not cat_blocked:
688
+ suggestions.append(
689
+ {
690
+ "type": "enforce_no_blocks",
691
+ "severity": "info",
692
+ "message": (
693
+ "Enforce mode active for the last "
694
+ f"{window_hours}h with zero blocks. Either traffic is "
695
+ "clean or your patterns are too permissive — sample a "
696
+ "few requests to confirm."
697
+ ),
698
+ "actions": ["Inspect: GET /api/dashboard/feed?limit=50"],
699
+ }
700
+ )
701
+ elif mode in ("observe", "dry-run") and not cat_would_blocked:
702
+ suggestions.append(
703
+ {
704
+ "type": "observe_clean",
705
+ "severity": "info",
706
+ "message": (
707
+ f"Observe mode for {window_hours}h with zero "
708
+ "would-have-blocked events. Safe to switch to enforce."
709
+ ),
710
+ "actions": ["Set ADMINA_GOVERNANCE_MODE=enforce"],
711
+ }
712
+ )
713
+
714
+ # 3. Loop breaker hot signal
715
+ lb_count = cat_total.get("loop_breaker", 0)
716
+ if lb_count >= max(min_count, 10):
717
+ suggestions.append(
718
+ {
719
+ "type": "loop_breaker_active",
720
+ "category": "loop_breaker",
721
+ "count": lb_count,
722
+ "severity": "warn",
723
+ "message": (
724
+ f"Loop breaker fired {lb_count} time(s) in {window_hours}h. "
725
+ "If these are legitimate template loops, consider lowering "
726
+ "the similarity threshold or raising max_consecutive."
727
+ ),
728
+ "actions": [
729
+ "Tune ADMINA_LOOP_SIMILARITY_THRESHOLD (default 0.85)",
730
+ "Tune ADMINA_LOOP_MAX_CONSECUTIVE (default 3)",
731
+ ],
732
+ }
733
+ )
734
+
735
+ # 4. Trend awareness — compare current window vs the
736
+ # immediately-preceding equal window. If a category's blocked
737
+ # rate has surged ≥2x with absolute count ≥ min_count, flag it.
738
+ try:
739
+ prev_query = (
740
+ f"SELECT details FROM {db}.governance_events "
741
+ f"WHERE timestamp >= now() - INTERVAL {int(window_hours * 2)} HOUR "
742
+ f" AND timestamp < now() - INTERVAL {int(window_hours)} HOUR "
743
+ f" AND lower(action) IN ('block','circuit_break')"
744
+ )
745
+ async with _ch_lock:
746
+ prev_res = await loop.run_in_executor(
747
+ None,
748
+ lambda: ch.query(prev_query),
749
+ )
750
+ prev_blocked: dict[str, int] = {}
751
+ for (det_raw,) in prev_res.result_rows:
752
+ try:
753
+ d = json.loads(det_raw) if det_raw else {}
754
+ except (TypeError, ValueError):
755
+ continue
756
+ fw = d.get("firewall", {}) or {}
757
+ pats = list(fw.get("patterns") or []) or list(
758
+ (fw.get("fast_path") or {}).get("patterns") or []
759
+ )
760
+ for p in pats:
761
+ c = p.get("pattern")
762
+ if c:
763
+ prev_blocked[c] = prev_blocked.get(c, 0) + 1
764
+
765
+ for cat, cur in cat_blocked.items():
766
+ if cat in ("(none)", "(unknown)") or cur < min_count:
767
+ continue
768
+ prev = prev_blocked.get(cat, 0)
769
+ if prev == 0 and cur >= min_count * 2:
770
+ suggestions.append(
771
+ {
772
+ "type": "trend_new_category",
773
+ "category": cat,
774
+ "previous": prev,
775
+ "current": cur,
776
+ "severity": "warn",
777
+ "message": (
778
+ f"Category '{cat}' was silent in the previous "
779
+ f"{window_hours}h and now blocks {cur} requests. "
780
+ "Investigate — could be a new attack campaign or "
781
+ "a legitimate workflow that needs whitelisting."
782
+ ),
783
+ "actions": [
784
+ "Inspect: GET /api/dashboard/feed?limit=100",
785
+ ],
786
+ }
787
+ )
788
+ elif prev > 0 and cur >= 2 * prev and cur >= min_count:
789
+ ratio = round(cur / prev, 1)
790
+ suggestions.append(
791
+ {
792
+ "type": "trend_surge",
793
+ "category": cat,
794
+ "previous": prev,
795
+ "current": cur,
796
+ "ratio": ratio,
797
+ "severity": "warn",
798
+ "message": (
799
+ f"'{cat}' blocks surged {ratio}x ({prev} → {cur}) "
800
+ f"compared to the previous {window_hours}h. "
801
+ "Likely a new attack pattern variant."
802
+ ),
803
+ "actions": [
804
+ "Inspect samples and consider adding a custom_pattern",
805
+ "Or temporarily switch the category to observe mode",
806
+ ],
807
+ }
808
+ )
809
+ except (OSError, RuntimeError, Exception) as exc:
810
+ logger.debug("Trend comparison skipped: %s", exc)
811
+
812
+ payload = {
813
+ "window_hours": window_hours,
814
+ "events_analyzed": total,
815
+ "mode": mode,
816
+ "by_category": cat_total,
817
+ "blocked_by_category": cat_blocked,
818
+ "would_blocked_by_category": cat_would_blocked,
819
+ "suggestions": suggestions,
820
+ }
821
+ if format == "csv":
822
+ return _suggestions_to_csv(payload)
823
+ return payload
824
+ except (OSError, RuntimeError, Exception) as exc:
825
+ logger.warning("Suggestions query failed: %s", exc)
826
+ return {
827
+ "window_hours": window_hours,
828
+ "events_analyzed": 0,
829
+ "suggestions": [],
830
+ "error": str(exc),
831
+ }
832
+
833
+ @router.get("/trend")
834
+ async def dashboard_trend(
835
+ window_hours: int = Query(24, ge=1, le=720),
836
+ bucket_minutes: int = Query(15, ge=1, le=1440),
837
+ ) -> dict[str, Any]:
838
+ """Time-series of governance events for charting.
839
+
840
+ Returns counts per bucket for the last `window_hours`, broken
841
+ down by action (allow / block / circuit_break / redact). The
842
+ bucket size is configurable so the same endpoint feeds both a
843
+ 24h-by-15min sparkline and a 30d-by-6h trend chart.
844
+
845
+ Args:
846
+ window_hours: lookback window in hours (max 720 = 30d).
847
+ bucket_minutes: aggregation bucket size in minutes.
848
+ """
849
+ ch = get_clickhouse()
850
+ if not ch:
851
+ return {
852
+ "window_hours": window_hours,
853
+ "bucket_minutes": bucket_minutes,
854
+ "buckets": [],
855
+ "error": "ClickHouse not available",
856
+ }
857
+ try:
858
+ loop = asyncio.get_running_loop()
859
+ db = get_settings().CLICKHOUSE_DB
860
+ # toStartOfInterval truncates each event to its bucket start;
861
+ # aggregate per (bucket, action). Returns at most
862
+ # window_hours * 60 / bucket_minutes rows × N actions.
863
+ query = (
864
+ f"SELECT toStartOfInterval(timestamp, INTERVAL {int(bucket_minutes)} MINUTE) AS bucket, "
865
+ f" lower(action) AS act, count() AS n "
866
+ f"FROM {db}.governance_events "
867
+ f"WHERE timestamp >= now() - INTERVAL {int(window_hours)} HOUR "
868
+ f"GROUP BY bucket, act ORDER BY bucket"
869
+ )
870
+ async with _ch_lock:
871
+ result = await loop.run_in_executor(None, lambda: ch.query(query))
872
+
873
+ # Pivot rows into per-bucket records: {ts, allow, block, circuit_break, redact}
874
+ buckets: dict[str, dict[str, int]] = {}
875
+ for bucket, act, n in result.result_rows:
876
+ key = bucket.isoformat() if hasattr(bucket, "isoformat") else str(bucket)
877
+ buckets.setdefault(key, {})[act] = int(n)
878
+
879
+ series = []
880
+ for ts in sorted(buckets):
881
+ row = buckets[ts]
882
+ series.append(
883
+ {
884
+ "ts": ts,
885
+ "allow": row.get("allow", 0),
886
+ "block": row.get("block", 0),
887
+ "circuit_break": row.get("circuit_break", 0),
888
+ "redact": row.get("redact", 0),
889
+ "total": sum(row.values()),
890
+ }
891
+ )
892
+
893
+ return {
894
+ "window_hours": window_hours,
895
+ "bucket_minutes": bucket_minutes,
896
+ "bucket_count": len(series),
897
+ "buckets": series,
898
+ }
899
+ except (OSError, RuntimeError, Exception) as exc:
900
+ logger.warning("Trend query failed: %s", exc)
901
+ return {
902
+ "window_hours": window_hours,
903
+ "bucket_minutes": bucket_minutes,
904
+ "buckets": [],
905
+ "error": str(exc),
906
+ }
907
+
908
+ @router.get("/oisg")
909
+ async def dashboard_oisg() -> dict[str, Any]:
910
+ """OISG adequacy score (Open Intelligent Secure Governed, 0-100)."""
911
+ result = compute_oisg_score(
912
+ firewall=get_firewall() if get_firewall else None,
913
+ pii_redactor=get_pii_redactor() if get_pii_redactor else None,
914
+ loop_breaker=get_loop_breaker() if get_loop_breaker else None,
915
+ forensic_box=get_forensic_box(),
916
+ compliance_engine=get_compliance(),
917
+ otel_exporter=get_otel_exporter() if get_otel_exporter else None,
918
+ governance_guards=get_governance_guards() if get_governance_guards else [],
919
+ config=get_config() if get_config else None,
920
+ engine_status=get_engine_status() if get_engine_status else {},
921
+ metrics=get_metrics(),
922
+ )
923
+ return {**result.to_dict(), "colors": PILLAR_COLORS}
924
+
925
+ return router