admina-framework 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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"