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,380 @@
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
+ """
16
+ Admina — Forensic Black Box — Compliance domain
17
+ Hash-chain integrity, immutable audit trail.
18
+ """
19
+
20
+ import hashlib
21
+ import json
22
+ import logging
23
+ import time
24
+ from datetime import UTC, datetime
25
+ from io import BytesIO
26
+ from pathlib import Path
27
+
28
+ # minio is in the [proxy] extra. Make it optional so the forensic module
29
+ # is importable on a pure-SDK install (filesystem backend works without it).
30
+ try:
31
+ from minio.error import S3Error as _S3Error # type: ignore[import-untyped]
32
+ except ImportError: # pragma: no cover
33
+
34
+ class _S3Error(Exception): # type: ignore[no-redef]
35
+ pass
36
+
37
+
38
+ logger = logging.getLogger("admina.forensic_blackbox")
39
+
40
+ # Key used to persist the chain state in MinIO.
41
+ _CHAIN_STATE_KEY = "_chain_state.json"
42
+
43
+
44
+ class ForensicBlackBox:
45
+ """
46
+ Immutable audit log with hash-chain integrity.
47
+
48
+ Three storage backends are supported, in this priority order:
49
+
50
+ 1. ``boto3_client`` — generic S3-compatible (AWS S3, Cloudflare R2,
51
+ SeaweedFS, Garage, Ceph RGW, Backblaze B2, …). The recommended
52
+ backend for new air-gapped or on-premise deployments since the
53
+ MinIO Python SDK has been archived.
54
+ 2. ``minio_client`` — legacy MinIO SDK. Kept for backward
55
+ compatibility; deprecated, will be removed in a future release.
56
+ 3. ``filesystem_dir`` — local JSON files with the same hash-chain
57
+ semantics. Zero external dependencies. Default for OSS / single
58
+ host / development deployments.
59
+
60
+ If none of the three is configured the class still works as an
61
+ in-memory ledger (events are hashed and chained, but lost on
62
+ restart).
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ minio_client=None,
68
+ bucket: str = "forensic-blackbox",
69
+ boto3_client=None,
70
+ filesystem_dir: str | None = None,
71
+ # S3 Object Lock (WORM) — only honoured by the boto3 backend
72
+ s3_object_lock: bool = False,
73
+ s3_lock_days: int = 365 * 7,
74
+ s3_auto_create_locked_bucket: bool = False,
75
+ # Retry policy for transient S3 errors
76
+ s3_max_retries: int = 5,
77
+ s3_base_delay_s: float = 0.2,
78
+ ):
79
+ self.minio_client = minio_client
80
+ self.boto3_client = boto3_client
81
+ self.bucket = bucket
82
+ self.filesystem_dir = Path(filesystem_dir).resolve() if filesystem_dir else None
83
+ self.s3_object_lock = bool(s3_object_lock)
84
+ self.s3_lock_days = int(s3_lock_days)
85
+ self.s3_auto_create_locked_bucket = bool(s3_auto_create_locked_bucket)
86
+ self.s3_max_retries = max(0, int(s3_max_retries))
87
+ self.s3_base_delay_s = max(0.0, float(s3_base_delay_s))
88
+ self.chain_head: str = "GENESIS"
89
+ self.record_count: int = 0
90
+ if self.filesystem_dir is not None:
91
+ self.filesystem_dir.mkdir(parents=True, exist_ok=True)
92
+ self._ensure_bucket()
93
+ self._restore_chain_state()
94
+
95
+ # ── Retry / backoff helper for transient S3 failures ────────
96
+ def _s3_call(self, fn, *args, **kwargs):
97
+ """Run *fn(*args, **kwargs)* with exponential backoff retries.
98
+
99
+ Used only by the boto3 backend; the legacy MinIO and filesystem
100
+ paths keep their original behaviour.
101
+ """
102
+ import time as _time
103
+
104
+ attempt = 0
105
+ while True:
106
+ try:
107
+ return fn(*args, **kwargs)
108
+ except Exception as exc: # noqa: BLE001
109
+ attempt += 1
110
+ if attempt > self.s3_max_retries:
111
+ raise
112
+ delay = self.s3_base_delay_s * (2 ** (attempt - 1))
113
+ logger.warning(
114
+ "S3 op %s failed (attempt %d/%d): %s — retrying in %.2fs",
115
+ getattr(fn, "__name__", "?"),
116
+ attempt,
117
+ self.s3_max_retries,
118
+ exc,
119
+ delay,
120
+ )
121
+ _time.sleep(delay)
122
+
123
+ def _ensure_bucket(self):
124
+ """Create bucket if it doesn't exist (S3 backends only).
125
+
126
+ For the boto3 backend, optionally create with ObjectLockEnabled
127
+ when s3_auto_create_locked_bucket is True — this MUST happen at
128
+ bucket creation, it cannot be enabled retroactively.
129
+ """
130
+ if self.boto3_client is not None:
131
+ try:
132
+ self._s3_call(self.boto3_client.head_bucket, Bucket=self.bucket)
133
+ except Exception: # noqa: BLE001 — bucket missing
134
+ kwargs = {"Bucket": self.bucket}
135
+ if self.s3_object_lock and self.s3_auto_create_locked_bucket:
136
+ kwargs["ObjectLockEnabledForBucket"] = True
137
+ try:
138
+ self._s3_call(self.boto3_client.create_bucket, **kwargs)
139
+ logger.info(
140
+ "Created forensic bucket (S3): %s%s",
141
+ self.bucket,
142
+ " (Object Lock enabled)"
143
+ if kwargs.get("ObjectLockEnabledForBucket")
144
+ else "",
145
+ )
146
+ except Exception: # noqa: BLE001
147
+ logger.exception("Failed to create S3 forensic bucket %s", self.bucket)
148
+ return
149
+ if self.minio_client is not None:
150
+ try:
151
+ if not self.minio_client.bucket_exists(self.bucket):
152
+ self.minio_client.make_bucket(self.bucket)
153
+ logger.info("Created forensic bucket (MinIO): %s", self.bucket)
154
+ except _S3Error:
155
+ logger.exception("Failed to create forensic bucket %s", self.bucket)
156
+ return
157
+ if self.filesystem_dir is not None:
158
+ return # mkdir already done in __init__
159
+ logger.warning("No forensic backend configured — events kept in memory only")
160
+
161
+ def _restore_chain_state(self):
162
+ """Restore chain_head and record_count from the configured backend."""
163
+ if self.boto3_client is not None:
164
+ try:
165
+ obj = self.boto3_client.get_object(Bucket=self.bucket, Key=_CHAIN_STATE_KEY)
166
+ state = json.loads(obj["Body"].read().decode("utf-8"))
167
+ self.chain_head = state.get("chain_head", "GENESIS")
168
+ self.record_count = state.get("record_count", 0)
169
+ logger.info(
170
+ "Restored forensic chain state (S3): seq=%d, head=%s...",
171
+ self.record_count,
172
+ self.chain_head[:16],
173
+ )
174
+ except Exception: # noqa: BLE001 — NoSuchKey or similar
175
+ logger.info("No existing forensic chain state in S3, starting fresh")
176
+ return
177
+ if self.minio_client is not None:
178
+ try:
179
+ response = self.minio_client.get_object(self.bucket, _CHAIN_STATE_KEY)
180
+ state = json.loads(response.read().decode("utf-8"))
181
+ self.chain_head = state.get("chain_head", "GENESIS")
182
+ self.record_count = state.get("record_count", 0)
183
+ logger.info(
184
+ "Restored forensic chain state (MinIO): seq=%d, head=%s...",
185
+ self.record_count,
186
+ self.chain_head[:16],
187
+ )
188
+ except (_S3Error, json.JSONDecodeError):
189
+ logger.info("No existing forensic chain state found, starting fresh")
190
+ return
191
+ if self.filesystem_dir is not None:
192
+ state_path = self.filesystem_dir / _CHAIN_STATE_KEY
193
+ if state_path.exists():
194
+ try:
195
+ state = json.loads(state_path.read_text(encoding="utf-8"))
196
+ self.chain_head = state.get("chain_head", "GENESIS")
197
+ self.record_count = state.get("record_count", 0)
198
+ logger.info(
199
+ "Restored forensic chain state (filesystem): seq=%d, head=%s...",
200
+ self.record_count,
201
+ self.chain_head[:16],
202
+ )
203
+ except (OSError, json.JSONDecodeError):
204
+ logger.warning("Corrupt chain state at %s — starting fresh", state_path)
205
+
206
+ def _persist_chain_state(self):
207
+ """Persist chain_head and record_count to the configured backend."""
208
+ payload = json.dumps(
209
+ {
210
+ "chain_head": self.chain_head,
211
+ "record_count": self.record_count,
212
+ "updated_at": datetime.now(UTC).isoformat(),
213
+ }
214
+ ).encode("utf-8")
215
+ if self.boto3_client is not None:
216
+ try:
217
+ # Chain-state is mutable by design (overwritten every
218
+ # write) so we deliberately do NOT lock it. Locking the
219
+ # individual records (in _store_to_s3) is what makes the
220
+ # chain tamper-evident.
221
+ self._s3_call(
222
+ self.boto3_client.put_object,
223
+ Bucket=self.bucket,
224
+ Key=_CHAIN_STATE_KEY,
225
+ Body=payload,
226
+ ContentType="application/json",
227
+ )
228
+ except Exception as e: # noqa: BLE001
229
+ logger.warning("Failed to persist chain state (S3): %s", e)
230
+ return
231
+ if self.minio_client is not None:
232
+ try:
233
+ self.minio_client.put_object(
234
+ self.bucket,
235
+ _CHAIN_STATE_KEY,
236
+ BytesIO(payload),
237
+ length=len(payload),
238
+ content_type="application/json",
239
+ )
240
+ except _S3Error as e:
241
+ logger.warning("Failed to persist chain state (MinIO): %s", e)
242
+ return
243
+ if self.filesystem_dir is not None:
244
+ try:
245
+ (self.filesystem_dir / _CHAIN_STATE_KEY).write_bytes(payload)
246
+ except OSError as e:
247
+ logger.warning("Failed to persist chain state (filesystem): %s", e)
248
+
249
+ def _compute_hash(self, data: str) -> str:
250
+ """SHA-256 hash for chain integrity."""
251
+ return hashlib.sha256(data.encode("utf-8")).hexdigest()
252
+
253
+ def record(self, event: dict) -> dict:
254
+ """
255
+ Record an event to the forensic black box.
256
+ Adds hash-chain integrity and eIDAS-style timestamp.
257
+ Returns the forensic record with integrity metadata.
258
+ """
259
+ self.record_count += 1
260
+
261
+ forensic_record = {
262
+ "sequence_number": self.record_count,
263
+ "timestamp_utc": datetime.now(UTC).isoformat(),
264
+ "timestamp_unix_ms": int(time.time() * 1000),
265
+ "previous_hash": self.chain_head,
266
+ "event": event,
267
+ }
268
+
269
+ record_json = json.dumps(forensic_record, sort_keys=True, default=str)
270
+ record_hash = self._compute_hash(record_json)
271
+ forensic_record["record_hash"] = record_hash
272
+
273
+ self.chain_head = record_hash
274
+
275
+ # Store the record and persist the updated chain state
276
+ self._store_to_s3(forensic_record)
277
+ self._persist_chain_state()
278
+
279
+ return {
280
+ "sequence_number": self.record_count,
281
+ "record_hash": record_hash,
282
+ "previous_hash": forensic_record["previous_hash"],
283
+ "stored": (
284
+ self.minio_client is not None
285
+ or self.boto3_client is not None
286
+ or self.filesystem_dir is not None
287
+ ),
288
+ }
289
+
290
+ def _store_to_s3(self, record: dict):
291
+ """Persist a forensic record using the configured backend."""
292
+ ts = datetime.now(UTC)
293
+ key = (
294
+ f"{ts.year}/{ts.month:02d}/{ts.day:02d}/"
295
+ f"{ts.hour:02d}/{record['sequence_number']:08d}.json"
296
+ )
297
+ data = json.dumps(record, sort_keys=True, default=str).encode("utf-8")
298
+ if self.boto3_client is not None:
299
+ put_kwargs: dict = {
300
+ "Bucket": self.bucket,
301
+ "Key": key,
302
+ "Body": data,
303
+ "ContentType": "application/json",
304
+ }
305
+ if self.s3_object_lock:
306
+ from datetime import timedelta
307
+
308
+ retain_until = datetime.now(UTC) + timedelta(days=self.s3_lock_days)
309
+ put_kwargs["ObjectLockMode"] = "COMPLIANCE"
310
+ put_kwargs["ObjectLockRetainUntilDate"] = retain_until
311
+ try:
312
+ self._s3_call(self.boto3_client.put_object, **put_kwargs)
313
+ logger.debug(
314
+ "Stored forensic record (S3%s): %s",
315
+ " + lock" if self.s3_object_lock else "",
316
+ key,
317
+ )
318
+ except Exception: # noqa: BLE001
319
+ logger.exception("Failed to store forensic record %s", key)
320
+ return
321
+ if self.minio_client is not None:
322
+ try:
323
+ self.minio_client.put_object(
324
+ self.bucket,
325
+ key,
326
+ BytesIO(data),
327
+ length=len(data),
328
+ content_type="application/json",
329
+ )
330
+ logger.debug("Stored forensic record (MinIO): %s", key)
331
+ except _S3Error:
332
+ logger.exception("Failed to store forensic record %s", key)
333
+ return
334
+ if self.filesystem_dir is not None:
335
+ path = self.filesystem_dir / key
336
+ try:
337
+ path.parent.mkdir(parents=True, exist_ok=True)
338
+ path.write_bytes(data)
339
+ logger.debug("Stored forensic record (filesystem): %s", path)
340
+ except OSError:
341
+ logger.exception("Failed to write forensic record %s", path)
342
+ return
343
+ # No backend → in-memory only, nothing to do
344
+
345
+ def verify_chain(self, records: list[dict]) -> dict:
346
+ """
347
+ Verify the integrity of a chain of forensic records.
348
+ Returns verification result.
349
+ """
350
+ if not records:
351
+ return {"valid": True, "checked": 0}
352
+
353
+ for i, record in enumerate(records):
354
+ event_copy = {k: v for k, v in record.items() if k != "record_hash"}
355
+ recomputed = self._compute_hash(json.dumps(event_copy, sort_keys=True, default=str))
356
+ if recomputed != record.get("record_hash"):
357
+ return {
358
+ "valid": False,
359
+ "error": f"Hash mismatch at sequence {record.get('sequence_number')}",
360
+ "checked": i,
361
+ }
362
+
363
+ if i > 0:
364
+ expected_prev = records[i - 1].get("record_hash")
365
+ actual_prev = record.get("previous_hash")
366
+ if expected_prev != actual_prev:
367
+ return {
368
+ "valid": False,
369
+ "error": f"Chain broken at sequence {record.get('sequence_number')}",
370
+ "checked": i,
371
+ }
372
+
373
+ return {"valid": True, "checked": len(records)}
374
+
375
+ def get_stats(self) -> dict:
376
+ return {
377
+ "record_count": self.record_count,
378
+ "chain_head": self.chain_head[:16] + "...",
379
+ "storage_available": self.minio_client is not None,
380
+ }