admina-framework 0.12.1__tar.gz → 0.12.2__tar.gz
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.
- {admina_framework-0.12.1/admina_framework.egg-info → admina_framework-0.12.2}/PKG-INFO +1 -1
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/__init__.py +1 -1
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/static/index.html +11 -3
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/forensic.py +21 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/engines/presidio.py +6 -1
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/dashboard.py +180 -29
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/gateway.py +165 -4
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/main.py +1 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2/admina_framework.egg-info}/PKG-INFO +1 -1
- {admina_framework-0.12.1 → admina_framework-0.12.2}/pyproject.toml +1 -1
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_forensic_blackbox.py +41 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_endpoints.py +331 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_mount.py +71 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_presidio_engine.py +27 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/LICENSE +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/NOTICE +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/README.md +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/commands/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/main.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/admina.yaml.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/docker-compose.yml.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/env.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/main.py.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin.py.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin_pyproject.toml.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin_readme.md.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/cli/templates/plugin_test.py.j2 +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/config.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/event_bus.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/secrets.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/core/types.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/static/heimdall.png +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/dashboard/static/vendor/alpinejs.min.js +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/coordination.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/egress.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/fingerprint.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/firewall.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/agent_security/loop_breaker.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/llm_engine.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/rag.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/ai_infra/webui.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/cross_regulation.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/gdpr.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/nis2.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/oisg.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/compliance/otel.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/classification.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/pii.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/data_sovereignty/residency.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/domains/governance.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/engines/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/_engines.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/cheshirecat/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/cheshirecat/admina-plugin/admina_governance.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/crewai/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/crewai/callbacks.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/langchain/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/langchain/callbacks.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/n8n/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/integrations/openclaw/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/base.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/_streaming.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/anthropic.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/bedrock.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/gemini.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/mistral.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/ollama.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/openai.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/adapters/vllm.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/alerts/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/alerts/log.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/alerts/webhook.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/auth/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/auth/apikey.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/compliance/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/connectors/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/connectors/chromadb.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/connectors/filesystem.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/forensic/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/forensic/filesystem.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/guards/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/guards/guardrailsai_guard.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/pii/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/pii/spacy_regex.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/transports/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/transports/http_rest.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/builtin/transports/mcp.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/plugins/registry.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/api/integration.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/config.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/dashboard_session.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/engine_bridge.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/governance.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/multi_upstream.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/proxy/state.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/py.typed +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/baselines/baseline.json +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/SHA256SUMS +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/coordination.jsonl +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/injection.jsonl +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/loop.jsonl +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora/pii.jsonl +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/corpora.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/detectors.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/gate.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/metrics.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/report.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/redteam/runner.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/__init__.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/_compat.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/compliance_kit.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/errors.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/governed_agent.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/governed_data.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/governed_model.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/retry.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina/sdk/streaming.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/SOURCES.txt +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/dependency_links.txt +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/entry_points.txt +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/requires.txt +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/admina_framework.egg-info/top_level.txt +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/setup.cfg +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_benchmark_14us.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination_cli.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination_corpus.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_coordination_wiring.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_core_config.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_domains.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_domains_governance.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_analyze.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_cli.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_pipeline.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_policy.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_egress_surfaces.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_engines.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_error_disclosure.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_fingerprint.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_firewall_parity.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_config.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_guard_fail_mode.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_gateway_helpers.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_governance_pipeline.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_guard_fail_mode.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_mcp_transport.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_dashboard_session.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_forensic_would_action.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_governance_mode_config.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_guard_fail_mode.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_mcp_response_pii.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_metrics_endpoint.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_plugins.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_proxy_security.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_redteam_efficacy.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_redteam_lib.py +0 -0
- {admina_framework-0.12.1 → admina_framework-0.12.2}/tests/test_version.py +0 -0
|
@@ -735,12 +735,14 @@
|
|
|
735
735
|
</p>
|
|
736
736
|
<p>
|
|
737
737
|
Until you submit a real assessment, the dashboard renders all articles at 0% with a default
|
|
738
|
-
placeholder. To record your real coverage,
|
|
739
|
-
<code class="inline">/api/compliance/gap-analysis</code
|
|
738
|
+
placeholder. To record your real coverage, send your declared evidence with a
|
|
739
|
+
<strong>POST</strong> request to <code class="inline">/api/compliance/gap-analysis</code>
|
|
740
|
+
on this host (the route accepts POST only: opening it in the browser answers
|
|
741
|
+
405 Method Not Allowed).
|
|
740
742
|
</p>
|
|
741
743
|
<p style="margin-bottom: 6px;"><strong>Example request:</strong></p>
|
|
742
744
|
<button class="modal-copy-btn" @click="copyAssessmentCmd($event)">Copy command</button>
|
|
743
|
-
<pre class="modal-pre" id="assessment-cmd">curl -X POST
|
|
745
|
+
<pre class="modal-pre" id="assessment-cmd">curl -X POST <span x-text="apiBase"></span>/api/compliance/gap-analysis \
|
|
744
746
|
-H "X-API-Key: $ADMINA_API_KEY" -H "Content-Type: application/json" \
|
|
745
747
|
-d '{"risk_category": "high",
|
|
746
748
|
"current_compliance": {
|
|
@@ -1153,6 +1155,12 @@ function dashboard() {
|
|
|
1153
1155
|
},
|
|
1154
1156
|
|
|
1155
1157
|
// ── Helpers ─────────────────────────────────────────
|
|
1158
|
+
// The origin that served this page: the proxy itself, or the dashboard
|
|
1159
|
+
// container that forwards /api/ to it.
|
|
1160
|
+
get apiBase() {
|
|
1161
|
+
return window.location.origin;
|
|
1162
|
+
},
|
|
1163
|
+
|
|
1156
1164
|
copyAssessmentCmd(ev) {
|
|
1157
1165
|
const pre = document.getElementById('assessment-cmd');
|
|
1158
1166
|
if (!pre) return;
|
|
@@ -24,6 +24,7 @@ import logging
|
|
|
24
24
|
import os
|
|
25
25
|
import threading
|
|
26
26
|
import time
|
|
27
|
+
from collections import deque
|
|
27
28
|
from datetime import UTC, datetime
|
|
28
29
|
from pathlib import Path
|
|
29
30
|
|
|
@@ -35,6 +36,8 @@ logger = logging.getLogger("admina.forensic_blackbox")
|
|
|
35
36
|
_CHAIN_STATE_KEY = "_chain_state.json"
|
|
36
37
|
# Sidecar holding the HMAC-SHA256 signature of the chain-state payload.
|
|
37
38
|
_CHAIN_STATE_SIG_KEY = "_chain_state.json.sig"
|
|
39
|
+
# Records kept in memory for recent_records(), newest last.
|
|
40
|
+
RECENT_RECORDS_MAX = 1000
|
|
38
41
|
|
|
39
42
|
|
|
40
43
|
class ForensicBlackBox(BaseForensicStore):
|
|
@@ -81,6 +84,10 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
81
84
|
self.chain_head: str = "GENESIS"
|
|
82
85
|
self.record_count: int = 0
|
|
83
86
|
self._write_lock = threading.Lock()
|
|
87
|
+
# The last records written by this process, for readers that need
|
|
88
|
+
# recent activity without reading the backend back (the dashboard
|
|
89
|
+
# feed when no analytics store is configured).
|
|
90
|
+
self._recent: deque[dict] = deque(maxlen=RECENT_RECORDS_MAX)
|
|
84
91
|
self._state_signing_key = state_signing_key or os.environ.get("ADMINA_FORENSIC_STATE_KEY")
|
|
85
92
|
if self.filesystem_dir is not None:
|
|
86
93
|
self.filesystem_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -346,6 +353,7 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
346
353
|
# Store the record and persist the updated chain state
|
|
347
354
|
self._store_to_s3(forensic_record)
|
|
348
355
|
self._persist_chain_state()
|
|
356
|
+
self._recent.append(forensic_record)
|
|
349
357
|
|
|
350
358
|
return {
|
|
351
359
|
"sequence_number": self.record_count,
|
|
@@ -354,6 +362,19 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
354
362
|
"stored": (self.boto3_client is not None or self.filesystem_dir is not None),
|
|
355
363
|
}
|
|
356
364
|
|
|
365
|
+
def recent_records(self, limit: int = RECENT_RECORDS_MAX) -> list[dict]:
|
|
366
|
+
"""Return up to *limit* records written by this process, newest first.
|
|
367
|
+
|
|
368
|
+
Only the last ``RECENT_RECORDS_MAX`` records are kept, in memory, with
|
|
369
|
+
every backend; records written before the process started are not
|
|
370
|
+
read back. Use :meth:`verify_chain` for the persisted chain.
|
|
371
|
+
"""
|
|
372
|
+
if limit <= 0:
|
|
373
|
+
return []
|
|
374
|
+
with self._write_lock:
|
|
375
|
+
records = list(self._recent)
|
|
376
|
+
return records[::-1][:limit]
|
|
377
|
+
|
|
357
378
|
def _store_to_s3(self, record: dict):
|
|
358
379
|
"""Persist a forensic record using the configured backend."""
|
|
359
380
|
ts = datetime.now(UTC)
|
|
@@ -57,6 +57,9 @@ _PRESIDIO_TO_ADMINA: dict[str, str] = {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
# Languages Admina configures for Presidio, each mapped to its spaCy model.
|
|
60
|
+
# The Presidio entity types requested from the analyzer.
|
|
61
|
+
_ENTITIES: list[str] = list(_PRESIDIO_TO_ADMINA)
|
|
62
|
+
|
|
60
63
|
_LANG_MODELS: dict[str, str] = {
|
|
61
64
|
"en": "en_core_web_sm",
|
|
62
65
|
"it": "it_core_news_sm",
|
|
@@ -111,9 +114,11 @@ class PresidioPIIEngine:
|
|
|
111
114
|
|
|
112
115
|
# Union of per-language analyses: a recognizer registered for only one
|
|
113
116
|
# language is missed by the other pass, so both are run and merged.
|
|
117
|
+
# Only the mapped entity types are requested: the other recognizers
|
|
118
|
+
# (URL among them, slow on text with many dots) would be discarded.
|
|
114
119
|
raw = []
|
|
115
120
|
for lang in self.languages:
|
|
116
|
-
raw.extend(self._analyzer.analyze(text=text, language=lang))
|
|
121
|
+
raw.extend(self._analyzer.analyze(text=text, language=lang, entities=_ENTITIES))
|
|
117
122
|
|
|
118
123
|
spans: list[tuple[int, int, str, str]] = []
|
|
119
124
|
for r in raw:
|
|
@@ -27,7 +27,7 @@ import io as _io
|
|
|
27
27
|
import json
|
|
28
28
|
import logging
|
|
29
29
|
import time
|
|
30
|
-
from datetime import UTC, datetime
|
|
30
|
+
from datetime import UTC, datetime, timedelta
|
|
31
31
|
from typing import Any
|
|
32
32
|
|
|
33
33
|
from fastapi import APIRouter, Query, Response, WebSocket, WebSocketDisconnect
|
|
@@ -76,6 +76,122 @@ def _suggestions_to_csv(payload: dict) -> Response:
|
|
|
76
76
|
return Response(content=buf.getvalue(), media_type="text/csv")
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
# ── Recent forensic records (feed without ClickHouse) ─────────
|
|
80
|
+
# Forensic record types that stand for one governed request, as a ClickHouse
|
|
81
|
+
# governance_events row does. Other records (coordination verdicts, response
|
|
82
|
+
# guard errors, events posted to /api/v1/audit) are not feed events.
|
|
83
|
+
_FEED_EVENT_TYPES = frozenset({"mcp_request", "gateway_request"})
|
|
84
|
+
# The source named in feed, trend and suggestions answers built from them.
|
|
85
|
+
_FORENSIC_SOURCE = "forensic_recent"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _plain(value: Any) -> str:
|
|
89
|
+
"""*value* as a plain string (the value of an enum member)."""
|
|
90
|
+
return str(getattr(value, "value", value) or "")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_utc(value: Any) -> datetime | None:
|
|
94
|
+
"""An ISO 8601 timestamp as an aware UTC datetime; None if unreadable."""
|
|
95
|
+
try:
|
|
96
|
+
parsed = datetime.fromisoformat(str(value))
|
|
97
|
+
except (TypeError, ValueError):
|
|
98
|
+
return None
|
|
99
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _row_from_forensic(record: dict) -> dict[str, Any] | None:
|
|
103
|
+
"""A forensic record of a governed request in the shape of a ClickHouse
|
|
104
|
+
``governance_events`` row; None for any other record.
|
|
105
|
+
|
|
106
|
+
``details`` is the JSON of the record's governance checks, plus
|
|
107
|
+
``would_action`` when set, as in the ClickHouse row. Hashes are left
|
|
108
|
+
empty: the forensic record of a request does not carry them.
|
|
109
|
+
"""
|
|
110
|
+
event = record.get("event")
|
|
111
|
+
if not isinstance(event, dict) or _plain(event.get("event_type")) not in _FEED_EVENT_TYPES:
|
|
112
|
+
return None
|
|
113
|
+
details = dict(event.get("checks") or {})
|
|
114
|
+
if event.get("would_action") is not None:
|
|
115
|
+
details["would_action"] = _plain(event["would_action"])
|
|
116
|
+
return {
|
|
117
|
+
"event_id": str(event.get("event_id", "")),
|
|
118
|
+
"timestamp": str(record.get("timestamp_utc", "")),
|
|
119
|
+
"event_type": _plain(event.get("event_type")),
|
|
120
|
+
"agent_id": str(event.get("agent_id", "")),
|
|
121
|
+
"session_id": str(event.get("session_id", "")),
|
|
122
|
+
"method": str(event.get("method", "")),
|
|
123
|
+
"tool_name": str(event.get("tool_name", "")),
|
|
124
|
+
"action": _plain(event.get("action")).lower(),
|
|
125
|
+
"risk_level": _plain(event.get("risk_level")).lower(),
|
|
126
|
+
"details": json.dumps(details, default=str),
|
|
127
|
+
"latency_ms": event.get("governance_latency_ms", 0.0),
|
|
128
|
+
"request_hash": "",
|
|
129
|
+
"response_hash": "",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _recent_rows(forensic_box: Any) -> list[dict[str, Any]] | None:
|
|
134
|
+
"""The governed requests among the recent records of *forensic_box*,
|
|
135
|
+
newest first, as ``governance_events`` rows; None when the store keeps
|
|
136
|
+
no recent records."""
|
|
137
|
+
recent = getattr(forensic_box, "recent_records", None)
|
|
138
|
+
if not callable(recent):
|
|
139
|
+
return None
|
|
140
|
+
rows = []
|
|
141
|
+
for record in recent():
|
|
142
|
+
row = _row_from_forensic(record)
|
|
143
|
+
if row is not None:
|
|
144
|
+
rows.append(row)
|
|
145
|
+
return rows
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _rows_between(
|
|
149
|
+
rows: list[dict[str, Any]], start: datetime, end: datetime | None = None
|
|
150
|
+
) -> list[dict[str, Any]]:
|
|
151
|
+
"""The *rows* with a timestamp at or after *start* (and before *end*)."""
|
|
152
|
+
out = []
|
|
153
|
+
for row in rows:
|
|
154
|
+
ts = _parse_utc(row["timestamp"])
|
|
155
|
+
if ts is not None and ts >= start and (end is None or ts < end):
|
|
156
|
+
out.append(row)
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _trend_from_rows(
|
|
161
|
+
rows: list[dict[str, Any]], window_hours: int, bucket_minutes: int
|
|
162
|
+
) -> dict[str, Any]:
|
|
163
|
+
"""The ``/trend`` answer computed from ``governance_events`` rows: counts
|
|
164
|
+
per action in buckets of *bucket_minutes* (aligned to the Unix epoch, as
|
|
165
|
+
ClickHouse's ``toStartOfInterval``) over the last *window_hours*."""
|
|
166
|
+
bucket_s = bucket_minutes * 60
|
|
167
|
+
start = datetime.now(UTC) - timedelta(hours=window_hours)
|
|
168
|
+
buckets: dict[datetime, dict[str, int]] = {}
|
|
169
|
+
for row in _rows_between(rows, start):
|
|
170
|
+
ts = _parse_utc(row["timestamp"])
|
|
171
|
+
if ts is None:
|
|
172
|
+
continue
|
|
173
|
+
key = datetime.fromtimestamp(int(ts.timestamp()) // bucket_s * bucket_s, UTC)
|
|
174
|
+
counts = buckets.setdefault(key, {})
|
|
175
|
+
counts[row["action"]] = counts.get(row["action"], 0) + 1
|
|
176
|
+
series = [
|
|
177
|
+
{
|
|
178
|
+
"ts": key.isoformat(),
|
|
179
|
+
"allow": counts.get("allow", 0),
|
|
180
|
+
"block": counts.get("block", 0),
|
|
181
|
+
"circuit_break": counts.get("circuit_break", 0),
|
|
182
|
+
"redact": counts.get("redact", 0),
|
|
183
|
+
"total": sum(counts.values()),
|
|
184
|
+
}
|
|
185
|
+
for key, counts in sorted(buckets.items())
|
|
186
|
+
]
|
|
187
|
+
return {
|
|
188
|
+
"window_hours": window_hours,
|
|
189
|
+
"bucket_minutes": bucket_minutes,
|
|
190
|
+
"bucket_count": len(series),
|
|
191
|
+
"buckets": series,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
79
195
|
# ── Governance score ─────────────────────────────────────────
|
|
80
196
|
def _compute_governance_score(
|
|
81
197
|
*,
|
|
@@ -295,10 +411,19 @@ def create_dashboard_endpoints(
|
|
|
295
411
|
limit: int = Query(50, ge=1, le=1000),
|
|
296
412
|
offset: int = Query(0, ge=0),
|
|
297
413
|
) -> dict[str, Any]:
|
|
298
|
-
"""Recent governance events (paginated).
|
|
414
|
+
"""Recent governance events (paginated).
|
|
415
|
+
|
|
416
|
+
Without ClickHouse the events are the governed requests among the
|
|
417
|
+
recent records of the forensic store, newest first (records written
|
|
418
|
+
since the proxy started, up to the store's in-memory limit).
|
|
419
|
+
"""
|
|
299
420
|
ch = get_clickhouse()
|
|
300
421
|
if not ch:
|
|
301
|
-
|
|
422
|
+
rows = _recent_rows(get_forensic_box())
|
|
423
|
+
if rows is None:
|
|
424
|
+
return {"events": [], "count": 0, "error": "ClickHouse not available"}
|
|
425
|
+
events = rows[offset : offset + limit]
|
|
426
|
+
return {"events": events, "count": len(events), "source": _FORENSIC_SOURCE}
|
|
302
427
|
try:
|
|
303
428
|
loop = asyncio.get_running_loop()
|
|
304
429
|
db = get_settings().CLICKHOUSE_DB
|
|
@@ -583,7 +708,8 @@ def create_dashboard_endpoints(
|
|
|
583
708
|
min_count: skip categories with fewer than this many events.
|
|
584
709
|
"""
|
|
585
710
|
ch = get_clickhouse()
|
|
586
|
-
if
|
|
711
|
+
recent = None if ch else _recent_rows(get_forensic_box())
|
|
712
|
+
if not ch and recent is None:
|
|
587
713
|
return {
|
|
588
714
|
"window_hours": window_hours,
|
|
589
715
|
"events_analyzed": 0,
|
|
@@ -595,19 +721,28 @@ def create_dashboard_endpoints(
|
|
|
595
721
|
loop = asyncio.get_running_loop()
|
|
596
722
|
db = get_settings().CLICKHOUSE_DB
|
|
597
723
|
mode = getattr(get_settings(), "GOVERNANCE_MODE", "enforce")
|
|
724
|
+
now = datetime.now(UTC)
|
|
725
|
+
window = timedelta(hours=window_hours)
|
|
598
726
|
|
|
599
727
|
# Aggregate by action + risk + a flat category extracted from details.
|
|
600
728
|
# Schema: (event_id, timestamp, event_type, agent_id, session_id,
|
|
601
729
|
# method, tool_name, action, risk_level, details, ...)
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
730
|
+
if ch:
|
|
731
|
+
query = (
|
|
732
|
+
f"SELECT action, risk_level, details "
|
|
733
|
+
f"FROM {db}.governance_events "
|
|
734
|
+
f"WHERE timestamp >= now() - INTERVAL {int(window_hours)} HOUR"
|
|
735
|
+
)
|
|
736
|
+
async with _ch_lock:
|
|
737
|
+
result = await loop.run_in_executor(None, lambda: ch.query(query))
|
|
738
|
+
window_rows = list(result.result_rows)
|
|
739
|
+
else:
|
|
740
|
+
window_rows = [
|
|
741
|
+
(r["action"], r["risk_level"], r["details"])
|
|
742
|
+
for r in _rows_between(recent, now - window)
|
|
743
|
+
]
|
|
744
|
+
|
|
745
|
+
total = len(window_rows)
|
|
611
746
|
if total == 0:
|
|
612
747
|
return {
|
|
613
748
|
"window_hours": window_hours,
|
|
@@ -630,7 +765,7 @@ def create_dashboard_endpoints(
|
|
|
630
765
|
cat_total: dict[str, int] = {}
|
|
631
766
|
cat_blocked: dict[str, int] = {}
|
|
632
767
|
cat_would_blocked: dict[str, int] = {}
|
|
633
|
-
for row in
|
|
768
|
+
for row in window_rows:
|
|
634
769
|
action, _risk, details_raw = row[0], row[1], row[2]
|
|
635
770
|
# Action is stored lowercase in ClickHouse — normalise once.
|
|
636
771
|
action_upper = (action or "").upper()
|
|
@@ -766,19 +901,27 @@ def create_dashboard_endpoints(
|
|
|
766
901
|
# immediately-preceding equal window. If a category's blocked
|
|
767
902
|
# rate has surged ≥2x with absolute count ≥ min_count, flag it.
|
|
768
903
|
try:
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
async with _ch_lock:
|
|
776
|
-
prev_res = await loop.run_in_executor(
|
|
777
|
-
None,
|
|
778
|
-
lambda: ch.query(prev_query),
|
|
904
|
+
if ch:
|
|
905
|
+
prev_query = (
|
|
906
|
+
f"SELECT details FROM {db}.governance_events "
|
|
907
|
+
f"WHERE timestamp >= now() - INTERVAL {int(window_hours * 2)} HOUR "
|
|
908
|
+
f" AND timestamp < now() - INTERVAL {int(window_hours)} HOUR "
|
|
909
|
+
f" AND lower(action) IN ('block','circuit_break')"
|
|
779
910
|
)
|
|
911
|
+
async with _ch_lock:
|
|
912
|
+
prev_res = await loop.run_in_executor(
|
|
913
|
+
None,
|
|
914
|
+
lambda: ch.query(prev_query),
|
|
915
|
+
)
|
|
916
|
+
prev_rows = list(prev_res.result_rows)
|
|
917
|
+
else:
|
|
918
|
+
prev_rows = [
|
|
919
|
+
(r["details"],)
|
|
920
|
+
for r in _rows_between(recent, now - 2 * window, now - window)
|
|
921
|
+
if r["action"] in ("block", "circuit_break")
|
|
922
|
+
]
|
|
780
923
|
prev_blocked: dict[str, int] = {}
|
|
781
|
-
for (det_raw,) in
|
|
924
|
+
for (det_raw,) in prev_rows:
|
|
782
925
|
try:
|
|
783
926
|
d = json.loads(det_raw) if det_raw else {}
|
|
784
927
|
except (TypeError, ValueError):
|
|
@@ -848,6 +991,8 @@ def create_dashboard_endpoints(
|
|
|
848
991
|
"would_blocked_by_category": cat_would_blocked,
|
|
849
992
|
"suggestions": suggestions,
|
|
850
993
|
}
|
|
994
|
+
if not ch:
|
|
995
|
+
payload["source"] = _FORENSIC_SOURCE
|
|
851
996
|
if format == "csv":
|
|
852
997
|
return _suggestions_to_csv(payload)
|
|
853
998
|
return payload
|
|
@@ -878,11 +1023,17 @@ def create_dashboard_endpoints(
|
|
|
878
1023
|
"""
|
|
879
1024
|
ch = get_clickhouse()
|
|
880
1025
|
if not ch:
|
|
1026
|
+
recent = _recent_rows(get_forensic_box())
|
|
1027
|
+
if recent is None:
|
|
1028
|
+
return {
|
|
1029
|
+
"window_hours": window_hours,
|
|
1030
|
+
"bucket_minutes": bucket_minutes,
|
|
1031
|
+
"buckets": [],
|
|
1032
|
+
"error": "ClickHouse not available",
|
|
1033
|
+
}
|
|
881
1034
|
return {
|
|
882
|
-
|
|
883
|
-
"
|
|
884
|
-
"buckets": [],
|
|
885
|
-
"error": "ClickHouse not available",
|
|
1035
|
+
**_trend_from_rows(recent, window_hours, bucket_minutes),
|
|
1036
|
+
"source": _FORENSIC_SOURCE,
|
|
886
1037
|
}
|
|
887
1038
|
try:
|
|
888
1039
|
loop = asyncio.get_running_loop()
|
|
@@ -28,20 +28,152 @@ Routes (prefix /v1):
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
30
|
import asyncio
|
|
31
|
+
import hashlib
|
|
31
32
|
import json
|
|
33
|
+
import logging
|
|
32
34
|
import re
|
|
33
35
|
import time
|
|
34
36
|
import uuid
|
|
35
|
-
from collections.abc import AsyncIterator
|
|
37
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
38
|
+
from datetime import UTC, datetime
|
|
36
39
|
from typing import Any
|
|
37
40
|
|
|
38
41
|
import httpx
|
|
39
42
|
from fastapi import APIRouter, HTTPException, Request
|
|
40
43
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
41
44
|
|
|
42
|
-
from admina.core.
|
|
45
|
+
from admina.core.event_bus import GovernanceEvent as BusGovernanceEvent
|
|
46
|
+
from admina.core.event_bus import bus as governance_bus
|
|
47
|
+
from admina.core.types import EventType, GovernanceAction, RiskLevel
|
|
43
48
|
from admina.domains.agent_security.egress import resolve_egress_mode
|
|
44
|
-
from admina.domains.governance import
|
|
49
|
+
from admina.domains.governance import (
|
|
50
|
+
build_governance_details,
|
|
51
|
+
redact_response_result,
|
|
52
|
+
run_pipeline,
|
|
53
|
+
safe_serialize,
|
|
54
|
+
)
|
|
55
|
+
from admina.proxy.config import GovernanceEvent
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
# Strong references to fire-and-forget tasks, so they are not
|
|
60
|
+
# garbage-collected before completion (see asyncio.create_task docs).
|
|
61
|
+
_background_tasks: set[asyncio.Task] = set()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _spawn(coro: Any) -> asyncio.Task:
|
|
65
|
+
"""Schedule *coro* as a background task and keep a strong ref."""
|
|
66
|
+
task = asyncio.create_task(coro)
|
|
67
|
+
_background_tasks.add(task)
|
|
68
|
+
task.add_done_callback(_background_tasks.discard)
|
|
69
|
+
return task
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _decision_metadata(gov_response: Any) -> dict:
|
|
73
|
+
"""Event-bus metadata for a gateway decision.
|
|
74
|
+
|
|
75
|
+
The same fields the /mcp surface publishes, minus ``content``: the
|
|
76
|
+
(redacted) request body never leaves the request on the bus, so live
|
|
77
|
+
subscribers (dashboard feed, alerts, OTel) see the decision only.
|
|
78
|
+
"""
|
|
79
|
+
meta = gov_response.to_dict()
|
|
80
|
+
meta.pop("content", None)
|
|
81
|
+
return meta
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _emit_decision_safe(event: BusGovernanceEvent) -> None:
|
|
85
|
+
"""Emit *event* on the governance bus; a failing subscriber is logged
|
|
86
|
+
and never propagates to the request."""
|
|
87
|
+
try:
|
|
88
|
+
await governance_bus.emit(event)
|
|
89
|
+
except Exception:
|
|
90
|
+
logger.warning("Gateway governance event emission failed", exc_info=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _emit_decision(gov_response: Any, session_id: str) -> None:
|
|
94
|
+
"""Publish one GOVERNANCE_DECISION event for a gateway request,
|
|
95
|
+
fire-and-forget: it adds no latency and cannot fail the request."""
|
|
96
|
+
_spawn(
|
|
97
|
+
_emit_decision_safe(
|
|
98
|
+
BusGovernanceEvent(
|
|
99
|
+
event_type=EventType.GOVERNANCE_DECISION,
|
|
100
|
+
session_id=session_id,
|
|
101
|
+
action=gov_response.action,
|
|
102
|
+
risk_level=gov_response.risk_level,
|
|
103
|
+
domain="gateway",
|
|
104
|
+
metadata=_decision_metadata(gov_response),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _count_decision(state: Any, action: str, pii_count: int) -> None:
|
|
111
|
+
"""Count a governed gateway request in the proxy counters (``/api/stats``
|
|
112
|
+
and ``/metrics``), as ``/mcp`` does: ``requests_total``, then
|
|
113
|
+
``requests_blocked`` for BLOCK and CIRCUIT_BREAK or ``requests_allowed``
|
|
114
|
+
otherwise, and ``requests_redacted`` when PII was redacted."""
|
|
115
|
+
inc = getattr(state, "inc_metric", None)
|
|
116
|
+
if inc is None:
|
|
117
|
+
return
|
|
118
|
+
inc("requests_total")
|
|
119
|
+
if action in ("BLOCK", "CIRCUIT_BREAK"):
|
|
120
|
+
inc("requests_blocked")
|
|
121
|
+
else:
|
|
122
|
+
inc("requests_allowed")
|
|
123
|
+
if pii_count > 0:
|
|
124
|
+
inc("requests_redacted")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _observe_latency(state: Any, start: float) -> None:
|
|
128
|
+
"""Add the time since *start* (``time.perf_counter()``) to the proxy's
|
|
129
|
+
average request latency."""
|
|
130
|
+
update = getattr(state, "update_avg_latency", None)
|
|
131
|
+
if update is not None:
|
|
132
|
+
update((time.perf_counter() - start) * 1000)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _enum_value(enum_cls: Any, value: Any, default: Any) -> Any:
|
|
136
|
+
"""*value* (an enum member or its name or value, any case) as a member
|
|
137
|
+
of *enum_cls*; *default* when it is none of them."""
|
|
138
|
+
raw = str(getattr(value, "value", value) or "").lower()
|
|
139
|
+
try:
|
|
140
|
+
return enum_cls(raw)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return default
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _analytics_event(
|
|
146
|
+
pre: Any, *, event_id: str, agent_id: str, session_id: str, prompt_text: str
|
|
147
|
+
) -> GovernanceEvent:
|
|
148
|
+
"""The analytics row (ClickHouse ``governance_events``) of a gateway
|
|
149
|
+
request: the same columns as an ``/mcp`` row, with event type
|
|
150
|
+
``gateway_request`` and method ``chat.completions``. Like the ``/mcp``
|
|
151
|
+
row it holds the governance checks and a truncated hash of the
|
|
152
|
+
prompt, never the prompt itself."""
|
|
153
|
+
return GovernanceEvent(
|
|
154
|
+
event_id=event_id,
|
|
155
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
156
|
+
event_type=EventType.GATEWAY_REQUEST,
|
|
157
|
+
agent_id=agent_id,
|
|
158
|
+
session_id=session_id,
|
|
159
|
+
method="chat.completions",
|
|
160
|
+
action=_enum_value(GovernanceAction, pre.action, GovernanceAction.ALLOW),
|
|
161
|
+
risk_level=_enum_value(RiskLevel, pre.risk_level, RiskLevel.LOW),
|
|
162
|
+
details=build_governance_details(pre),
|
|
163
|
+
latency_ms=pre.latency_ms,
|
|
164
|
+
request_hash=hashlib.sha256(prompt_text.encode()).hexdigest()[:32],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _store_event_safe(
|
|
169
|
+
store_event: Callable[[GovernanceEvent], Awaitable[None]], event: GovernanceEvent
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Store *event* with *store_event*; a failure is logged and never
|
|
172
|
+
reaches the request."""
|
|
173
|
+
try:
|
|
174
|
+
await store_event(event)
|
|
175
|
+
except Exception:
|
|
176
|
+
logger.warning("Gateway analytics event storage failed", exc_info=True)
|
|
45
177
|
|
|
46
178
|
|
|
47
179
|
def _extract_prompt_text(messages: list) -> str:
|
|
@@ -262,6 +394,7 @@ def create_gateway_endpoints(
|
|
|
262
394
|
*,
|
|
263
395
|
get_state: Any,
|
|
264
396
|
get_settings: Any,
|
|
397
|
+
store_event: Callable[[GovernanceEvent], Awaitable[None]] | None = None,
|
|
265
398
|
) -> APIRouter:
|
|
266
399
|
"""Create the OpenAI-compatible gateway router (prefix ``/v1``).
|
|
267
400
|
|
|
@@ -273,6 +406,9 @@ def create_gateway_endpoints(
|
|
|
273
406
|
loop_breaker, egress_policy, governance_guards, forensic_box,
|
|
274
407
|
http_client).
|
|
275
408
|
get_settings: Callable returning the settings object.
|
|
409
|
+
store_event: Coroutine function storing a :class:`GovernanceEvent`
|
|
410
|
+
in the analytics store; called, fire-and-forget, for every
|
|
411
|
+
governed request while the state has a ``clickhouse`` client.
|
|
276
412
|
"""
|
|
277
413
|
router = APIRouter(prefix="/v1", tags=["gateway"])
|
|
278
414
|
|
|
@@ -307,6 +443,7 @@ def create_gateway_endpoints(
|
|
|
307
443
|
agent_id = re.sub(r"[\r\n]", "", request.headers.get("X-Agent-Id", "gateway"))[:128]
|
|
308
444
|
prompt_text = _extract_prompt_text(messages)
|
|
309
445
|
event_id = uuid.uuid4().hex
|
|
446
|
+
start = time.perf_counter()
|
|
310
447
|
|
|
311
448
|
pre = await run_pipeline(
|
|
312
449
|
body={"params": {"messages": messages}},
|
|
@@ -328,6 +465,8 @@ def create_gateway_endpoints(
|
|
|
328
465
|
egress_mode=resolve_egress_mode(cfg.GOVERNANCE_MODE),
|
|
329
466
|
)
|
|
330
467
|
action = pre.gov_response.action # uppercase: ALLOW/BLOCK/CIRCUIT_BREAK
|
|
468
|
+
pii_count = pre.checks.get("pii_redaction", {}).get("count", 0)
|
|
469
|
+
_count_decision(state, action, pii_count)
|
|
331
470
|
|
|
332
471
|
await _record_forensic(
|
|
333
472
|
state.forensic_box,
|
|
@@ -338,9 +477,24 @@ def create_gateway_endpoints(
|
|
|
338
477
|
risk_level=pre.gov_response.risk_level,
|
|
339
478
|
pre=pre,
|
|
340
479
|
)
|
|
480
|
+
if store_event is not None and getattr(state, "clickhouse", None):
|
|
481
|
+
_spawn(
|
|
482
|
+
_store_event_safe(
|
|
483
|
+
store_event,
|
|
484
|
+
_analytics_event(
|
|
485
|
+
pre,
|
|
486
|
+
event_id=event_id,
|
|
487
|
+
agent_id=agent_id,
|
|
488
|
+
session_id=session_id,
|
|
489
|
+
prompt_text=prompt_text,
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
)
|
|
341
493
|
|
|
342
494
|
block_message = cfg.ADMINA_GATEWAY_BLOCK_MESSAGE
|
|
343
495
|
if action in ("BLOCK", "CIRCUIT_BREAK"):
|
|
496
|
+
_emit_decision(pre.gov_response, session_id)
|
|
497
|
+
_observe_latency(state, start)
|
|
344
498
|
if stream:
|
|
345
499
|
return StreamingResponse(
|
|
346
500
|
_aiter_list(_synthetic_stream(model, block_message)),
|
|
@@ -352,7 +506,6 @@ def create_gateway_endpoints(
|
|
|
352
506
|
url = f"{upstream}/chat/completions"
|
|
353
507
|
headers = {"X-Admina-Event-Id": event_id}
|
|
354
508
|
|
|
355
|
-
pii_count = pre.checks.get("pii_redaction", {}).get("count", 0)
|
|
356
509
|
fwd_messages = pre.redacted_body["params"]["messages"] if pii_count > 0 else messages
|
|
357
510
|
forward_body = {**body, "messages": fwd_messages}
|
|
358
511
|
|
|
@@ -378,6 +531,7 @@ def create_gateway_endpoints(
|
|
|
378
531
|
try:
|
|
379
532
|
upstream_resp = await stream_cm.__aenter__()
|
|
380
533
|
except httpx.ConnectError:
|
|
534
|
+
_emit_decision(pre.gov_response, session_id)
|
|
381
535
|
raise HTTPException(status_code=502, detail="Gateway upstream unreachable")
|
|
382
536
|
|
|
383
537
|
async def _proxy() -> AsyncIterator[str]:
|
|
@@ -387,15 +541,22 @@ def create_gateway_endpoints(
|
|
|
387
541
|
):
|
|
388
542
|
yield sse
|
|
389
543
|
finally:
|
|
544
|
+
# One decision event per stream, at completion (or
|
|
545
|
+
# client disconnect), never per chunk.
|
|
546
|
+
_emit_decision(pre.gov_response, session_id)
|
|
547
|
+
_observe_latency(state, start)
|
|
390
548
|
await stream_cm.__aexit__(None, None, None)
|
|
391
549
|
|
|
392
550
|
return StreamingResponse(_proxy(), media_type="text/event-stream")
|
|
393
551
|
|
|
394
552
|
# non-streaming passthrough
|
|
553
|
+
_emit_decision(pre.gov_response, session_id)
|
|
395
554
|
try:
|
|
396
555
|
resp = await state.http_client.post(url, json=forward_body, headers=headers)
|
|
397
556
|
except httpx.ConnectError:
|
|
398
557
|
raise HTTPException(status_code=502, detail="Gateway upstream unreachable")
|
|
558
|
+
finally:
|
|
559
|
+
_observe_latency(state, start)
|
|
399
560
|
data = resp.json()
|
|
400
561
|
if cfg.PII_REDACTION_ENABLED and isinstance(data.get("choices"), list):
|
|
401
562
|
data["choices"], _ = redact_response_result(data["choices"], state.pii_redactor)
|
|
@@ -561,6 +561,7 @@ app.include_router(_integration_router)
|
|
|
561
561
|
_gateway_router = create_gateway_endpoints(
|
|
562
562
|
get_state=lambda: app.state.proxy,
|
|
563
563
|
get_settings=lambda: settings,
|
|
564
|
+
store_event=lambda event: _store_event_async(app.state.proxy.clickhouse, event),
|
|
564
565
|
)
|
|
565
566
|
app.include_router(_gateway_router)
|
|
566
567
|
|