admina-framework 0.10.1__tar.gz → 0.11.1__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.10.1/admina_framework.egg-info → admina_framework-0.11.1}/PKG-INFO +15 -6
- {admina_framework-0.10.1 → admina_framework-0.11.1}/README.md +9 -2
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/__init__.py +1 -1
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/core/types.py +1 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/forensic.py +87 -6
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/governance.py +26 -1
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/engines/__init__.py +16 -6
- admina_framework-0.11.1/admina/engines/presidio.py +181 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/cheshirecat/admina-plugin/admina_governance.py +3 -3
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/base.py +24 -0
- admina_framework-0.11.1/admina/plugins/builtin/adapters/_streaming.py +49 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/anthropic.py +36 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/ollama.py +24 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/openai.py +33 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/vllm.py +16 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/forensic/filesystem.py +68 -25
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/api/dashboard.py +7 -2
- admina_framework-0.11.1/admina/proxy/api/gateway.py +400 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/api/integration.py +25 -6
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/config.py +28 -1
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/main.py +48 -1
- admina_framework-0.11.1/admina/redteam/__init__.py +72 -0
- admina_framework-0.11.1/admina/redteam/baselines/baseline.json +45 -0
- admina_framework-0.11.1/admina/redteam/corpora/SHA256SUMS +3 -0
- admina_framework-0.11.1/admina/redteam/corpora/injection.jsonl +64 -0
- admina_framework-0.11.1/admina/redteam/corpora/loop.jsonl +22 -0
- admina_framework-0.11.1/admina/redteam/corpora/pii.jsonl +42 -0
- admina_framework-0.11.1/admina/redteam/corpora.py +52 -0
- admina_framework-0.11.1/admina/redteam/detectors.py +177 -0
- admina_framework-0.11.1/admina/redteam/gate.py +130 -0
- admina_framework-0.11.1/admina/redteam/metrics.py +73 -0
- admina_framework-0.11.1/admina/redteam/report.py +165 -0
- admina_framework-0.11.1/admina/redteam/runner.py +78 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/__init__.py +2 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/governed_model.py +154 -0
- admina_framework-0.11.1/admina/sdk/streaming.py +81 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1/admina_framework.egg-info}/PKG-INFO +15 -6
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina_framework.egg-info/SOURCES.txt +29 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina_framework.egg-info/requires.txt +6 -3
- {admina_framework-0.10.1 → admina_framework-0.11.1}/pyproject.toml +15 -7
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_engines.py +29 -0
- admina_framework-0.11.1/tests/test_error_disclosure.py +72 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_forensic_blackbox.py +152 -0
- admina_framework-0.11.1/tests/test_gateway_config.py +37 -0
- admina_framework-0.11.1/tests/test_gateway_endpoints.py +441 -0
- admina_framework-0.11.1/tests/test_gateway_guard_fail_mode.py +175 -0
- admina_framework-0.11.1/tests/test_gateway_helpers.py +209 -0
- admina_framework-0.11.1/tests/test_gateway_mount.py +117 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_governance_pipeline.py +29 -0
- admina_framework-0.11.1/tests/test_guard_fail_mode.py +42 -0
- admina_framework-0.11.1/tests/test_presidio_engine.py +69 -0
- admina_framework-0.11.1/tests/test_proxy_forensic_would_action.py +145 -0
- admina_framework-0.11.1/tests/test_proxy_governance_mode_config.py +56 -0
- admina_framework-0.11.1/tests/test_proxy_guard_fail_mode.py +205 -0
- admina_framework-0.11.1/tests/test_redteam_efficacy.py +54 -0
- admina_framework-0.11.1/tests/test_redteam_lib.py +532 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/LICENSE +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/NOTICE +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/commands/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/main.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/admina.yaml.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/docker-compose.yml.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/env.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/main.py.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/plugin.py.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/plugin_pyproject.toml.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/plugin_readme.md.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/cli/templates/plugin_test.py.j2 +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/core/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/core/config.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/core/event_bus.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/core/secrets.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/dashboard/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/dashboard/static/heimdall.png +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/dashboard/static/index.html +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/dashboard/static/vendor/alpinejs.min.js +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/agent_security/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/agent_security/firewall.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/agent_security/loop_breaker.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/ai_infra/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/ai_infra/llm_engine.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/ai_infra/rag.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/ai_infra/webui.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/cross_regulation.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/gdpr.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/nis2.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/oisg.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/compliance/otel.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/data_sovereignty/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/data_sovereignty/classification.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/data_sovereignty/pii.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/domains/data_sovereignty/residency.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/_engines.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/cheshirecat/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/crewai/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/crewai/callbacks.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/langchain/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/langchain/callbacks.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/n8n/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/integrations/openclaw/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/bedrock.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/gemini.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/adapters/mistral.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/alerts/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/alerts/log.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/alerts/webhook.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/auth/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/auth/apikey.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/compliance/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/compliance/eu_ai_act.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/connectors/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/connectors/chromadb.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/connectors/filesystem.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/forensic/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/guards/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/guards/guardrailsai_guard.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/pii/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/pii/spacy_regex.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/transports/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/transports/http_rest.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/builtin/transports/mcp.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/plugins/registry.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/api/__init__.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/engine_bridge.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/governance.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/multi_upstream.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/proxy/state.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/py.typed +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/_compat.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/compliance_kit.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/errors.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/governed_agent.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/governed_data.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina/sdk/retry.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina_framework.egg-info/dependency_links.txt +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina_framework.egg-info/entry_points.txt +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/admina_framework.egg-info/top_level.txt +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/setup.cfg +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_benchmark_14us.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_core_config.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_domains.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_domains_governance.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_firewall_parity.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_mcp_transport.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_proxy_mcp_response_pii.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_proxy_plugins.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_proxy_security.py +0 -0
- {admina_framework-0.10.1 → admina_framework-0.11.1}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: admina-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
4
4
|
Summary: Admina — governed AI development framework
|
|
5
5
|
Author-email: Stefano Noferi <info@admina.org>
|
|
6
6
|
Maintainer-email: Stefano Noferi <info@admina.org>
|
|
@@ -30,7 +30,7 @@ License-File: NOTICE
|
|
|
30
30
|
Requires-Dist: pyyaml<7,>=6.0
|
|
31
31
|
Requires-Dist: click<9,>=8.1
|
|
32
32
|
Requires-Dist: jinja2<4,>=3.1
|
|
33
|
-
Requires-Dist: cryptography<
|
|
33
|
+
Requires-Dist: cryptography<51,>=41.0
|
|
34
34
|
Provides-Extra: proxy
|
|
35
35
|
Requires-Dist: fastapi<1,>=0.104; extra == "proxy"
|
|
36
36
|
Requires-Dist: uvicorn[standard]<1,>=0.24; extra == "proxy"
|
|
@@ -52,7 +52,7 @@ Requires-Dist: opentelemetry-sdk<2,>=1.20; extra == "telemetry"
|
|
|
52
52
|
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc<2,>=1.20; extra == "telemetry"
|
|
53
53
|
Requires-Dist: opentelemetry-instrumentation-fastapi<1,>=0.40b0; extra == "telemetry"
|
|
54
54
|
Provides-Extra: rust
|
|
55
|
-
Requires-Dist: admina-core<0.
|
|
55
|
+
Requires-Dist: admina-core<0.12,>=0.9.3; extra == "rust"
|
|
56
56
|
Provides-Extra: full
|
|
57
57
|
Requires-Dist: admina-framework[nlp,proxy,telemetry]; extra == "full"
|
|
58
58
|
Provides-Extra: openai
|
|
@@ -69,8 +69,10 @@ Provides-Extra: bedrock
|
|
|
69
69
|
Requires-Dist: boto3<2,>=1.34; extra == "bedrock"
|
|
70
70
|
Provides-Extra: adapters
|
|
71
71
|
Requires-Dist: admina-framework[anthropic,bedrock,gemini,mistral,ollama,openai]; extra == "adapters"
|
|
72
|
+
Provides-Extra: presidio
|
|
73
|
+
Requires-Dist: presidio-analyzer<3,>=2.2; extra == "presidio"
|
|
72
74
|
Provides-Extra: all
|
|
73
|
-
Requires-Dist: admina-framework[adapters,nlp,proxy,telemetry]; extra == "all"
|
|
75
|
+
Requires-Dist: admina-framework[adapters,nlp,presidio,proxy,telemetry]; extra == "all"
|
|
74
76
|
Dynamic: license-file
|
|
75
77
|
|
|
76
78
|
<!--
|
|
@@ -329,16 +331,23 @@ from admina import GovernedModel, GovernedData, GovernedAgent, ComplianceKit
|
|
|
329
331
|
|
|
330
332
|
| Interface | Builtin implementations |
|
|
331
333
|
|-----------|------------------------|
|
|
332
|
-
| Model Adapter | Ollama, OpenAI |
|
|
334
|
+
| Model Adapter | Ollama, OpenAI, Anthropic, Gemini, Mistral, Bedrock, vLLM |
|
|
333
335
|
| Data Connector | ChromaDB, Filesystem |
|
|
334
336
|
| Governance Domain | GuardrailsAI (toxic, jailbreak, bias, PII) |
|
|
335
337
|
| Compliance Template | EU AI Act |
|
|
336
338
|
| Transport Adapter | MCP, HTTP REST |
|
|
337
339
|
| Forensic Store | Filesystem, S3-compatible (boto3 — AWS S3, MinIO, R2, …) |
|
|
338
340
|
| Auth Provider | API Key |
|
|
339
|
-
| PII Engine | spaCy + Regex |
|
|
341
|
+
| PII Engine | spaCy + Regex (default), Microsoft Presidio (`pip install admina-framework[presidio]`, `ADMINA_PII_ENGINE=presidio`) |
|
|
340
342
|
| Alert Channel | Log, Webhook |
|
|
341
343
|
|
|
344
|
+
Model adapters lazy-import their provider SDK, so install the ones you
|
|
345
|
+
use: `pip install admina-framework[adapters]` for all of them, or a
|
|
346
|
+
single provider (`[openai]`, `[ollama]`, `[anthropic]`, `[mistral]`,
|
|
347
|
+
`[gemini]`, `[bedrock]`). vLLM has no extra of its own — it serves an
|
|
348
|
+
OpenAI-compatible API and subclasses the OpenAI adapter, so install
|
|
349
|
+
`[openai]` for it.
|
|
350
|
+
|
|
342
351
|
```bash
|
|
343
352
|
admina plugin list # List registered plugins
|
|
344
353
|
admina plugin install ./my-plugin # Install a custom plugin
|
|
@@ -254,16 +254,23 @@ from admina import GovernedModel, GovernedData, GovernedAgent, ComplianceKit
|
|
|
254
254
|
|
|
255
255
|
| Interface | Builtin implementations |
|
|
256
256
|
|-----------|------------------------|
|
|
257
|
-
| Model Adapter | Ollama, OpenAI |
|
|
257
|
+
| Model Adapter | Ollama, OpenAI, Anthropic, Gemini, Mistral, Bedrock, vLLM |
|
|
258
258
|
| Data Connector | ChromaDB, Filesystem |
|
|
259
259
|
| Governance Domain | GuardrailsAI (toxic, jailbreak, bias, PII) |
|
|
260
260
|
| Compliance Template | EU AI Act |
|
|
261
261
|
| Transport Adapter | MCP, HTTP REST |
|
|
262
262
|
| Forensic Store | Filesystem, S3-compatible (boto3 — AWS S3, MinIO, R2, …) |
|
|
263
263
|
| Auth Provider | API Key |
|
|
264
|
-
| PII Engine | spaCy + Regex |
|
|
264
|
+
| PII Engine | spaCy + Regex (default), Microsoft Presidio (`pip install admina-framework[presidio]`, `ADMINA_PII_ENGINE=presidio`) |
|
|
265
265
|
| Alert Channel | Log, Webhook |
|
|
266
266
|
|
|
267
|
+
Model adapters lazy-import their provider SDK, so install the ones you
|
|
268
|
+
use: `pip install admina-framework[adapters]` for all of them, or a
|
|
269
|
+
single provider (`[openai]`, `[ollama]`, `[anthropic]`, `[mistral]`,
|
|
270
|
+
`[gemini]`, `[bedrock]`). vLLM has no extra of its own — it serves an
|
|
271
|
+
OpenAI-compatible API and subclasses the OpenAI adapter, so install
|
|
272
|
+
`[openai]` for it.
|
|
273
|
+
|
|
267
274
|
```bash
|
|
268
275
|
admina plugin list # List registered plugins
|
|
269
276
|
admina plugin install ./my-plugin # Install a custom plugin
|
|
@@ -63,6 +63,7 @@ class EventType(str, Enum):
|
|
|
63
63
|
# Proxy / MCP events
|
|
64
64
|
MCP_REQUEST = "mcp_request"
|
|
65
65
|
MCP_RESPONSE = "mcp_response"
|
|
66
|
+
GATEWAY_REQUEST = "gateway_request"
|
|
66
67
|
INJECTION_DETECTED = "injection_detected"
|
|
67
68
|
PII_REDACTED = "pii_redacted"
|
|
68
69
|
LOOP_DETECTED = "loop_detected"
|
|
@@ -18,8 +18,10 @@ Hash-chain integrity, immutable audit trail.
|
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
20
|
import hashlib
|
|
21
|
+
import hmac
|
|
21
22
|
import json
|
|
22
23
|
import logging
|
|
24
|
+
import os
|
|
23
25
|
import threading
|
|
24
26
|
import time
|
|
25
27
|
from datetime import UTC, datetime
|
|
@@ -31,6 +33,8 @@ logger = logging.getLogger("admina.forensic_blackbox")
|
|
|
31
33
|
|
|
32
34
|
# Key used to persist the chain state in the object store.
|
|
33
35
|
_CHAIN_STATE_KEY = "_chain_state.json"
|
|
36
|
+
# Sidecar holding the HMAC-SHA256 signature of the chain-state payload.
|
|
37
|
+
_CHAIN_STATE_SIG_KEY = "_chain_state.json.sig"
|
|
34
38
|
|
|
35
39
|
|
|
36
40
|
class ForensicBlackBox(BaseForensicStore):
|
|
@@ -63,6 +67,8 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
63
67
|
# Retry policy for transient S3 errors
|
|
64
68
|
s3_max_retries: int = 5,
|
|
65
69
|
s3_base_delay_s: float = 0.2,
|
|
70
|
+
# Optional HMAC key for the chain-state file (else ADMINA_FORENSIC_STATE_KEY)
|
|
71
|
+
state_signing_key: str | None = None,
|
|
66
72
|
):
|
|
67
73
|
self.boto3_client = boto3_client
|
|
68
74
|
self.bucket = bucket
|
|
@@ -75,6 +81,7 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
75
81
|
self.chain_head: str = "GENESIS"
|
|
76
82
|
self.record_count: int = 0
|
|
77
83
|
self._write_lock = threading.Lock()
|
|
84
|
+
self._state_signing_key = state_signing_key or os.environ.get("ADMINA_FORENSIC_STATE_KEY")
|
|
78
85
|
if self.filesystem_dir is not None:
|
|
79
86
|
self.filesystem_dir.mkdir(parents=True, exist_ok=True)
|
|
80
87
|
self._ensure_bucket()
|
|
@@ -160,12 +167,35 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
160
167
|
)
|
|
161
168
|
return True
|
|
162
169
|
|
|
170
|
+
def _read_s3_state_sig(self) -> str | None:
|
|
171
|
+
"""Read the HMAC sidecar object for the chain state (S3), or None."""
|
|
172
|
+
try:
|
|
173
|
+
obj = self.boto3_client.get_object(Bucket=self.bucket, Key=_CHAIN_STATE_SIG_KEY)
|
|
174
|
+
return obj["Body"].read().decode("utf-8").strip()
|
|
175
|
+
except Exception: # noqa: BLE001 — NoSuchKey or similar
|
|
176
|
+
return None
|
|
177
|
+
|
|
163
178
|
def _restore_chain_state(self):
|
|
164
179
|
"""Restore chain_head and record_count from the configured backend."""
|
|
165
180
|
if self.boto3_client is not None:
|
|
166
181
|
try:
|
|
167
182
|
obj = self.boto3_client.get_object(Bucket=self.bucket, Key=_CHAIN_STATE_KEY)
|
|
168
|
-
|
|
183
|
+
payload = obj["Body"].read()
|
|
184
|
+
except Exception: # noqa: BLE001 — NoSuchKey or similar
|
|
185
|
+
logger.info("No existing forensic chain state in S3, starting fresh")
|
|
186
|
+
self._reconstruct_chain_state_from_records()
|
|
187
|
+
return
|
|
188
|
+
if self._state_signing_key and not self._state_sig_is_valid(
|
|
189
|
+
payload, self._read_s3_state_sig()
|
|
190
|
+
):
|
|
191
|
+
logger.critical(
|
|
192
|
+
"Forensic chain state signature INVALID or MISSING (S3) "
|
|
193
|
+
"— possible tampering; reconstructing from records"
|
|
194
|
+
)
|
|
195
|
+
self._reconstruct_chain_state_from_records()
|
|
196
|
+
return
|
|
197
|
+
try:
|
|
198
|
+
state = json.loads(payload)
|
|
169
199
|
self.chain_head = state.get("chain_head", "GENESIS")
|
|
170
200
|
self.record_count = state.get("record_count", 0)
|
|
171
201
|
logger.info(
|
|
@@ -173,15 +203,37 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
173
203
|
self.record_count,
|
|
174
204
|
self.chain_head[:16],
|
|
175
205
|
)
|
|
176
|
-
except
|
|
177
|
-
logger.
|
|
206
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
207
|
+
logger.error("Corrupt forensic chain state (S3) — reconstructing from records")
|
|
178
208
|
self._reconstruct_chain_state_from_records()
|
|
179
209
|
return
|
|
180
210
|
if self.filesystem_dir is not None:
|
|
181
211
|
state_path = self.filesystem_dir / _CHAIN_STATE_KEY
|
|
182
212
|
if state_path.exists():
|
|
183
213
|
try:
|
|
184
|
-
|
|
214
|
+
payload = state_path.read_bytes()
|
|
215
|
+
except OSError:
|
|
216
|
+
logger.error(
|
|
217
|
+
"Cannot read forensic chain state at %s — reconstructing from records",
|
|
218
|
+
state_path,
|
|
219
|
+
)
|
|
220
|
+
self._reconstruct_chain_state_from_records()
|
|
221
|
+
return
|
|
222
|
+
if self._state_signing_key:
|
|
223
|
+
sig_path = self.filesystem_dir / _CHAIN_STATE_SIG_KEY
|
|
224
|
+
signature = (
|
|
225
|
+
sig_path.read_text(encoding="utf-8").strip() if sig_path.exists() else None
|
|
226
|
+
)
|
|
227
|
+
if not self._state_sig_is_valid(payload, signature):
|
|
228
|
+
logger.critical(
|
|
229
|
+
"Forensic chain state signature INVALID or MISSING at %s "
|
|
230
|
+
"— possible tampering; reconstructing from records",
|
|
231
|
+
state_path,
|
|
232
|
+
)
|
|
233
|
+
self._reconstruct_chain_state_from_records()
|
|
234
|
+
return
|
|
235
|
+
try:
|
|
236
|
+
state = json.loads(payload)
|
|
185
237
|
self.chain_head = state.get("chain_head", "GENESIS")
|
|
186
238
|
self.record_count = state.get("record_count", 0)
|
|
187
239
|
logger.info(
|
|
@@ -189,7 +241,7 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
189
241
|
self.record_count,
|
|
190
242
|
self.chain_head[:16],
|
|
191
243
|
)
|
|
192
|
-
except
|
|
244
|
+
except json.JSONDecodeError:
|
|
193
245
|
logger.error(
|
|
194
246
|
"Corrupt forensic chain state at %s — reconstructing from records",
|
|
195
247
|
state_path,
|
|
@@ -220,12 +272,24 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
220
272
|
Body=payload,
|
|
221
273
|
ContentType="application/json",
|
|
222
274
|
)
|
|
275
|
+
sig = self._sign_state_payload(payload)
|
|
276
|
+
if sig is not None:
|
|
277
|
+
self._s3_call(
|
|
278
|
+
self.boto3_client.put_object,
|
|
279
|
+
Bucket=self.bucket,
|
|
280
|
+
Key=_CHAIN_STATE_SIG_KEY,
|
|
281
|
+
Body=sig.encode("utf-8"),
|
|
282
|
+
ContentType="text/plain",
|
|
283
|
+
)
|
|
223
284
|
except Exception as e: # noqa: BLE001
|
|
224
285
|
logger.warning("Failed to persist chain state (S3): %s", e)
|
|
225
286
|
return
|
|
226
287
|
if self.filesystem_dir is not None:
|
|
227
288
|
try:
|
|
228
289
|
(self.filesystem_dir / _CHAIN_STATE_KEY).write_bytes(payload)
|
|
290
|
+
sig = self._sign_state_payload(payload)
|
|
291
|
+
if sig is not None:
|
|
292
|
+
(self.filesystem_dir / _CHAIN_STATE_SIG_KEY).write_text(sig, encoding="utf-8")
|
|
229
293
|
except OSError as e:
|
|
230
294
|
logger.warning("Failed to persist chain state (filesystem): %s", e)
|
|
231
295
|
|
|
@@ -233,6 +297,23 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
233
297
|
"""SHA-256 hash for chain integrity."""
|
|
234
298
|
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
|
235
299
|
|
|
300
|
+
def _sign_state_payload(self, payload: bytes) -> str | None:
|
|
301
|
+
"""HMAC-SHA256 hex digest of *payload*, or None when no signing key
|
|
302
|
+
is configured (``ADMINA_FORENSIC_STATE_KEY`` unset)."""
|
|
303
|
+
if not self._state_signing_key:
|
|
304
|
+
return None
|
|
305
|
+
return hmac.new(
|
|
306
|
+
self._state_signing_key.encode("utf-8"), payload, hashlib.sha256
|
|
307
|
+
).hexdigest()
|
|
308
|
+
|
|
309
|
+
def _state_sig_is_valid(self, payload: bytes, signature: str | None) -> bool:
|
|
310
|
+
"""True iff a signing key is set and *signature* matches *payload*
|
|
311
|
+
(constant-time)."""
|
|
312
|
+
expected = self._sign_state_payload(payload)
|
|
313
|
+
if expected is None or not signature:
|
|
314
|
+
return False
|
|
315
|
+
return hmac.compare_digest(signature, expected)
|
|
316
|
+
|
|
236
317
|
def record(self, event: dict) -> dict:
|
|
237
318
|
"""
|
|
238
319
|
Record an event to the forensic black box.
|
|
@@ -439,7 +520,7 @@ class ForensicBlackBox(BaseForensicStore):
|
|
|
439
520
|
resp = self._s3_call(self.boto3_client.list_objects_v2, **kwargs)
|
|
440
521
|
for item in resp.get("Contents", []) or []:
|
|
441
522
|
key = item["Key"]
|
|
442
|
-
if key.endswith(_CHAIN_STATE_KEY):
|
|
523
|
+
if key.endswith(_CHAIN_STATE_KEY) or key.endswith(_CHAIN_STATE_SIG_KEY):
|
|
443
524
|
continue
|
|
444
525
|
obj = self._s3_call(self.boto3_client.get_object, Bucket=self.bucket, Key=key)
|
|
445
526
|
out.append(json.loads(obj["Body"].read().decode("utf-8")))
|
|
@@ -40,6 +40,20 @@ logger = logging.getLogger("admina.proxy")
|
|
|
40
40
|
_MAX_SCAN_DEPTH = 6
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def normalize_guard_fail_mode(value: str | None) -> str:
|
|
44
|
+
"""Normalize the guard fail-mode setting to ``"open"`` or ``"closed"``.
|
|
45
|
+
|
|
46
|
+
``"open"`` (default): a guard that raises its contract is skipped and
|
|
47
|
+
recorded as an ``ERROR`` check; the pipeline continues. ``"closed"``:
|
|
48
|
+
a guard exception yields ``action=BLOCK``. ``None`` and the empty string
|
|
49
|
+
resolve to ``"open"``. Any other value raises ``ValueError``.
|
|
50
|
+
"""
|
|
51
|
+
normalized = (value or "open").strip().lower()
|
|
52
|
+
if normalized not in ("open", "closed"):
|
|
53
|
+
raise ValueError(f"guard fail mode must be 'open' or 'closed' (got {value!r})")
|
|
54
|
+
return normalized
|
|
55
|
+
|
|
56
|
+
|
|
43
57
|
@dataclass
|
|
44
58
|
class GovernanceResult:
|
|
45
59
|
"""Result of running the full governance pipeline on a request."""
|
|
@@ -72,6 +86,7 @@ async def run_pipeline(
|
|
|
72
86
|
pii_enabled: bool = True,
|
|
73
87
|
loop_enabled: bool = True,
|
|
74
88
|
mode: str = "enforce",
|
|
89
|
+
guard_fail_mode: str = "open",
|
|
75
90
|
) -> GovernanceResult:
|
|
76
91
|
"""Execute the full governance pipeline and return a GovernanceResult.
|
|
77
92
|
|
|
@@ -83,6 +98,11 @@ async def run_pipeline(
|
|
|
83
98
|
``False`` for single-shot callers (e.g. GovernedModel) that have no
|
|
84
99
|
cross-call session state; the stage is skipped and ``loop_result`` is
|
|
85
100
|
set to a no-op dict so downstream code is unaffected.
|
|
101
|
+
|
|
102
|
+
``guard_fail_mode`` selects request-side behavior when a governance guard
|
|
103
|
+
raises: ``"open"`` (default) records an ``ERROR`` check and continues;
|
|
104
|
+
``"closed"`` sets ``action=BLOCK`` (risk HIGH) while still recording the
|
|
105
|
+
``ERROR`` check.
|
|
86
106
|
"""
|
|
87
107
|
start_time = time.perf_counter()
|
|
88
108
|
result = GovernanceResult()
|
|
@@ -137,11 +157,16 @@ async def run_pipeline(
|
|
|
137
157
|
exc,
|
|
138
158
|
exc_info=True,
|
|
139
159
|
)
|
|
140
|
-
# follow-up: optional fail-closed mode
|
|
141
160
|
result.checks[f"guard_{guard.name}"] = {
|
|
142
161
|
"action": "ERROR",
|
|
143
162
|
"error": str(exc),
|
|
144
163
|
}
|
|
164
|
+
if guard_fail_mode == "closed":
|
|
165
|
+
# Fail-closed: a guard that breaks its contract blocks the
|
|
166
|
+
# request. The ERROR entry above stays as the audit trail.
|
|
167
|
+
result.action = GovernanceAction.BLOCK
|
|
168
|
+
result.risk_level = RiskLevel.HIGH
|
|
169
|
+
break
|
|
145
170
|
|
|
146
171
|
# 5. Apply governance MODE — observe / dry-run downgrade BLOCK to ALLOW
|
|
147
172
|
# but record what would have happened in `would_action` so dashboards
|
|
@@ -373,13 +373,25 @@ def _spacy_regex_pii() -> PIIBridge:
|
|
|
373
373
|
_PII_ENGINE_FACTORIES["spacy-regex"] = _spacy_regex_pii
|
|
374
374
|
|
|
375
375
|
|
|
376
|
+
def _presidio_pii() -> PIIBridge:
|
|
377
|
+
from admina.engines.presidio import get_presidio_pii_engine
|
|
378
|
+
|
|
379
|
+
return get_presidio_pii_engine()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
_PII_ENGINE_FACTORIES["presidio"] = _presidio_pii
|
|
383
|
+
|
|
384
|
+
|
|
376
385
|
def get_pii_engine(name: str | None = None) -> PIIBridge:
|
|
377
386
|
"""Get the configured PII engine.
|
|
378
387
|
|
|
379
|
-
Resolution order: explicit *name* arg >
|
|
380
|
-
``spacy-regex``. An engine selected by name
|
|
381
|
-
Rust auto-detection (Rust accelerates only the
|
|
388
|
+
Resolution order: explicit *name* arg > ``ADMINA_PII_ENGINE`` env >
|
|
389
|
+
admina.yaml ``pii_engine`` > ``spacy-regex``. An engine selected by name
|
|
390
|
+
takes precedence over Rust auto-detection (Rust accelerates only the
|
|
391
|
+
``spacy-regex`` path).
|
|
382
392
|
"""
|
|
393
|
+
if name is None:
|
|
394
|
+
name = os.environ.get("ADMINA_PII_ENGINE") or None
|
|
383
395
|
if name is None:
|
|
384
396
|
try:
|
|
385
397
|
from admina.core.config import load_config
|
|
@@ -391,9 +403,7 @@ def get_pii_engine(name: str | None = None) -> PIIBridge:
|
|
|
391
403
|
factory = _PII_ENGINE_FACTORIES.get(name)
|
|
392
404
|
if factory is None:
|
|
393
405
|
raise ValueError(
|
|
394
|
-
f"Unknown pii_engine {name!r}. Available: "
|
|
395
|
-
f"{sorted(_PII_ENGINE_FACTORIES)}. "
|
|
396
|
-
"(A Presidio-backed engine is planned for a later release.)"
|
|
406
|
+
f"Unknown pii_engine {name!r}. Available: {sorted(_PII_ENGINE_FACTORIES)}."
|
|
397
407
|
)
|
|
398
408
|
return factory()
|
|
399
409
|
|
|
@@ -0,0 +1,181 @@
|
|
|
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
|
+
"""Admina — Microsoft Presidio PII engine (analyzer-only).
|
|
16
|
+
|
|
17
|
+
Presidio performs *detection*; Admina performs the *masking*, so the output
|
|
18
|
+
format is identical to the spaCy+regex engine (the per-category mask from
|
|
19
|
+
``PII_CATEGORIES``, e.g. ``[EMAIL]`` / ``[PERSON]``). Registered on the
|
|
20
|
+
synchronous ``PIIBridge`` factory path (admina/engines/__init__.py), NOT the
|
|
21
|
+
async ``BasePIIEngine`` plugin ABC.
|
|
22
|
+
|
|
23
|
+
Languages: EN + IT. Each configured language whose spaCy model is installed is
|
|
24
|
+
analyzed and the results are unioned, so an entity a recognizer only registers
|
|
25
|
+
for one language (e.g. IT_FISCAL_CODE under "it") is still caught. Presidio and
|
|
26
|
+
its models are an optional extra:
|
|
27
|
+
pip install 'admina-framework[presidio]'
|
|
28
|
+
python -m spacy download en_core_web_sm it_core_news_sm
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from functools import lru_cache
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from admina.domains.data_sovereignty.pii import PII_CATEGORIES
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("admina.engines.presidio")
|
|
40
|
+
|
|
41
|
+
# Presidio entity_type -> Admina PII_CATEGORIES key. Unmapped Presidio types
|
|
42
|
+
# (DATE_TIME, URL, NRP, ...) are intentionally ignored so the category set and
|
|
43
|
+
# false-positive profile stay aligned with the spaCy+regex engine.
|
|
44
|
+
_PRESIDIO_TO_ADMINA: dict[str, str] = {
|
|
45
|
+
"EMAIL_ADDRESS": "EMAIL",
|
|
46
|
+
"PHONE_NUMBER": "PHONE",
|
|
47
|
+
"CREDIT_CARD": "CREDIT_CARD",
|
|
48
|
+
"IBAN_CODE": "IBAN",
|
|
49
|
+
"IP_ADDRESS": "IP_ADDRESS",
|
|
50
|
+
"US_SSN": "SSN",
|
|
51
|
+
"PERSON": "PERSON",
|
|
52
|
+
"LOCATION": "GPE",
|
|
53
|
+
"ORGANIZATION": "ORG",
|
|
54
|
+
"IT_FISCAL_CODE": "IT_CODICE_FISCALE",
|
|
55
|
+
"ES_NIF": "ES_DNI_NIE",
|
|
56
|
+
"ES_NIE": "ES_DNI_NIE",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Languages Admina configures for Presidio, each mapped to its spaCy model.
|
|
60
|
+
_LANG_MODELS: dict[str, str] = {
|
|
61
|
+
"en": "en_core_web_sm",
|
|
62
|
+
"it": "it_core_news_sm",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_analyzer(languages: tuple[str, ...]):
|
|
67
|
+
"""Build a Presidio AnalyzerEngine over the given (installed) languages."""
|
|
68
|
+
from presidio_analyzer import AnalyzerEngine
|
|
69
|
+
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
|
70
|
+
|
|
71
|
+
configuration = {
|
|
72
|
+
"nlp_engine_name": "spacy",
|
|
73
|
+
"models": [{"lang_code": lang, "model_name": _LANG_MODELS[lang]} for lang in languages],
|
|
74
|
+
}
|
|
75
|
+
nlp_engine = NlpEngineProvider(nlp_configuration=configuration).create_engine()
|
|
76
|
+
return AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=list(languages))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PresidioPIIEngine:
|
|
80
|
+
"""Synchronous ``PIIBridge`` backed by Microsoft Presidio (analyzer-only)."""
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
try:
|
|
84
|
+
import presidio_analyzer # noqa: F401
|
|
85
|
+
except ImportError as exc:
|
|
86
|
+
raise ImportError(
|
|
87
|
+
"The Presidio PII engine requires the [presidio] extra. Install it with "
|
|
88
|
+
"`pip install 'admina-framework[presidio]'` and download the models "
|
|
89
|
+
"`python -m spacy download en_core_web_sm it_core_news_sm`."
|
|
90
|
+
) from exc
|
|
91
|
+
|
|
92
|
+
import spacy
|
|
93
|
+
|
|
94
|
+
languages = tuple(
|
|
95
|
+
lang for lang, model in _LANG_MODELS.items() if spacy.util.is_package(model)
|
|
96
|
+
)
|
|
97
|
+
if not languages:
|
|
98
|
+
raise ImportError(
|
|
99
|
+
"Presidio is installed but no supported spaCy model is present. Run "
|
|
100
|
+
"`python -m spacy download en_core_web_sm it_core_news_sm`."
|
|
101
|
+
)
|
|
102
|
+
self.languages: list[str] = list(languages)
|
|
103
|
+
self._analyzer = _build_analyzer(languages)
|
|
104
|
+
self.total_redacted: int = 0
|
|
105
|
+
self.redactions_by_type: dict[str, int] = {}
|
|
106
|
+
logger.info("[OK] Presidio PII engine ready (languages: %s)", self.languages)
|
|
107
|
+
|
|
108
|
+
def redact(self, text: str) -> dict[str, Any]:
|
|
109
|
+
if not text:
|
|
110
|
+
return {"redacted_text": text, "entities": [], "categories": [], "count": 0}
|
|
111
|
+
|
|
112
|
+
# Union of per-language analyses: a recognizer registered for only one
|
|
113
|
+
# language is missed by the other pass, so both are run and merged.
|
|
114
|
+
raw = []
|
|
115
|
+
for lang in self.languages:
|
|
116
|
+
raw.extend(self._analyzer.analyze(text=text, language=lang))
|
|
117
|
+
|
|
118
|
+
spans: list[tuple[int, int, str, str]] = []
|
|
119
|
+
for r in raw:
|
|
120
|
+
category = _PRESIDIO_TO_ADMINA.get(r.entity_type)
|
|
121
|
+
if category is None:
|
|
122
|
+
continue
|
|
123
|
+
cfg = PII_CATEGORIES.get(category, {})
|
|
124
|
+
if not cfg.get("enabled", False):
|
|
125
|
+
continue
|
|
126
|
+
spans.append((r.start, r.end, category, cfg.get("mask", f"[{category}]")))
|
|
127
|
+
|
|
128
|
+
# Resolve overlaps deterministically: earliest start first, longest on
|
|
129
|
+
# ties; greedily drop any span overlapping an already-accepted one. This
|
|
130
|
+
# also dedupes the same span produced by two language passes.
|
|
131
|
+
spans.sort(key=lambda s: (s[0], -(s[1] - s[0])))
|
|
132
|
+
accepted: list[tuple[int, int, str, str]] = []
|
|
133
|
+
last_end = -1
|
|
134
|
+
for start, end, category, mask in spans:
|
|
135
|
+
if start >= last_end:
|
|
136
|
+
accepted.append((start, end, category, mask))
|
|
137
|
+
last_end = end
|
|
138
|
+
|
|
139
|
+
entities = [
|
|
140
|
+
{
|
|
141
|
+
"type": category,
|
|
142
|
+
"start": start,
|
|
143
|
+
"end": end,
|
|
144
|
+
"original_length": end - start,
|
|
145
|
+
"method": "presidio",
|
|
146
|
+
}
|
|
147
|
+
for (start, end, category, mask) in accepted
|
|
148
|
+
]
|
|
149
|
+
redacted = text
|
|
150
|
+
for start, end, _category, mask in sorted(accepted, key=lambda s: s[0], reverse=True):
|
|
151
|
+
redacted = redacted[:start] + mask + redacted[end:]
|
|
152
|
+
|
|
153
|
+
count = len(entities)
|
|
154
|
+
if count:
|
|
155
|
+
self.total_redacted += count
|
|
156
|
+
for ent in entities:
|
|
157
|
+
self.redactions_by_type[ent["type"]] = (
|
|
158
|
+
self.redactions_by_type.get(ent["type"], 0) + 1
|
|
159
|
+
)
|
|
160
|
+
categories = sorted({ent["type"] for ent in entities})
|
|
161
|
+
return {
|
|
162
|
+
"redacted_text": redacted,
|
|
163
|
+
"entities": entities,
|
|
164
|
+
"categories": categories,
|
|
165
|
+
"count": count,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def get_stats(self) -> dict[str, Any]:
|
|
169
|
+
return {
|
|
170
|
+
"total_redacted": self.total_redacted,
|
|
171
|
+
"redactions_by_type": self.redactions_by_type,
|
|
172
|
+
"spacy_available": True,
|
|
173
|
+
"engine": "presidio",
|
|
174
|
+
"languages": list(self.languages),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@lru_cache(maxsize=1)
|
|
179
|
+
def get_presidio_pii_engine() -> PresidioPIIEngine:
|
|
180
|
+
"""Return a process-wide cached Presidio engine (analyzer construction is costly)."""
|
|
181
|
+
return PresidioPIIEngine()
|
|
@@ -117,7 +117,7 @@ def agent_fast_reply(fast_reply: dict, cat) -> dict:
|
|
|
117
117
|
)
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
if result.get("action") == "
|
|
120
|
+
if result.get("action") == "REDACT" and result.get("redacted_content"):
|
|
121
121
|
# Replace PII in the working memory so the LLM never sees it
|
|
122
122
|
user_msg["text"] = result["redacted_content"]
|
|
123
123
|
cat.working_memory["user_message_json"] = user_msg
|
|
@@ -149,7 +149,7 @@ def before_cat_sends_message(message: dict, cat) -> dict:
|
|
|
149
149
|
result = _validate(text, session_id=session_id)
|
|
150
150
|
|
|
151
151
|
if result is not None:
|
|
152
|
-
if result.get("action") == "
|
|
152
|
+
if result.get("action") == "REDACT" and result.get("redacted_content"):
|
|
153
153
|
message["content"] = result["redacted_content"]
|
|
154
154
|
|
|
155
155
|
if result.get("action") == "BLOCK":
|
|
@@ -201,7 +201,7 @@ def before_cat_recalls_memories(default_query: str, cat) -> str:
|
|
|
201
201
|
)
|
|
202
202
|
return "" # empty query = no retrieval
|
|
203
203
|
|
|
204
|
-
if result.get("action") == "
|
|
204
|
+
if result.get("action") == "REDACT" and result.get("redacted_content"):
|
|
205
205
|
return result["redacted_content"]
|
|
206
206
|
|
|
207
207
|
return default_query
|
|
@@ -29,6 +29,7 @@ it in ``admina.yaml`` under the ``plugins:`` section.
|
|
|
29
29
|
from __future__ import annotations
|
|
30
30
|
|
|
31
31
|
from abc import ABC, abstractmethod
|
|
32
|
+
from collections.abc import AsyncIterator
|
|
32
33
|
from typing import TYPE_CHECKING, Any
|
|
33
34
|
|
|
34
35
|
if TYPE_CHECKING:
|
|
@@ -102,6 +103,29 @@ class BaseModelAdapter(ABC):
|
|
|
102
103
|
model_name: A model identifier, e.g. ``"llama3"`` or ``"gpt-4o"``.
|
|
103
104
|
"""
|
|
104
105
|
|
|
106
|
+
async def send_stream(
|
|
107
|
+
self,
|
|
108
|
+
prompt: str,
|
|
109
|
+
context: Any = None,
|
|
110
|
+
**kwargs: Any,
|
|
111
|
+
) -> AsyncIterator[str]:
|
|
112
|
+
"""Stream text deltas from the model.
|
|
113
|
+
|
|
114
|
+
Base default: a non-streaming fallback that calls :meth:`send`
|
|
115
|
+
once and yields the whole text as a single chunk. Adapters backed
|
|
116
|
+
by SDKs with mature streaming override this to yield incrementally.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
prompt: The text prompt to send.
|
|
120
|
+
context: Optional conversation context or system prompt.
|
|
121
|
+
**kwargs: Provider-specific parameters (temperature, model, …).
|
|
122
|
+
|
|
123
|
+
Yields:
|
|
124
|
+
Raw (un-redacted) text deltas. The fallback yields exactly one.
|
|
125
|
+
"""
|
|
126
|
+
result = await self.send(prompt, context=context, **kwargs)
|
|
127
|
+
yield result.get("text", "")
|
|
128
|
+
|
|
105
129
|
@property
|
|
106
130
|
@abstractmethod
|
|
107
131
|
def name(self) -> str:
|