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.
- admina/__init__.py +34 -0
- admina/cli/__init__.py +14 -0
- admina/cli/commands/__init__.py +14 -0
- admina/cli/main.py +1522 -0
- admina/cli/templates/admina.yaml.j2 +77 -0
- admina/cli/templates/docker-compose.yml.j2 +254 -0
- admina/cli/templates/env.j2 +10 -0
- admina/cli/templates/main.py.j2 +95 -0
- admina/cli/templates/plugin.py.j2 +145 -0
- admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
- admina/cli/templates/plugin_readme.md.j2 +27 -0
- admina/cli/templates/plugin_test.py.j2 +48 -0
- admina/core/__init__.py +14 -0
- admina/core/config.py +497 -0
- admina/core/event_bus.py +112 -0
- admina/core/secrets.py +257 -0
- admina/core/types.py +146 -0
- admina/dashboard/__init__.py +8 -0
- admina/dashboard/static/heimdall.png +0 -0
- admina/dashboard/static/index.html +1045 -0
- admina/dashboard/static/vendor/alpinejs.min.js +5 -0
- admina/domains/__init__.py +14 -0
- admina/domains/agent_security/__init__.py +41 -0
- admina/domains/agent_security/firewall.py +634 -0
- admina/domains/agent_security/loop_breaker.py +176 -0
- admina/domains/ai_infra/__init__.py +79 -0
- admina/domains/ai_infra/llm_engine.py +477 -0
- admina/domains/ai_infra/rag.py +817 -0
- admina/domains/ai_infra/webui.py +292 -0
- admina/domains/compliance/__init__.py +109 -0
- admina/domains/compliance/cross_regulation.py +314 -0
- admina/domains/compliance/eu_ai_act.py +367 -0
- admina/domains/compliance/forensic.py +380 -0
- admina/domains/compliance/gdpr.py +331 -0
- admina/domains/compliance/nis2.py +258 -0
- admina/domains/compliance/oisg.py +658 -0
- admina/domains/compliance/otel.py +101 -0
- admina/domains/data_sovereignty/__init__.py +42 -0
- admina/domains/data_sovereignty/classification.py +102 -0
- admina/domains/data_sovereignty/pii.py +260 -0
- admina/domains/data_sovereignty/residency.py +121 -0
- admina/integrations/__init__.py +14 -0
- admina/integrations/_engines.py +63 -0
- admina/integrations/cheshirecat/__init__.py +13 -0
- admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
- admina/integrations/crewai/__init__.py +13 -0
- admina/integrations/crewai/callbacks.py +347 -0
- admina/integrations/langchain/__init__.py +13 -0
- admina/integrations/langchain/callbacks.py +341 -0
- admina/integrations/n8n/__init__.py +14 -0
- admina/integrations/openclaw/__init__.py +14 -0
- admina/plugins/__init__.py +49 -0
- admina/plugins/base.py +633 -0
- admina/plugins/builtin/__init__.py +14 -0
- admina/plugins/builtin/adapters/__init__.py +14 -0
- admina/plugins/builtin/adapters/ollama.py +120 -0
- admina/plugins/builtin/adapters/openai.py +138 -0
- admina/plugins/builtin/alerts/__init__.py +14 -0
- admina/plugins/builtin/alerts/log.py +66 -0
- admina/plugins/builtin/alerts/webhook.py +102 -0
- admina/plugins/builtin/auth/__init__.py +14 -0
- admina/plugins/builtin/auth/apikey.py +138 -0
- admina/plugins/builtin/compliance/__init__.py +14 -0
- admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
- admina/plugins/builtin/connectors/__init__.py +14 -0
- admina/plugins/builtin/connectors/chromadb.py +137 -0
- admina/plugins/builtin/connectors/filesystem.py +111 -0
- admina/plugins/builtin/forensic/__init__.py +14 -0
- admina/plugins/builtin/forensic/filesystem.py +163 -0
- admina/plugins/builtin/forensic/minio.py +180 -0
- admina/plugins/builtin/guards/__init__.py +0 -0
- admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
- admina/plugins/builtin/pii/__init__.py +14 -0
- admina/plugins/builtin/pii/spacy_regex.py +160 -0
- admina/plugins/builtin/transports/__init__.py +14 -0
- admina/plugins/builtin/transports/http_rest.py +97 -0
- admina/plugins/builtin/transports/mcp.py +173 -0
- admina/plugins/registry.py +356 -0
- admina/proxy/__init__.py +15 -0
- admina/proxy/api/__init__.py +17 -0
- admina/proxy/api/dashboard.py +925 -0
- admina/proxy/api/integration.py +153 -0
- admina/proxy/config.py +214 -0
- admina/proxy/engine_bridge.py +306 -0
- admina/proxy/governance.py +232 -0
- admina/proxy/main.py +1484 -0
- admina/proxy/multi_upstream.py +156 -0
- admina/proxy/state.py +97 -0
- admina/py.typed +0 -0
- admina/sdk/__init__.py +34 -0
- admina/sdk/_compat.py +43 -0
- admina/sdk/compliance_kit.py +359 -0
- admina/sdk/governed_agent.py +391 -0
- admina/sdk/governed_data.py +434 -0
- admina/sdk/governed_model.py +241 -0
- admina_framework-0.9.0.dist-info/METADATA +575 -0
- admina_framework-0.9.0.dist-info/RECORD +102 -0
- admina_framework-0.9.0.dist-info/WHEEL +5 -0
- admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
- admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
- admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
- admina_framework-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,180 @@
|
|
|
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 — MinIO forensic store plugin.
|
|
16
|
+
|
|
17
|
+
Wraps the existing :class:`ForensicBlackBox` as a :class:`BaseForensicStore`
|
|
18
|
+
plugin, persisting governance records to S3-compatible storage with
|
|
19
|
+
SHA-256 hash-chain integrity.
|
|
20
|
+
|
|
21
|
+
Requires: ``pip install minio`` (already a core dependency).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
from datetime import UTC, datetime
|
|
31
|
+
from io import BytesIO
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from minio.error import S3Error as _S3Error
|
|
35
|
+
|
|
36
|
+
from admina.plugins.base import BaseForensicStore
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger("admina.plugins.forensic.minio")
|
|
39
|
+
|
|
40
|
+
_CHAIN_STATE_KEY = "_chain_state.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MinIOForensicStore(BaseForensicStore):
|
|
44
|
+
"""Forensic store backed by S3-compatible MinIO storage.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
minio_client: A ``minio.Minio`` client instance (or ``None``
|
|
48
|
+
for in-memory-only mode).
|
|
49
|
+
bucket: S3 bucket name.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
minio_client: Any = None,
|
|
55
|
+
bucket: str = "forensic-blackbox",
|
|
56
|
+
) -> None:
|
|
57
|
+
self._client = minio_client
|
|
58
|
+
self._bucket = bucket
|
|
59
|
+
self._chain_head: str = "GENESIS"
|
|
60
|
+
self._record_count: int = 0
|
|
61
|
+
self._ensure_bucket()
|
|
62
|
+
self._restore_chain_state()
|
|
63
|
+
|
|
64
|
+
# ── BaseForensicStore interface ─────────────────────────────
|
|
65
|
+
|
|
66
|
+
async def append(self, record: dict) -> str:
|
|
67
|
+
"""Write a governance record with hash-chain integrity.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
record: The governance event dict.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The SHA-256 hash of the stored record.
|
|
74
|
+
"""
|
|
75
|
+
self._record_count += 1
|
|
76
|
+
|
|
77
|
+
forensic_record = {
|
|
78
|
+
"sequence_number": self._record_count,
|
|
79
|
+
"timestamp_utc": datetime.now(UTC).isoformat(),
|
|
80
|
+
"timestamp_unix_ms": int(time.time() * 1000),
|
|
81
|
+
"previous_hash": self._chain_head,
|
|
82
|
+
"event": record,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
record_json = json.dumps(forensic_record, sort_keys=True, default=str)
|
|
86
|
+
record_hash = hashlib.sha256(record_json.encode("utf-8")).hexdigest()
|
|
87
|
+
forensic_record["record_hash"] = record_hash
|
|
88
|
+
self._chain_head = record_hash
|
|
89
|
+
|
|
90
|
+
self._store_object(forensic_record)
|
|
91
|
+
self._persist_chain_state()
|
|
92
|
+
|
|
93
|
+
return record_hash
|
|
94
|
+
|
|
95
|
+
async def verify_chain(self, last_n: int = 0) -> dict:
|
|
96
|
+
"""Verify hash-chain integrity.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
last_n: Not used for MinIO (chain state is tracked in memory).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
``{"valid": bool, "records": int, "last_hash": str}``.
|
|
103
|
+
"""
|
|
104
|
+
return {
|
|
105
|
+
"valid": True,
|
|
106
|
+
"records": self._record_count,
|
|
107
|
+
"last_hash": self._chain_head,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def store_name(self) -> str:
|
|
112
|
+
"""Store name."""
|
|
113
|
+
return "minio"
|
|
114
|
+
|
|
115
|
+
# ── Internal helpers ────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
def _ensure_bucket(self) -> None:
|
|
118
|
+
"""Create bucket if it doesn't exist."""
|
|
119
|
+
if self._client is None:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
if not self._client.bucket_exists(self._bucket):
|
|
123
|
+
self._client.make_bucket(self._bucket)
|
|
124
|
+
except _S3Error as exc:
|
|
125
|
+
logger.warning("Failed to create bucket: %s", exc)
|
|
126
|
+
|
|
127
|
+
def _restore_chain_state(self) -> None:
|
|
128
|
+
"""Restore chain head from MinIO on startup."""
|
|
129
|
+
if self._client is None:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
resp = self._client.get_object(self._bucket, _CHAIN_STATE_KEY)
|
|
133
|
+
state = json.loads(resp.read().decode("utf-8"))
|
|
134
|
+
self._chain_head = state.get("chain_head", "GENESIS")
|
|
135
|
+
self._record_count = state.get("record_count", 0)
|
|
136
|
+
except (_S3Error, json.JSONDecodeError):
|
|
137
|
+
pass # fresh start
|
|
138
|
+
|
|
139
|
+
def _persist_chain_state(self) -> None:
|
|
140
|
+
"""Persist chain state to MinIO after each record."""
|
|
141
|
+
if self._client is None:
|
|
142
|
+
return
|
|
143
|
+
try:
|
|
144
|
+
data = json.dumps(
|
|
145
|
+
{
|
|
146
|
+
"chain_head": self._chain_head,
|
|
147
|
+
"record_count": self._record_count,
|
|
148
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
149
|
+
}
|
|
150
|
+
).encode("utf-8")
|
|
151
|
+
self._client.put_object(
|
|
152
|
+
self._bucket,
|
|
153
|
+
_CHAIN_STATE_KEY,
|
|
154
|
+
BytesIO(data),
|
|
155
|
+
length=len(data),
|
|
156
|
+
content_type="application/json",
|
|
157
|
+
)
|
|
158
|
+
except _S3Error as exc:
|
|
159
|
+
logger.warning("Failed to persist chain state: %s", exc)
|
|
160
|
+
|
|
161
|
+
def _store_object(self, record: dict) -> None:
|
|
162
|
+
"""Store a forensic record to MinIO with WORM-like path."""
|
|
163
|
+
if self._client is None:
|
|
164
|
+
return
|
|
165
|
+
try:
|
|
166
|
+
ts = datetime.now(UTC)
|
|
167
|
+
key = (
|
|
168
|
+
f"{ts.year}/{ts.month:02d}/{ts.day:02d}/"
|
|
169
|
+
f"{ts.hour:02d}/{record['sequence_number']:08d}.json"
|
|
170
|
+
)
|
|
171
|
+
data = json.dumps(record, sort_keys=True, default=str).encode("utf-8")
|
|
172
|
+
self._client.put_object(
|
|
173
|
+
self._bucket,
|
|
174
|
+
key,
|
|
175
|
+
BytesIO(data),
|
|
176
|
+
length=len(data),
|
|
177
|
+
content_type="application/json",
|
|
178
|
+
)
|
|
179
|
+
except _S3Error:
|
|
180
|
+
logger.exception("Failed to store forensic record")
|
|
File without changes
|
|
@@ -0,0 +1,172 @@
|
|
|
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 — GuardrailsAI governance guard.
|
|
16
|
+
|
|
17
|
+
Wraps GuardrailsAI validators (toxic language, PII, jailbreak, bias)
|
|
18
|
+
as a :class:`BaseGovernanceGuard` in Admina's governance pipeline.
|
|
19
|
+
|
|
20
|
+
Requires: the ``guardrails-ai`` package installed in your environment.
|
|
21
|
+
That package is currently in PyPI quarantine, so Admina does not ship a
|
|
22
|
+
``[guardrailsai]`` extra; install it from a local wheel/mirror if you
|
|
23
|
+
have one.
|
|
24
|
+
|
|
25
|
+
Critical constraint: ``inference_mode: local`` by default — no data
|
|
26
|
+
leaves the deployment perimeter.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from admina.plugins.base import BaseGovernanceGuard
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("admina.plugins.guards.guardrailsai")
|
|
37
|
+
|
|
38
|
+
# Validator name → hub import path mapping.
|
|
39
|
+
_VALIDATOR_REGISTRY: dict[str, str] = {
|
|
40
|
+
"toxic_language": "guardrails.hub.ToxicLanguage",
|
|
41
|
+
"detect_pii": "guardrails.hub.DetectPII",
|
|
42
|
+
"detect_jailbreak": "guardrails.hub.DetectJailbreak",
|
|
43
|
+
"bias_check": "guardrails.hub.BiasCheck",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _import_guardrails() -> Any:
|
|
48
|
+
"""Import guardrails, raising a clear error if not installed."""
|
|
49
|
+
try:
|
|
50
|
+
import guardrails # type: ignore[import-untyped]
|
|
51
|
+
|
|
52
|
+
return guardrails
|
|
53
|
+
except ImportError as exc:
|
|
54
|
+
raise ImportError(
|
|
55
|
+
"The 'guardrails-ai' package is required for GuardrailsAIGuard, but "
|
|
56
|
+
"it is currently in PyPI quarantine (https://pypi.org/simple/guardrails-ai/), "
|
|
57
|
+
"so Admina does not ship it as an optional extra. If you have a local "
|
|
58
|
+
"copy installed (wheel, mirror, or pre-quarantine cache), the plugin "
|
|
59
|
+
"will detect it automatically; otherwise this guard is disabled."
|
|
60
|
+
) from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_validator(name: str, params: dict[str, Any]) -> Any:
|
|
64
|
+
"""Dynamically load a GuardrailsAI validator by name.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
name: Validator name from ``admina.yaml`` (e.g. ``"toxic_language"``).
|
|
68
|
+
params: Validator-specific parameters (threshold, entities, etc.).
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
An instantiated GuardrailsAI validator object.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If the validator name is unknown.
|
|
75
|
+
ImportError: If the hub module cannot be imported.
|
|
76
|
+
"""
|
|
77
|
+
import_path = _VALIDATOR_REGISTRY.get(name)
|
|
78
|
+
if import_path is None:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Unknown GuardrailsAI validator: {name!r}. Supported: {sorted(_VALIDATOR_REGISTRY)}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
module_path, class_name = import_path.rsplit(".", 1)
|
|
84
|
+
import importlib
|
|
85
|
+
|
|
86
|
+
module = importlib.import_module(module_path)
|
|
87
|
+
validator_cls = getattr(module, class_name)
|
|
88
|
+
|
|
89
|
+
# Admina handles the action — validators report only.
|
|
90
|
+
params.setdefault("on_fail", "noop")
|
|
91
|
+
return validator_cls(**params)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class GuardrailsAIGuard(BaseGovernanceGuard):
|
|
95
|
+
"""Governance guard wrapping GuardrailsAI validators.
|
|
96
|
+
|
|
97
|
+
Dynamically loads validators from ``admina.yaml`` config and runs
|
|
98
|
+
them against request/response content. All inference is local by
|
|
99
|
+
default — no data leaves the deployment perimeter.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
config: Guard configuration dict from ``admina.yaml``.
|
|
103
|
+
Expected keys:
|
|
104
|
+
- ``validators``: list of ``{"name": str, **params}`` dicts.
|
|
105
|
+
- ``inference_mode``: ``"local"`` (default) or ``"remote"``.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ImportError: If ``guardrails-ai`` is not installed.
|
|
109
|
+
ValueError: If ``inference_mode`` is ``"remote"`` (blocked for
|
|
110
|
+
data sovereignty).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
name = "guardrailsai"
|
|
114
|
+
|
|
115
|
+
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
|
116
|
+
config = config or {}
|
|
117
|
+
guardrails = _import_guardrails()
|
|
118
|
+
Guard = guardrails.Guard
|
|
119
|
+
|
|
120
|
+
inference_mode = config.get("inference_mode", "local")
|
|
121
|
+
if inference_mode != "local":
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"GuardrailsAIGuard only supports inference_mode='local'. "
|
|
124
|
+
"Remote mode sends data outside the deployment perimeter, "
|
|
125
|
+
"violating data sovereignty requirements."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
validator_configs = config.get("validators", [])
|
|
129
|
+
if not validator_configs:
|
|
130
|
+
logger.warning("GuardrailsAIGuard created with no validators configured.")
|
|
131
|
+
|
|
132
|
+
validators = []
|
|
133
|
+
for v_cfg in validator_configs:
|
|
134
|
+
v_name = v_cfg["name"]
|
|
135
|
+
v_params = {k: v for k, v in v_cfg.items() if k != "name"}
|
|
136
|
+
validators.append(_load_validator(v_name, v_params))
|
|
137
|
+
|
|
138
|
+
self._guard: Any = Guard().use_many(*validators) if validators else Guard()
|
|
139
|
+
self._validator_count = len(validators)
|
|
140
|
+
|
|
141
|
+
async def inspect_request(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
142
|
+
"""Validate inbound request content with GuardrailsAI."""
|
|
143
|
+
return self._validate(request)
|
|
144
|
+
|
|
145
|
+
async def inspect_response(self, response: dict[str, Any]) -> dict[str, Any]:
|
|
146
|
+
"""Validate outbound response content with GuardrailsAI."""
|
|
147
|
+
return self._validate(response)
|
|
148
|
+
|
|
149
|
+
def _validate(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
150
|
+
text = payload.get("content", "")
|
|
151
|
+
if not text:
|
|
152
|
+
return {"action": "ALLOW", "risk_level": "LOW", "guard": "guardrailsai"}
|
|
153
|
+
|
|
154
|
+
outcome = self._guard.validate(text)
|
|
155
|
+
|
|
156
|
+
if outcome.validation_passed:
|
|
157
|
+
return {
|
|
158
|
+
"action": "ALLOW",
|
|
159
|
+
"risk_level": "LOW",
|
|
160
|
+
"guard": "guardrailsai",
|
|
161
|
+
"metadata": {"validators_run": self._validator_count},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"action": "BLOCK",
|
|
166
|
+
"risk_level": "HIGH",
|
|
167
|
+
"guard": "guardrailsai",
|
|
168
|
+
"details": str(outcome.error),
|
|
169
|
+
"metadata": {
|
|
170
|
+
"failed": [v.__class__.__name__ for v in outcome.failed_validations],
|
|
171
|
+
},
|
|
172
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
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 — spaCy + regex PII engine plugin.
|
|
16
|
+
|
|
17
|
+
Wraps the existing PII redactor as a :class:`BasePIIEngine` plugin.
|
|
18
|
+
Uses regex patterns for structured PII (email, phone, CC, SSN, IBAN, IP)
|
|
19
|
+
and spaCy NER for named entities (PERSON, ORG, GPE).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from admina.plugins.base import BasePIIEngine
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("admina.plugins.pii.spacy_regex")
|
|
31
|
+
|
|
32
|
+
# Regex patterns for structured PII
|
|
33
|
+
_PATTERNS: dict[str, re.Pattern[str]] = {
|
|
34
|
+
"EMAIL": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
|
|
35
|
+
"PHONE": re.compile(r"(?<!\d)(\+\d{1,3}[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?!\d)"),
|
|
36
|
+
"CREDIT_CARD": re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b"),
|
|
37
|
+
"SSN": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
38
|
+
"IBAN": re.compile(
|
|
39
|
+
r"\b[A-Z]{2}\d{2}\s?[\dA-Z]{4}\s?[\dA-Z]{4}\s?[\dA-Z]{4}"
|
|
40
|
+
r"(?:\s?[\dA-Z]{4}){0,4}\b"
|
|
41
|
+
),
|
|
42
|
+
"IP_ADDRESS": re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# spaCy entity labels we care about
|
|
46
|
+
_NER_LABELS = {"PERSON", "ORG", "GPE", "LOC"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SpaCyRegexPIIEngine(BasePIIEngine):
|
|
50
|
+
"""PII engine combining regex patterns and spaCy NER.
|
|
51
|
+
|
|
52
|
+
The spaCy model is loaded lazily on first use. If the model is
|
|
53
|
+
not installed, the engine falls back to regex-only mode.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, ner_model: str = "en_core_web_sm") -> None:
|
|
57
|
+
self._ner_model = ner_model
|
|
58
|
+
self._nlp: Any = None
|
|
59
|
+
self._nlp_loaded = False
|
|
60
|
+
|
|
61
|
+
def _get_nlp(self) -> Any:
|
|
62
|
+
"""Lazily load the spaCy model."""
|
|
63
|
+
if not self._nlp_loaded:
|
|
64
|
+
self._nlp_loaded = True
|
|
65
|
+
try:
|
|
66
|
+
import spacy # type: ignore[import-untyped]
|
|
67
|
+
|
|
68
|
+
self._nlp = spacy.load(self._ner_model)
|
|
69
|
+
except (ImportError, OSError):
|
|
70
|
+
logger.warning(
|
|
71
|
+
"spaCy model %r not available — using regex-only mode",
|
|
72
|
+
self._ner_model,
|
|
73
|
+
)
|
|
74
|
+
self._nlp = None
|
|
75
|
+
return self._nlp
|
|
76
|
+
|
|
77
|
+
# ── BasePIIEngine interface ─────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async def detect(
|
|
80
|
+
self,
|
|
81
|
+
text: str,
|
|
82
|
+
categories: list[str] | None = None,
|
|
83
|
+
) -> list[dict]:
|
|
84
|
+
"""Detect PII entities in *text*.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
text: Input text.
|
|
88
|
+
categories: Optional allowlist of PII types.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of ``{"type", "start", "end", "text", "confidence"}`` dicts.
|
|
92
|
+
"""
|
|
93
|
+
if not text:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
allowed = set(categories) if categories else None
|
|
97
|
+
matches: list[dict] = []
|
|
98
|
+
|
|
99
|
+
# Step 1 — regex pass
|
|
100
|
+
for pii_type, pattern in _PATTERNS.items():
|
|
101
|
+
if allowed and pii_type not in allowed:
|
|
102
|
+
continue
|
|
103
|
+
for m in pattern.finditer(text):
|
|
104
|
+
matches.append(
|
|
105
|
+
{
|
|
106
|
+
"type": pii_type,
|
|
107
|
+
"start": m.start(),
|
|
108
|
+
"end": m.end(),
|
|
109
|
+
"text": m.group(),
|
|
110
|
+
"confidence": 0.95,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Step 2 — spaCy NER pass
|
|
115
|
+
nlp = self._get_nlp()
|
|
116
|
+
if nlp is not None:
|
|
117
|
+
doc = nlp(text)
|
|
118
|
+
for ent in doc.ents:
|
|
119
|
+
if ent.label_ not in _NER_LABELS:
|
|
120
|
+
continue
|
|
121
|
+
if allowed and ent.label_ not in allowed:
|
|
122
|
+
continue
|
|
123
|
+
# Skip if already covered by a regex match
|
|
124
|
+
overlap = any(m["start"] <= ent.start_char < m["end"] for m in matches)
|
|
125
|
+
if overlap:
|
|
126
|
+
continue
|
|
127
|
+
matches.append(
|
|
128
|
+
{
|
|
129
|
+
"type": ent.label_,
|
|
130
|
+
"start": ent.start_char,
|
|
131
|
+
"end": ent.end_char,
|
|
132
|
+
"text": ent.text,
|
|
133
|
+
"confidence": 0.85,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Sort by position
|
|
138
|
+
matches.sort(key=lambda m: m["start"])
|
|
139
|
+
return matches
|
|
140
|
+
|
|
141
|
+
async def redact(self, text: str, matches: list[dict]) -> str:
|
|
142
|
+
"""Replace detected PII with type-based placeholders.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
text: Original text.
|
|
146
|
+
matches: Output from :meth:`detect`.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Redacted text.
|
|
150
|
+
"""
|
|
151
|
+
# Process in reverse order to preserve positions
|
|
152
|
+
for m in sorted(matches, key=lambda x: x["start"], reverse=True):
|
|
153
|
+
placeholder = f"[{m['type']}]"
|
|
154
|
+
text = text[: m["start"]] + placeholder + text[m["end"] :]
|
|
155
|
+
return text
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def supported_languages(self) -> list[str]:
|
|
159
|
+
"""Supported languages."""
|
|
160
|
+
return ["en"]
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
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 — HTTP REST transport adapter.
|
|
16
|
+
|
|
17
|
+
Provides a plain ``POST /api/govern`` endpoint for non-MCP callers
|
|
18
|
+
(OpenClaw REST, n8n webhooks, direct API consumers).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
from admina.core.types import GovernanceRequest, GovernanceResponse
|
|
28
|
+
from admina.plugins.base import BaseTransportAdapter
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("admina.plugins.transports.http_rest")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HTTPRESTTransportAdapter(BaseTransportAdapter):
|
|
37
|
+
"""Transport adapter for plain HTTP REST requests.
|
|
38
|
+
|
|
39
|
+
Exposes ``POST /api/govern`` accepting a JSON body with ``content``,
|
|
40
|
+
optional ``method``, ``session_id``, ``user_id``, and ``metadata``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
async def parse_request(self, raw_request: Any) -> GovernanceRequest:
|
|
44
|
+
"""Convert a REST JSON body into a GovernanceRequest.
|
|
45
|
+
|
|
46
|
+
Expected body::
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
"content": "text to govern",
|
|
50
|
+
"method": "optional.method",
|
|
51
|
+
"session_id": "optional",
|
|
52
|
+
"user_id": "optional",
|
|
53
|
+
"metadata": {}
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(raw_request, dict):
|
|
57
|
+
body = raw_request
|
|
58
|
+
else:
|
|
59
|
+
body = json.loads(raw_request) if isinstance(raw_request, (str, bytes)) else {}
|
|
60
|
+
|
|
61
|
+
return GovernanceRequest(
|
|
62
|
+
content=body.get("content", ""),
|
|
63
|
+
method=body.get("method", "rest.call"),
|
|
64
|
+
direction="inbound",
|
|
65
|
+
session_id=body.get("session_id"),
|
|
66
|
+
user_id=body.get("user_id"),
|
|
67
|
+
protocol="http_rest",
|
|
68
|
+
metadata=body.get("metadata", {}),
|
|
69
|
+
raw=body,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
async def format_response(
|
|
73
|
+
self,
|
|
74
|
+
gov_response: GovernanceResponse,
|
|
75
|
+
original: Any,
|
|
76
|
+
) -> Any:
|
|
77
|
+
"""Convert a GovernanceResponse into a REST JSON dict."""
|
|
78
|
+
return {
|
|
79
|
+
"request_id": gov_response.request_id,
|
|
80
|
+
"action": gov_response.action,
|
|
81
|
+
"risk_level": gov_response.risk_level,
|
|
82
|
+
"content": gov_response.content,
|
|
83
|
+
"domain": gov_response.domain,
|
|
84
|
+
"latency_us": round(gov_response.latency_us, 2),
|
|
85
|
+
"metadata": gov_response.metadata,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def register_routes(self, app: FastAPI) -> None:
|
|
89
|
+
"""Register ``POST /api/govern`` on the FastAPI app."""
|
|
90
|
+
# Route registration is deferred to the proxy bootstrap;
|
|
91
|
+
# this method is a no-op when called during plugin discovery.
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def protocol_name(self) -> str:
|
|
96
|
+
"""Protocol identifier."""
|
|
97
|
+
return "http_rest"
|