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
admina/core/secrets.py ADDED
@@ -0,0 +1,257 @@
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 — Secret Vault
16
+
17
+ Generates, stores, and retrieves secrets for the Admina platform.
18
+ Secrets are encrypted at rest using Fernet symmetric encryption.
19
+
20
+ Vault layout (per-project, under ``<project>/.admina/``):
21
+
22
+ .admina/
23
+ vault.key — Fernet key (mode 0600, never committed)
24
+ secrets.json — encrypted secret values
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import re
32
+ import secrets
33
+ import string
34
+ from pathlib import Path
35
+
36
+ from cryptography.fernet import Fernet
37
+
38
+ # ── Secret Generation ────────────────────────────────────────
39
+
40
+
41
+ def generate_api_key() -> str:
42
+ """Generate a 64-char hex API key (256-bit entropy)."""
43
+ return secrets.token_hex(32)
44
+
45
+
46
+ def generate_password(length: int = 20) -> str:
47
+ """Generate a random password meeting quality requirements.
48
+
49
+ Guarantees at least one uppercase, one lowercase, one digit,
50
+ and one special character.
51
+ """
52
+ if length < 12:
53
+ length = 12
54
+
55
+ upper = string.ascii_uppercase
56
+ lower = string.ascii_lowercase
57
+ digits = string.digits
58
+ special = "!@#%&*+-"
59
+
60
+ # Guarantee one of each class
61
+ password_chars = [
62
+ secrets.choice(upper),
63
+ secrets.choice(lower),
64
+ secrets.choice(digits),
65
+ secrets.choice(special),
66
+ ]
67
+
68
+ # Fill the rest from the full alphabet
69
+ alphabet = upper + lower + digits + special
70
+ for _ in range(length - len(password_chars)):
71
+ password_chars.append(secrets.choice(alphabet))
72
+
73
+ # Shuffle to avoid predictable positions
74
+ shuffled = list(password_chars)
75
+ for i in range(len(shuffled) - 1, 0, -1):
76
+ j = secrets.randbelow(i + 1)
77
+ shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
78
+
79
+ return "".join(shuffled)
80
+
81
+
82
+ # ── Password Validation ──────────────────────────────────────
83
+
84
+ _MIN_PASSWORD_LENGTH = 12
85
+
86
+
87
+ def validate_password(password: str) -> tuple[bool, list[str]]:
88
+ """Check password quality. Returns (ok, list_of_issues)."""
89
+ issues: list[str] = []
90
+
91
+ if len(password) < _MIN_PASSWORD_LENGTH:
92
+ issues.append(f"Minimum {_MIN_PASSWORD_LENGTH} characters (got {len(password)})")
93
+ if not re.search(r"[A-Z]", password):
94
+ issues.append("At least one uppercase letter")
95
+ if not re.search(r"[a-z]", password):
96
+ issues.append("At least one lowercase letter")
97
+ if not re.search(r"\d", password):
98
+ issues.append("At least one digit")
99
+ if not re.search(r"[^A-Za-z0-9]", password):
100
+ issues.append("At least one special character")
101
+
102
+ return (len(issues) == 0, issues)
103
+
104
+
105
+ # ── Secret Vault ─────────────────────────────────────────────
106
+
107
+ # Keys managed by the vault
108
+ VAULT_KEYS = (
109
+ "ADMINA_API_KEY",
110
+ "ADMINA_DASHBOARD_PASSWORD",
111
+ "CLICKHOUSE_PASSWORD",
112
+ "GRAFANA_ADMIN_PASSWORD",
113
+ "MINIO_SECRET_KEY",
114
+ # Used by the optional Open WebUI service in docker-compose when
115
+ # ai_infra.webui is enabled. Generated unconditionally so the
116
+ # compose file works whether or not the operator picked the
117
+ # ai_infra domain — it's just a random session secret.
118
+ "WEBUI_SECRET_KEY",
119
+ )
120
+
121
+
122
+ class SecretVault:
123
+ """Encrypted secret store backed by a local JSON file.
124
+
125
+ Parameters
126
+ ----------
127
+ project_dir:
128
+ Root of the Admina project. The vault lives at
129
+ ``<project_dir>/.admina/secrets.json``.
130
+ """
131
+
132
+ def __init__(self, project_dir: str | Path) -> None:
133
+ self._dir = Path(project_dir) / ".admina"
134
+ self._key_path = self._dir / "vault.key"
135
+ self._secrets_path = self._dir / "secrets.json"
136
+ self._fernet: Fernet | None = None
137
+
138
+ # ── public API ────────────────────────────────────────
139
+
140
+ @property
141
+ def is_initialized(self) -> bool:
142
+ """True if the vault has been bootstrapped at least once."""
143
+ return self._secrets_path.is_file() and self._key_path.is_file()
144
+
145
+ def bootstrap(self) -> dict[str, str]:
146
+ """Generate all platform secrets and store them in the vault.
147
+
148
+ Returns the generated secrets as a plain dict (for one-time display).
149
+ """
150
+ generated: dict[str, str] = {
151
+ "ADMINA_API_KEY": generate_api_key(),
152
+ "ADMINA_DASHBOARD_PASSWORD": generate_password(),
153
+ "CLICKHOUSE_PASSWORD": generate_password(),
154
+ "GRAFANA_ADMIN_PASSWORD": generate_password(),
155
+ "MINIO_SECRET_KEY": generate_password(),
156
+ # Independent random secret for the optional Open WebUI
157
+ # session cookie — never share it with the user-facing
158
+ # dashboard password.
159
+ "WEBUI_SECRET_KEY": generate_api_key(),
160
+ }
161
+
162
+ # Use the same dashboard password for all web UIs (but NOT
163
+ # the WEBUI session secret, which is internal only).
164
+ shared_password = generated["ADMINA_DASHBOARD_PASSWORD"]
165
+ generated["CLICKHOUSE_PASSWORD"] = shared_password
166
+ generated["GRAFANA_ADMIN_PASSWORD"] = shared_password
167
+ generated["MINIO_SECRET_KEY"] = shared_password
168
+
169
+ self._ensure_dir()
170
+ self._ensure_key()
171
+ self._save(generated)
172
+ return generated
173
+
174
+ def get(self, key: str) -> str | None:
175
+ """Retrieve a single secret by key. Returns None if not found."""
176
+ data = self._load()
177
+ return data.get(key)
178
+
179
+ def get_all(self) -> dict[str, str]:
180
+ """Return all secrets as a plain dict."""
181
+ return self._load()
182
+
183
+ def set(self, key: str, value: str) -> None:
184
+ """Set or update a single secret."""
185
+ data = self._load()
186
+ data[key] = value
187
+ self._save(data)
188
+
189
+ def update_password(self, new_password: str) -> None:
190
+ """Update the shared web UI password across all services."""
191
+ data = self._load()
192
+ data["ADMINA_DASHBOARD_PASSWORD"] = new_password
193
+ data["CLICKHOUSE_PASSWORD"] = new_password
194
+ data["GRAFANA_ADMIN_PASSWORD"] = new_password
195
+ data["MINIO_SECRET_KEY"] = new_password
196
+ self._save(data)
197
+
198
+ def export_env(self) -> dict[str, str]:
199
+ """Return secrets suitable for injection into subprocess env.
200
+
201
+ Same as get_all() but filters to known VAULT_KEYS only.
202
+ """
203
+ data = self._load()
204
+ return {k: v for k, v in data.items() if k in VAULT_KEYS}
205
+
206
+ def write_dotenv(self, env_path: str | Path) -> None:
207
+ """Write a .env file with the current vault secrets.
208
+
209
+ The file is written with mode 0600.
210
+ """
211
+ data = self.export_env()
212
+ lines = [
213
+ "# Auto-generated by admina vault. Do not edit manually.",
214
+ "# Regenerate with: admina password reset",
215
+ "",
216
+ ]
217
+ for key in VAULT_KEYS:
218
+ value = data.get(key, "")
219
+ # Use double quotes — Docker Compose env_file strips them correctly.
220
+ # Single quotes are taken literally by some env_file parsers.
221
+ lines.append(f'{key}="{value}"')
222
+ lines.append("")
223
+
224
+ env_path = Path(env_path)
225
+ env_path.write_text("\n".join(lines))
226
+ os.chmod(env_path, 0o600)
227
+
228
+ # ── internal ──────────────────────────────────────────
229
+
230
+ def _ensure_dir(self) -> None:
231
+ self._dir.mkdir(parents=True, exist_ok=True)
232
+
233
+ def _ensure_key(self) -> None:
234
+ if not self._key_path.is_file():
235
+ key = Fernet.generate_key()
236
+ self._key_path.write_bytes(key)
237
+ os.chmod(self._key_path, 0o600)
238
+ self._fernet = None # reset cached fernet
239
+
240
+ def _get_fernet(self) -> Fernet:
241
+ if self._fernet is None:
242
+ key = self._key_path.read_bytes().strip()
243
+ self._fernet = Fernet(key)
244
+ return self._fernet
245
+
246
+ def _save(self, data: dict[str, str]) -> None:
247
+ plaintext = json.dumps(data, indent=2).encode()
248
+ encrypted = self._get_fernet().encrypt(plaintext)
249
+ self._secrets_path.write_bytes(encrypted)
250
+ os.chmod(self._secrets_path, 0o600)
251
+
252
+ def _load(self) -> dict[str, str]:
253
+ if not self._secrets_path.is_file():
254
+ return {}
255
+ encrypted = self._secrets_path.read_bytes()
256
+ plaintext = self._get_fernet().decrypt(encrypted)
257
+ return json.loads(plaintext)
admina/core/types.py ADDED
@@ -0,0 +1,146 @@
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 — Protocol-agnostic governance types.
16
+
17
+ These dataclasses decouple the governance engine from any wire format.
18
+ Transport adapters (MCP, A2A, AG-UI, REST) convert to/from these types.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ import uuid
25
+ from dataclasses import dataclass, field
26
+ from enum import Enum
27
+ from typing import Any, Literal
28
+
29
+ __all__ = [
30
+ "RiskLevel",
31
+ "GovernanceAction",
32
+ "EventType",
33
+ "GovernanceRequest",
34
+ "GovernanceResponse",
35
+ ]
36
+
37
+
38
+ class RiskLevel(str, Enum):
39
+ """Risk severity levels used across all governance domains.
40
+
41
+ Using ``str`` mixin so values serialise directly to JSON strings.
42
+ """
43
+
44
+ LOW = "low"
45
+ MEDIUM = "medium"
46
+ HIGH = "high"
47
+ CRITICAL = "critical"
48
+
49
+
50
+ class GovernanceAction(str, Enum):
51
+ """Actions the governance engine can take on a request."""
52
+
53
+ ALLOW = "allow"
54
+ BLOCK = "block"
55
+ REDACT = "redact"
56
+ ESCALATE = "escalate"
57
+ CIRCUIT_BREAK = "circuit_break"
58
+
59
+
60
+ class EventType(str, Enum):
61
+ """Unified event types used across proxy and SDK."""
62
+
63
+ # Proxy / MCP events
64
+ MCP_REQUEST = "mcp_request"
65
+ MCP_RESPONSE = "mcp_response"
66
+ INJECTION_DETECTED = "injection_detected"
67
+ PII_REDACTED = "pii_redacted"
68
+ LOOP_DETECTED = "loop_detected"
69
+ POLICY_VIOLATION = "policy_violation"
70
+ CIRCUIT_BREAK = "circuit_break"
71
+
72
+ # SDK / framework events
73
+ MODEL_CALL = "model.call"
74
+ MODEL_RESPONSE = "model.response"
75
+ DATA_ACCESS = "data.access"
76
+ DATA_CLASSIFY = "data.classify"
77
+ DATA_REDACT = "data.redact"
78
+ AGENT_REQUEST = "agent.request"
79
+ AGENT_RESPONSE = "agent.response"
80
+ GOVERNANCE_DECISION = "governance.decision"
81
+ COMPLIANCE_CHECK = "compliance.check"
82
+
83
+
84
+ @dataclass
85
+ class GovernanceRequest:
86
+ """A protocol-agnostic inbound request to the governance engine.
87
+
88
+ Transport adapters normalize protocol-specific messages into this
89
+ dataclass before the governance pipeline processes them.
90
+ """
91
+
92
+ content: str
93
+ method: str = ""
94
+ direction: Literal["inbound", "outbound"] = "inbound"
95
+ session_id: str | None = None
96
+ user_id: str | None = None
97
+ agent_id: str | None = None
98
+ protocol: str = "unknown"
99
+ metadata: dict[str, Any] = field(default_factory=dict)
100
+ raw: Any = None
101
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
102
+ timestamp_us: float = field(default_factory=lambda: time.time() * 1_000_000)
103
+
104
+ def to_dict(self) -> dict[str, Any]:
105
+ """Serialize to a plain dict (excluding ``raw``)."""
106
+ return {
107
+ "request_id": self.request_id,
108
+ "content": self.content,
109
+ "method": self.method,
110
+ "direction": self.direction,
111
+ "session_id": self.session_id,
112
+ "user_id": self.user_id,
113
+ "agent_id": self.agent_id,
114
+ "protocol": self.protocol,
115
+ "metadata": self.metadata,
116
+ "timestamp_us": self.timestamp_us,
117
+ }
118
+
119
+
120
+ @dataclass
121
+ class GovernanceResponse:
122
+ """The governance engine's decision for a single request.
123
+
124
+ Returned by the governance pipeline and converted back to wire format
125
+ by the transport adapter.
126
+ """
127
+
128
+ content: str
129
+ action: Literal["ALLOW", "BLOCK", "REDACT", "CIRCUIT_BREAK"] = "ALLOW"
130
+ risk_level: Literal["LOW", "MEDIUM", "HIGH", "CRITICAL"] = "LOW"
131
+ domain: str = ""
132
+ latency_us: float = 0.0
133
+ metadata: dict[str, Any] = field(default_factory=dict)
134
+ request_id: str = ""
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ """Serialize to a plain dict."""
138
+ return {
139
+ "request_id": self.request_id,
140
+ "content": self.content,
141
+ "action": self.action,
142
+ "risk_level": self.risk_level,
143
+ "domain": self.domain,
144
+ "latency_us": self.latency_us,
145
+ "metadata": self.metadata,
146
+ }
@@ -0,0 +1,8 @@
1
+ # Copyright © 2025–2026 Stefano Noferi & Admina contributors
2
+ # Licensed under the Apache License, Version 2.0
3
+ """Admina built-in dashboard.
4
+
5
+ Static SPA (Alpine.js) served by FastAPI when running `admina dev`
6
+ locally (no Docker). The same files are also served by nginx in the
7
+ Docker stack — see ../../dashboard/Dockerfile.
8
+ """
Binary file