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,173 @@
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 — MCP transport adapter.
16
+
17
+ Converts JSON-RPC 2.0 (MCP wire format) to/from the protocol-agnostic
18
+ :class:`GovernanceRequest` / :class:`GovernanceResponse` dataclasses.
19
+
20
+ The governance engine never sees JSON-RPC — this adapter is the only
21
+ place that knows about MCP framing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ from typing import Any
29
+
30
+ from admina.core.types import GovernanceRequest, GovernanceResponse
31
+
32
+ logger = logging.getLogger("admina.transport.mcp")
33
+
34
+
35
+ def parse_request(
36
+ body: dict[str, Any],
37
+ *,
38
+ session_id: str | None = None,
39
+ agent_id: str | None = None,
40
+ ) -> GovernanceRequest:
41
+ """Convert a JSON-RPC 2.0 MCP request dict into a GovernanceRequest.
42
+
43
+ Args:
44
+ body: The parsed JSON body of an incoming ``POST /mcp`` call.
45
+ session_id: Optional session identifier (from ``X-Session-Id`` header).
46
+ agent_id: Optional agent identifier (from ``X-Agent-Id`` header).
47
+
48
+ Returns:
49
+ A populated :class:`GovernanceRequest`.
50
+ """
51
+ method = body.get("method", "unknown")
52
+ params = body.get("params", {})
53
+ content_str = json.dumps(body, default=str)
54
+
55
+ return GovernanceRequest(
56
+ content=content_str,
57
+ method=method,
58
+ direction="inbound",
59
+ session_id=session_id,
60
+ agent_id=agent_id,
61
+ protocol="mcp",
62
+ metadata={
63
+ "jsonrpc_id": body.get("id"),
64
+ "params": params,
65
+ },
66
+ raw=body,
67
+ )
68
+
69
+
70
+ def format_block_response(
71
+ gov_response: GovernanceResponse,
72
+ original_body: dict[str, Any],
73
+ ) -> dict[str, Any]:
74
+ """Format a BLOCK governance response as a JSON-RPC 2.0 error.
75
+
76
+ Args:
77
+ gov_response: The governance engine's decision.
78
+ original_body: The original JSON-RPC request (for the ``id`` field).
79
+
80
+ Returns:
81
+ A JSON-RPC 2.0 error response dict.
82
+ """
83
+ return {
84
+ "jsonrpc": "2.0",
85
+ "id": original_body.get("id"),
86
+ "error": {
87
+ "code": -32600,
88
+ "message": "Request blocked by Admina governance",
89
+ "data": {
90
+ "event_id": gov_response.request_id,
91
+ "reason": "injection_detected",
92
+ "risk_level": gov_response.risk_level,
93
+ "governance_latency_us": round(gov_response.latency_us, 2),
94
+ },
95
+ },
96
+ }
97
+
98
+
99
+ def format_circuit_break_response(
100
+ gov_response: GovernanceResponse,
101
+ original_body: dict[str, Any],
102
+ ) -> dict[str, Any]:
103
+ """Format a CIRCUIT_BREAK governance response as a JSON-RPC 2.0 error.
104
+
105
+ Args:
106
+ gov_response: The governance engine's decision.
107
+ original_body: The original JSON-RPC request (for the ``id`` field).
108
+
109
+ Returns:
110
+ A JSON-RPC 2.0 error response dict.
111
+ """
112
+ return {
113
+ "jsonrpc": "2.0",
114
+ "id": original_body.get("id"),
115
+ "error": {
116
+ "code": -32000,
117
+ "message": "Circuit breaker activated: reasoning loop detected",
118
+ "data": {
119
+ "event_id": gov_response.request_id,
120
+ "reason": "reasoning_loop",
121
+ "similarity": gov_response.metadata.get("similarity"),
122
+ "governance_latency_us": round(gov_response.latency_us, 2),
123
+ },
124
+ },
125
+ }
126
+
127
+
128
+ def format_allow_headers(
129
+ gov_response: GovernanceResponse,
130
+ *,
131
+ forensic_hash: str | None = None,
132
+ ) -> dict[str, str]:
133
+ """Build the extra HTTP headers added to a successful proxy response.
134
+
135
+ Args:
136
+ gov_response: The governance engine's ALLOW decision.
137
+ forensic_hash: Optional truncated forensic record hash.
138
+
139
+ Returns:
140
+ A dict of HTTP header name → value.
141
+ """
142
+ headers: dict[str, str] = {
143
+ "X-Admina-Event-Id": gov_response.request_id,
144
+ "X-Admina-Governance-Action": gov_response.action,
145
+ "X-Admina-Latency-Us": str(round(gov_response.latency_us, 2)),
146
+ }
147
+ if forensic_hash:
148
+ headers["X-Admina-Forensic-Hash"] = forensic_hash
149
+ return headers
150
+
151
+
152
+ def extract_text_fields(obj: Any, depth: int = 0) -> list[str]:
153
+ """Recursively extract all string values from a dict/list.
154
+
155
+ Args:
156
+ obj: A parsed JSON value (dict, list, or scalar).
157
+ depth: Current recursion depth (capped at 5).
158
+
159
+ Returns:
160
+ A flat list of string values found.
161
+ """
162
+ if depth > 5:
163
+ return []
164
+ texts: list[str] = []
165
+ if isinstance(obj, str):
166
+ texts.append(obj)
167
+ elif isinstance(obj, dict):
168
+ for v in obj.values():
169
+ texts.extend(extract_text_fields(v, depth + 1))
170
+ elif isinstance(obj, list):
171
+ for item in obj:
172
+ texts.extend(extract_text_fields(item, depth + 1))
173
+ return texts
@@ -0,0 +1,356 @@
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 — Plugin registry with discovery and validation.
16
+
17
+ The registry scans three locations for plugins:
18
+
19
+ 1. ``plugins/builtin/`` — shipped with Admina.
20
+ 2. ``~/.admina/plugins/`` — user-installed plugins.
21
+ 3. ``admina.yaml`` ``plugins:`` list — explicit module paths.
22
+
23
+ Each discovered module is imported, its classes are validated against the
24
+ 9 base classes, and matching plugins are registered for runtime lookup.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import importlib
30
+ import inspect
31
+ import logging
32
+ import sys
33
+ from pathlib import Path
34
+ from types import ModuleType
35
+
36
+ from admina.plugins.base import (
37
+ BaseAlertChannel,
38
+ BaseAuthProvider,
39
+ BaseComplianceTemplate,
40
+ BaseDataConnector,
41
+ BaseForensicStore,
42
+ BaseGovernanceGuard,
43
+ BaseModelAdapter,
44
+ BasePIIEngine,
45
+ BaseTransportAdapter,
46
+ )
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Canonical mapping from plugin type key to ABC.
51
+ PLUGIN_TYPES: dict[str, type] = {
52
+ "model_adapter": BaseModelAdapter,
53
+ "data_connector": BaseDataConnector,
54
+ "governance_guard": BaseGovernanceGuard,
55
+ "compliance_template": BaseComplianceTemplate,
56
+ "transport_adapter": BaseTransportAdapter,
57
+ "forensic_store": BaseForensicStore,
58
+ "auth_provider": BaseAuthProvider,
59
+ "pii_engine": BasePIIEngine,
60
+ "alert_channel": BaseAlertChannel,
61
+ }
62
+
63
+ # Reverse lookup: ABC → type key.
64
+ _BASE_TO_TYPE: dict[type, str] = {v: k for k, v in PLUGIN_TYPES.items()}
65
+
66
+
67
+ def _plugin_type_for_class(cls: type) -> str | None:
68
+ """Return the plugin type key for *cls*, or ``None`` if not a plugin."""
69
+ for base, key in _BASE_TO_TYPE.items():
70
+ if issubclass(cls, base):
71
+ return key
72
+ return None
73
+
74
+
75
+ class PluginRegistry:
76
+ """Central registry for all Admina plugins.
77
+
78
+ Usage::
79
+
80
+ registry = PluginRegistry()
81
+ registry.discover() # scan default locations
82
+ adapter = registry.get("model_adapter", "ollama")
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ # {type_key: {name: class}}
87
+ self._plugins: dict[str, dict[str, type]] = {key: {} for key in PLUGIN_TYPES}
88
+
89
+ # ── Public API ──────────────────────────────────────────────
90
+
91
+ def register(self, cls: type) -> None:
92
+ """Register a single plugin class.
93
+
94
+ Args:
95
+ cls: A concrete subclass of one of the 9 base classes.
96
+
97
+ Raises:
98
+ TypeError: If *cls* is not a concrete subclass of a known base.
99
+ ValueError: If the plugin name is already registered for its type.
100
+ """
101
+ if inspect.isabstract(cls):
102
+ raise TypeError(f"Cannot register abstract class {cls.__name__!r}")
103
+
104
+ type_key = _plugin_type_for_class(cls)
105
+ if type_key is None:
106
+ raise TypeError(f"{cls.__name__!r} does not extend any known plugin base class")
107
+
108
+ name = self._extract_name(cls, type_key)
109
+ bucket = self._plugins[type_key]
110
+
111
+ if name in bucket:
112
+ logger.warning(
113
+ "Plugin %s/%s already registered — overwriting with %s",
114
+ type_key,
115
+ name,
116
+ cls.__name__,
117
+ )
118
+
119
+ bucket[name] = cls
120
+ logger.debug("Registered plugin %s/%s → %s", type_key, name, cls.__name__)
121
+
122
+ def get(self, type_key: str, name: str) -> type | None:
123
+ """Look up a registered plugin class by type and name.
124
+
125
+ Args:
126
+ type_key: One of the keys in :data:`PLUGIN_TYPES`
127
+ (e.g. ``"model_adapter"``).
128
+ name: The plugin name (e.g. ``"ollama"``).
129
+
130
+ Returns:
131
+ The plugin class, or ``None`` if not found.
132
+ """
133
+ return self._plugins.get(type_key, {}).get(name)
134
+
135
+ def list(self, type_key: str) -> dict[str, type]:
136
+ """Return all registered plugins for a given type.
137
+
138
+ Args:
139
+ type_key: One of the keys in :data:`PLUGIN_TYPES`.
140
+
141
+ Returns:
142
+ A dict mapping name → class.
143
+ """
144
+ return dict(self._plugins.get(type_key, {}))
145
+
146
+ def list_all(self) -> dict[str, dict[str, type]]:
147
+ """Return every registered plugin, grouped by type."""
148
+ return {k: dict(v) for k, v in self._plugins.items()}
149
+
150
+ def discover(
151
+ self,
152
+ *,
153
+ builtin_path: Path | None = None,
154
+ user_path: Path | None = None,
155
+ extra_modules: list[str] | None = None,
156
+ entry_point_group: str = "admina.plugins",
157
+ ) -> int:
158
+ """Scan plugin sources and register all found plugins.
159
+
160
+ Sources scanned, in order:
161
+
162
+ 1. Builtin (``plugins/builtin/``)
163
+ 2. User (``~/.admina/plugins/``)
164
+ 3. Explicit module paths from ``admina.yaml`` ``plugins:``
165
+ 4. Python entry-points group ``admina.plugins`` — third-party
166
+ packages register plugins via their pyproject.toml without
167
+ being on the filesystem. Example::
168
+
169
+ [project.entry-points."admina.plugins"]
170
+ my_adapter = "mypkg.module:MyAdapter"
171
+
172
+ or pointing at a module to pick up every concrete plugin
173
+ class it defines::
174
+
175
+ [project.entry-points."admina.plugins"]
176
+ my_pack = "mypkg.plugins"
177
+
178
+ Args:
179
+ builtin_path: Override for ``plugins/builtin/``.
180
+ user_path: Override for ``~/.admina/plugins/``.
181
+ extra_modules: Dotted module paths from admina.yaml.
182
+ entry_point_group: Entry-points group name to scan.
183
+ Default ``admina.plugins``. Empty string disables it.
184
+
185
+ Returns:
186
+ Total number of plugins registered during this call.
187
+ """
188
+ count = 0
189
+
190
+ # 1. Built-in plugins — locate the top-level `plugins.builtin`
191
+ # package via import, so editable installs and installed wheels
192
+ # both resolve correctly.
193
+ if builtin_path is None:
194
+ try:
195
+ import admina.plugins.builtin as _builtin_pkg
196
+
197
+ builtin_path = Path(next(iter(_builtin_pkg.__path__)))
198
+ except (ImportError, StopIteration):
199
+ builtin_path = Path(__file__).parent / "builtin"
200
+ count += self._scan_directory(builtin_path)
201
+
202
+ # 2. User plugins
203
+ if user_path is None:
204
+ user_path = Path.home() / ".admina" / "plugins"
205
+ count += self._scan_directory(user_path)
206
+
207
+ # 3. Explicit module paths from admina.yaml
208
+ for mod_path in extra_modules or []:
209
+ count += self._load_module_path(mod_path)
210
+
211
+ # 4. Entry-points (third-party pip-installed packages)
212
+ if entry_point_group:
213
+ count += self._scan_entry_points(entry_point_group)
214
+
215
+ logger.info("Discovery complete — %d plugin(s) registered", count)
216
+ return count
217
+
218
+ def _scan_entry_points(self, group: str) -> int:
219
+ """Discover and register plugins exposed via Python entry-points."""
220
+ count = 0
221
+ try:
222
+ from importlib.metadata import entry_points
223
+ except ImportError: # pragma: no cover (Python < 3.8)
224
+ return 0
225
+ try:
226
+ eps = entry_points(group=group)
227
+ except TypeError:
228
+ # Python 3.9 entry_points() returns a dict
229
+ eps = entry_points().get(group, []) # type: ignore[assignment]
230
+ for ep in eps:
231
+ try:
232
+ obj = ep.load()
233
+ except Exception: # noqa: BLE001 — third-party code, isolate
234
+ logger.warning(
235
+ "Failed to load entry-point plugin %s from %s",
236
+ ep.name,
237
+ ep.value,
238
+ exc_info=True,
239
+ )
240
+ continue
241
+ if inspect.isclass(obj):
242
+ try:
243
+ self.register(obj)
244
+ count += 1
245
+ except (TypeError, ValueError):
246
+ logger.warning(
247
+ "Entry-point %s exposes %s but it is not a valid plugin",
248
+ ep.name,
249
+ obj.__name__,
250
+ )
251
+ elif inspect.ismodule(obj):
252
+ count += self._register_from_module(obj)
253
+ else:
254
+ logger.warning(
255
+ "Entry-point %s loaded %r — expected a class or module",
256
+ ep.name,
257
+ obj,
258
+ )
259
+ return count
260
+
261
+ # ── Internal helpers ────────────────────────────────────────
262
+
263
+ def _scan_directory(self, directory: Path) -> int:
264
+ """Import all ``.py`` modules under *directory* and register plugins."""
265
+ if not directory.is_dir():
266
+ logger.debug("Plugin directory does not exist: %s", directory)
267
+ return 0
268
+
269
+ count = 0
270
+ for py_file in sorted(directory.rglob("*.py")):
271
+ if py_file.name == "__init__.py":
272
+ continue
273
+ count += self._load_file(py_file)
274
+
275
+ return count
276
+
277
+ def _load_file(self, py_file: Path) -> int:
278
+ """Import a single ``.py`` file and register all plugin classes."""
279
+ mod_name = f"_admina_plugin_{py_file.stem}_{id(py_file)}"
280
+ try:
281
+ spec = importlib.util.spec_from_file_location(mod_name, str(py_file))
282
+ if spec is None or spec.loader is None:
283
+ return 0
284
+ mod = importlib.util.module_from_spec(spec)
285
+ sys.modules[mod_name] = mod
286
+ spec.loader.exec_module(mod)
287
+ except (ImportError, AttributeError, RuntimeError):
288
+ logger.warning("Failed to import plugin file %s", py_file, exc_info=True)
289
+ return 0
290
+
291
+ return self._register_from_module(mod)
292
+
293
+ def _load_module_path(self, mod_path: str) -> int:
294
+ """Import a module by dotted path and register all plugin classes."""
295
+ try:
296
+ mod = importlib.import_module(mod_path)
297
+ except ImportError:
298
+ logger.warning("Failed to import plugin module %r", mod_path, exc_info=True)
299
+ return 0
300
+
301
+ return self._register_from_module(mod)
302
+
303
+ def _register_from_module(self, mod: ModuleType) -> int:
304
+ """Find and register all concrete plugin classes in *mod*."""
305
+ count = 0
306
+ for _attr_name, obj in inspect.getmembers(mod, inspect.isclass):
307
+ # Skip the ABCs themselves and classes not defined in this module
308
+ if obj.__module__ != mod.__name__:
309
+ continue
310
+ if inspect.isabstract(obj):
311
+ continue
312
+ if _plugin_type_for_class(obj) is not None:
313
+ self.register(obj)
314
+ count += 1
315
+ return count
316
+
317
+ @staticmethod
318
+ def _extract_name(cls: type, type_key: str) -> str:
319
+ """Extract the canonical name from a plugin class.
320
+
321
+ Tries the property/attribute that matches the base class contract
322
+ (``name``, ``protocol_name``, ``store_name``, etc.). Falls back
323
+ to the lower-cased class name.
324
+ """
325
+ # Map type_key → property name on the ABC
326
+ name_attrs = {
327
+ "model_adapter": "name",
328
+ "data_connector": "name",
329
+ "governance_guard": "name",
330
+ "compliance_template": "framework_name",
331
+ "transport_adapter": "protocol_name",
332
+ "forensic_store": "store_name",
333
+ "auth_provider": "provider_name",
334
+ "pii_engine": "supported_languages",
335
+ "alert_channel": "channel_name",
336
+ }
337
+
338
+ attr = name_attrs.get(type_key, "name")
339
+
340
+ # For pii_engine, the identifier is not a name property — use class name
341
+ if type_key == "pii_engine":
342
+ return cls.__name__.lower()
343
+
344
+ # Try to get from a class-level attribute (not an instance property)
345
+ # Instantiation-free: check if the class defines the attr as a plain value
346
+ for klass in cls.__mro__:
347
+ if attr in klass.__dict__:
348
+ val = klass.__dict__[attr]
349
+ # If it's a plain value (str), use it
350
+ if isinstance(val, str):
351
+ return val
352
+ # If it's a property, we can't call it without an instance
353
+ break
354
+
355
+ # Fallback: lower-cased class name
356
+ return cls.__name__.lower()
@@ -0,0 +1,15 @@
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 Governance Proxy
@@ -0,0 +1,17 @@
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 proxy API sub-package — dashboard and integration endpoints."""
16
+
17
+ from __future__ import annotations