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,156 @@
|
|
|
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 — Multi-Upstream MCP Router
|
|
17
|
+
Routes MCP calls to correct upstream servers. Enables OpenClaw integration.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("admina.router")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ServerRoute:
|
|
30
|
+
"""A registered upstream MCP server."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
upstream_url: str = ""
|
|
34
|
+
server_type: str = "sse" # sse | http | stdio
|
|
35
|
+
proxy_mode: str = "transparent" # transparent | stdio_wrap
|
|
36
|
+
headers: dict = field(default_factory=dict)
|
|
37
|
+
tool_names: list = field(default_factory=list) # tools this server provides
|
|
38
|
+
healthy: bool = True
|
|
39
|
+
request_count: int = 0
|
|
40
|
+
block_count: int = 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MultiUpstreamRouter:
|
|
44
|
+
"""
|
|
45
|
+
Manages routing to multiple MCP upstream servers.
|
|
46
|
+
|
|
47
|
+
Loading:
|
|
48
|
+
router = MultiUpstreamRouter()
|
|
49
|
+
router.load_config("/app/routing.json")
|
|
50
|
+
|
|
51
|
+
Routing:
|
|
52
|
+
route = router.resolve("github") # by server name
|
|
53
|
+
route = router.resolve_by_tool("git_push") # by tool name (after discovery)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, default_upstream: str = "http://localhost:9000"):
|
|
57
|
+
self.default_upstream = default_upstream
|
|
58
|
+
self.routes: dict[str, ServerRoute] = {}
|
|
59
|
+
self.tool_to_server: dict[str, str] = {}
|
|
60
|
+
self.governance_config: dict = {}
|
|
61
|
+
self._loaded = False
|
|
62
|
+
|
|
63
|
+
def load_config(self, config_path: str | Path) -> None:
|
|
64
|
+
"""Load routing configuration from JSON file."""
|
|
65
|
+
path = Path(config_path)
|
|
66
|
+
if not path.exists():
|
|
67
|
+
logger.warning("Routing config not found: %s, using default upstream", path)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
with open(path) as f:
|
|
71
|
+
config = json.load(f)
|
|
72
|
+
|
|
73
|
+
self.default_upstream = config.get("default_upstream", self.default_upstream)
|
|
74
|
+
self.governance_config = config.get("governance", {})
|
|
75
|
+
|
|
76
|
+
for name, route_def in config.get("routes", {}).items():
|
|
77
|
+
self.routes[name] = ServerRoute(
|
|
78
|
+
name=name,
|
|
79
|
+
upstream_url=route_def.get("upstream_url", ""),
|
|
80
|
+
server_type=route_def.get("type", "sse"),
|
|
81
|
+
proxy_mode=route_def.get("proxy_mode", "transparent"),
|
|
82
|
+
headers=route_def.get("headers", {}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self._loaded = True
|
|
86
|
+
logger.info("Loaded %d upstream routes from %s", len(self.routes), path)
|
|
87
|
+
|
|
88
|
+
def resolve(self, server_name: str) -> ServerRoute | None:
|
|
89
|
+
"""Resolve a server route by name."""
|
|
90
|
+
route = self.routes.get(server_name)
|
|
91
|
+
if route:
|
|
92
|
+
route.request_count += 1
|
|
93
|
+
return route
|
|
94
|
+
|
|
95
|
+
def resolve_by_tool(self, tool_name: str) -> ServerRoute | None:
|
|
96
|
+
"""Resolve which server provides a given tool."""
|
|
97
|
+
server_name = self.tool_to_server.get(tool_name)
|
|
98
|
+
if server_name:
|
|
99
|
+
return self.resolve(server_name)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def register_tool_mapping(self, server_name: str, tool_names: list[str]) -> None:
|
|
103
|
+
"""After tool discovery, map tool names to their server."""
|
|
104
|
+
for tool in tool_names:
|
|
105
|
+
self.tool_to_server[tool] = server_name
|
|
106
|
+
route = self.routes.get(server_name)
|
|
107
|
+
if route:
|
|
108
|
+
route.tool_names = tool_names
|
|
109
|
+
logger.info("Registered %d tools for server '%s'", len(tool_names), server_name)
|
|
110
|
+
|
|
111
|
+
def get_upstream_url(self, server_name: str | None = None) -> str:
|
|
112
|
+
"""Get the upstream URL for a given server, or default."""
|
|
113
|
+
if server_name:
|
|
114
|
+
route = self.resolve(server_name)
|
|
115
|
+
if route and route.upstream_url:
|
|
116
|
+
return route.upstream_url
|
|
117
|
+
return self.default_upstream
|
|
118
|
+
|
|
119
|
+
def get_upstream_headers(self, server_name: str | None = None) -> dict:
|
|
120
|
+
"""Get any extra headers for the upstream server."""
|
|
121
|
+
if server_name:
|
|
122
|
+
route = self.resolve(server_name)
|
|
123
|
+
if route:
|
|
124
|
+
return route.headers
|
|
125
|
+
return {}
|
|
126
|
+
|
|
127
|
+
def record_block(self, server_name: str) -> None:
|
|
128
|
+
"""Record that a request to this server was blocked."""
|
|
129
|
+
route = self.routes.get(server_name)
|
|
130
|
+
if route:
|
|
131
|
+
route.block_count += 1
|
|
132
|
+
|
|
133
|
+
def get_stats(self) -> dict:
|
|
134
|
+
"""Return routing statistics."""
|
|
135
|
+
return {
|
|
136
|
+
"total_routes": len(self.routes),
|
|
137
|
+
"loaded": self._loaded,
|
|
138
|
+
"default_upstream": self.default_upstream,
|
|
139
|
+
"routes": {
|
|
140
|
+
name: {
|
|
141
|
+
"type": r.server_type,
|
|
142
|
+
"upstream": r.upstream_url or "(stdio)",
|
|
143
|
+
"requests": r.request_count,
|
|
144
|
+
"blocks": r.block_count,
|
|
145
|
+
"tools": len(r.tool_names),
|
|
146
|
+
"healthy": r.healthy,
|
|
147
|
+
}
|
|
148
|
+
for name, r in self.routes.items()
|
|
149
|
+
},
|
|
150
|
+
"tool_mappings": len(self.tool_to_server),
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_multi_upstream(self) -> bool:
|
|
155
|
+
"""Whether multi-upstream mode is active."""
|
|
156
|
+
return self._loaded and len(self.routes) > 0
|
admina/proxy/state.py
ADDED
|
@@ -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
|
+
"""Proxy runtime state — holds connections, engines, and metrics."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
import redis.asyncio as aioredis
|
|
26
|
+
from minio import Minio
|
|
27
|
+
|
|
28
|
+
from admina.domains.compliance.eu_ai_act import EUAIActCompliance
|
|
29
|
+
from admina.domains.compliance.forensic import ForensicBlackBox
|
|
30
|
+
from admina.domains.compliance.gdpr import ProcessingActivitiesRegistry
|
|
31
|
+
from admina.domains.compliance.nis2 import NIS2Compliance
|
|
32
|
+
from admina.domains.compliance.otel import OTELGovernanceExporter
|
|
33
|
+
from admina.plugins.registry import PluginRegistry
|
|
34
|
+
from admina.proxy.multi_upstream import MultiUpstreamRouter
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ProxyState:
|
|
39
|
+
"""Mutable runtime state for the proxy.
|
|
40
|
+
|
|
41
|
+
Created once at startup, passed to handlers via app.state.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Connections
|
|
45
|
+
redis: aioredis.Redis | None = None
|
|
46
|
+
minio: Minio | None = None
|
|
47
|
+
clickhouse: Any = None
|
|
48
|
+
http_client: httpx.AsyncClient | None = None
|
|
49
|
+
|
|
50
|
+
# Governance engines (set by engine_bridge)
|
|
51
|
+
firewall: Any = None
|
|
52
|
+
pii_redactor: Any = None
|
|
53
|
+
loop_breaker: Any = None
|
|
54
|
+
|
|
55
|
+
# Subsystems
|
|
56
|
+
forensic_box: ForensicBlackBox | None = None
|
|
57
|
+
compliance: EUAIActCompliance = field(default_factory=EUAIActCompliance)
|
|
58
|
+
nis2: NIS2Compliance = field(default_factory=NIS2Compliance)
|
|
59
|
+
gdpr: ProcessingActivitiesRegistry = field(default_factory=ProcessingActivitiesRegistry)
|
|
60
|
+
router: MultiUpstreamRouter | None = None
|
|
61
|
+
registry: PluginRegistry = field(default_factory=PluginRegistry)
|
|
62
|
+
|
|
63
|
+
# Plugins
|
|
64
|
+
governance_guards: list = field(default_factory=list)
|
|
65
|
+
alert_channels: list = field(default_factory=list)
|
|
66
|
+
auth_providers: list = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
# OTEL
|
|
69
|
+
otel_exporter: OTELGovernanceExporter | None = None
|
|
70
|
+
|
|
71
|
+
# Metrics
|
|
72
|
+
metrics: dict[str, Any] = field(
|
|
73
|
+
default_factory=lambda: {
|
|
74
|
+
"requests_total": 0,
|
|
75
|
+
"requests_blocked": 0,
|
|
76
|
+
"requests_allowed": 0,
|
|
77
|
+
"requests_redacted": 0,
|
|
78
|
+
"avg_latency_ms": 0.0,
|
|
79
|
+
"started_at": datetime.now(UTC).isoformat(),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
_metrics_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
83
|
+
|
|
84
|
+
def inc_metric(self, key: str, value: int = 1) -> None:
|
|
85
|
+
with self._metrics_lock:
|
|
86
|
+
self.metrics[key] += value
|
|
87
|
+
|
|
88
|
+
def update_avg_latency(self, latency_ms: float) -> None:
|
|
89
|
+
with self._metrics_lock:
|
|
90
|
+
n = self.metrics["requests_total"]
|
|
91
|
+
if n <= 1:
|
|
92
|
+
self.metrics["avg_latency_ms"] = latency_ms
|
|
93
|
+
else:
|
|
94
|
+
self.metrics["avg_latency_ms"] = round(
|
|
95
|
+
(self.metrics["avg_latency_ms"] * (n - 1) + latency_ms) / n,
|
|
96
|
+
2,
|
|
97
|
+
)
|
admina/py.typed
ADDED
|
File without changes
|
admina/sdk/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
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 SDK — governed AI primitives.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
from admina.sdk import GovernedModel, GovernedData, GovernedAgent, ComplianceKit
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from admina.sdk.compliance_kit import ComplianceKit
|
|
25
|
+
from admina.sdk.governed_agent import GovernedAgent
|
|
26
|
+
from admina.sdk.governed_data import GovernedData
|
|
27
|
+
from admina.sdk.governed_model import GovernedModel
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"GovernedModel",
|
|
31
|
+
"GovernedData",
|
|
32
|
+
"GovernedAgent",
|
|
33
|
+
"ComplianceKit",
|
|
34
|
+
]
|
admina/sdk/_compat.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
"""Async-to-sync bridge that works both inside and outside running loops."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from collections.abc import Coroutine
|
|
21
|
+
from typing import Any, TypeVar
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_sync(coro: Coroutine[Any, Any, T]) -> T:
|
|
27
|
+
"""Run an async coroutine synchronously.
|
|
28
|
+
|
|
29
|
+
Works correctly whether or not an event loop is already running.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
asyncio.get_running_loop()
|
|
33
|
+
except RuntimeError:
|
|
34
|
+
# No running loop — safe to use asyncio.run()
|
|
35
|
+
return asyncio.run(coro)
|
|
36
|
+
else:
|
|
37
|
+
# Inside a running loop (Jupyter, FastAPI, etc.)
|
|
38
|
+
# Create a new thread to avoid deadlock
|
|
39
|
+
import concurrent.futures
|
|
40
|
+
|
|
41
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
42
|
+
future = pool.submit(asyncio.run, coro)
|
|
43
|
+
return future.result()
|
|
@@ -0,0 +1,359 @@
|
|
|
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 — ComplianceKit SDK primitive.
|
|
16
|
+
|
|
17
|
+
Wraps the existing EU AI Act compliance engine and provides a unified
|
|
18
|
+
interface for risk classification, gap analysis, and report generation
|
|
19
|
+
across compliance frameworks.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from admina.core.event_bus import EventType, GovernanceEvent, bus
|
|
29
|
+
from admina.domains.compliance.eu_ai_act import EUAIActCompliance
|
|
30
|
+
from admina.sdk._compat import run_sync
|
|
31
|
+
|
|
32
|
+
__all__ = ["ComplianceKit"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RiskClassification:
|
|
37
|
+
"""Result of a risk classification.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
risk_category: The risk category (unacceptable, high, limited, minimal).
|
|
41
|
+
level: Numeric risk level (1-4).
|
|
42
|
+
description: Human-readable description.
|
|
43
|
+
action: Required action for this risk level.
|
|
44
|
+
framework: The compliance framework used.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
risk_category: str
|
|
48
|
+
level: int = 1
|
|
49
|
+
description: str = ""
|
|
50
|
+
action: str = ""
|
|
51
|
+
framework: str = "eu_ai_act"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class GapReport:
|
|
56
|
+
"""Result of a gap analysis.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
applicable: Whether gap analysis applies to this risk level.
|
|
60
|
+
compliance_score: Percentage of checks passed (0-100).
|
|
61
|
+
total_checks: Total number of checks evaluated.
|
|
62
|
+
passed_checks: Number of checks passed.
|
|
63
|
+
gaps: List of unmet requirements.
|
|
64
|
+
status: COMPLIANT or GAPS_FOUND.
|
|
65
|
+
framework: The compliance framework used.
|
|
66
|
+
message: Optional message (e.g. for non-applicable cases).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
applicable: bool = False
|
|
70
|
+
compliance_score: float = 0.0
|
|
71
|
+
total_checks: int = 0
|
|
72
|
+
passed_checks: int = 0
|
|
73
|
+
gaps: list[dict[str, Any]] = field(default_factory=list)
|
|
74
|
+
status: str = "GAPS_FOUND"
|
|
75
|
+
framework: str = "eu_ai_act"
|
|
76
|
+
message: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Report:
|
|
81
|
+
"""A generated compliance report.
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
title: Report title.
|
|
85
|
+
generated_at: ISO timestamp of generation.
|
|
86
|
+
framework: The compliance framework.
|
|
87
|
+
classification: Risk classification details.
|
|
88
|
+
gap_analysis: Gap analysis details.
|
|
89
|
+
recommendations: List of recommended actions.
|
|
90
|
+
content: Full report content dict.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
title: str = ""
|
|
94
|
+
generated_at: str = ""
|
|
95
|
+
framework: str = "eu_ai_act"
|
|
96
|
+
classification: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
gap_analysis: dict[str, Any] = field(default_factory=dict)
|
|
98
|
+
recommendations: list[str] = field(default_factory=list)
|
|
99
|
+
content: dict[str, Any] = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
def to_json(self) -> str:
|
|
102
|
+
"""Serialize the report to JSON string.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
JSON string of the report content.
|
|
106
|
+
"""
|
|
107
|
+
return json.dumps(self.content, indent=2, default=str)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ComplianceKit:
|
|
111
|
+
"""SDK primitive for compliance checking and reporting.
|
|
112
|
+
|
|
113
|
+
Provides risk classification, gap analysis, and report generation
|
|
114
|
+
across compliance frameworks. Currently supports EU AI Act with
|
|
115
|
+
extensibility for additional frameworks via plugin templates.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
frameworks: List of framework names to activate.
|
|
119
|
+
Defaults to ["eu_ai_act"].
|
|
120
|
+
audit: Whether to emit governance events.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
frameworks: list[str] | None = None,
|
|
126
|
+
audit: bool = True,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Initialize ComplianceKit.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
frameworks: Framework names to activate. Defaults to ["eu_ai_act"].
|
|
132
|
+
audit: If True, emit events to the event bus.
|
|
133
|
+
"""
|
|
134
|
+
self._frameworks = frameworks or ["eu_ai_act"]
|
|
135
|
+
self._audit = audit
|
|
136
|
+
self._engines: dict[str, Any] = {}
|
|
137
|
+
self._init_engines()
|
|
138
|
+
|
|
139
|
+
def _init_engines(self) -> None:
|
|
140
|
+
"""Initialize compliance engines for configured frameworks."""
|
|
141
|
+
for fw in self._frameworks:
|
|
142
|
+
if fw == "eu_ai_act":
|
|
143
|
+
self._engines[fw] = EUAIActCompliance()
|
|
144
|
+
|
|
145
|
+
def _get_engine(self, framework: str) -> Any:
|
|
146
|
+
"""Get the compliance engine for a framework.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
framework: Framework name.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The compliance engine instance.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
ValueError: If the framework is not supported.
|
|
156
|
+
"""
|
|
157
|
+
engine = self._engines.get(framework)
|
|
158
|
+
if engine is None:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Framework '{framework}' is not supported. Available: {list(self._engines.keys())}"
|
|
161
|
+
)
|
|
162
|
+
return engine
|
|
163
|
+
|
|
164
|
+
def classify_risk(
|
|
165
|
+
self,
|
|
166
|
+
description: str,
|
|
167
|
+
use_case: str,
|
|
168
|
+
data_types: list[str] | None = None,
|
|
169
|
+
framework: str = "eu_ai_act",
|
|
170
|
+
) -> RiskClassification:
|
|
171
|
+
"""Classify an AI system's risk level.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
description: Description of the AI system.
|
|
175
|
+
use_case: The use case or application domain.
|
|
176
|
+
data_types: Types of data processed (e.g. ["health", "financial"]).
|
|
177
|
+
framework: Compliance framework to use.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
RiskClassification with category, level, and required action.
|
|
181
|
+
"""
|
|
182
|
+
engine = self._get_engine(framework)
|
|
183
|
+
result = engine.classify_risk(
|
|
184
|
+
description,
|
|
185
|
+
use_case,
|
|
186
|
+
data_types or [],
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
classification = RiskClassification(
|
|
190
|
+
risk_category=result["risk_category"],
|
|
191
|
+
level=result.get("level", 1),
|
|
192
|
+
description=result.get("description", ""),
|
|
193
|
+
action=result.get("action", ""),
|
|
194
|
+
framework=framework,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if self._audit:
|
|
198
|
+
_emit_sync(
|
|
199
|
+
GovernanceEvent(
|
|
200
|
+
event_type=EventType.COMPLIANCE_CHECK,
|
|
201
|
+
domain="compliance",
|
|
202
|
+
action="ALLOW",
|
|
203
|
+
metadata={
|
|
204
|
+
"operation": "classify_risk",
|
|
205
|
+
"framework": framework,
|
|
206
|
+
"risk_category": classification.risk_category,
|
|
207
|
+
"level": classification.level,
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return classification
|
|
213
|
+
|
|
214
|
+
def gap_analysis(
|
|
215
|
+
self,
|
|
216
|
+
framework: str = "eu_ai_act",
|
|
217
|
+
risk_category: str = "high",
|
|
218
|
+
current_compliance: dict[str, list[bool] | bool] | None = None,
|
|
219
|
+
) -> GapReport:
|
|
220
|
+
"""Perform a compliance gap analysis.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
framework: Compliance framework to evaluate.
|
|
224
|
+
risk_category: The risk category to analyze gaps for.
|
|
225
|
+
current_compliance: Dict mapping requirement keys to either
|
|
226
|
+
a list of booleans (one per check) or a single boolean
|
|
227
|
+
(treated as a single check that is fully met or unmet).
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
GapReport with score, gaps, and status.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
ValueError: If a value is not a bool or list of bools.
|
|
234
|
+
"""
|
|
235
|
+
engine = self._get_engine(framework)
|
|
236
|
+
normalised: dict[str, list[bool]] = {}
|
|
237
|
+
for key, value in (current_compliance or {}).items():
|
|
238
|
+
if isinstance(value, bool):
|
|
239
|
+
normalised[key] = [value]
|
|
240
|
+
elif isinstance(value, list) and all(isinstance(v, bool) for v in value):
|
|
241
|
+
normalised[key] = value
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"current_compliance[{key!r}] must be bool or list[bool], "
|
|
245
|
+
f"got {type(value).__name__}"
|
|
246
|
+
)
|
|
247
|
+
result = engine.gap_analysis(risk_category, normalised)
|
|
248
|
+
|
|
249
|
+
report = GapReport(
|
|
250
|
+
applicable=result.get("applicable", False),
|
|
251
|
+
compliance_score=result.get("compliance_score", 0.0),
|
|
252
|
+
total_checks=result.get("total_checks", 0),
|
|
253
|
+
passed_checks=result.get("passed_checks", 0),
|
|
254
|
+
gaps=result.get("gaps", []),
|
|
255
|
+
status=result.get("status", "GAPS_FOUND"),
|
|
256
|
+
framework=framework,
|
|
257
|
+
message=result.get("message", ""),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if self._audit:
|
|
261
|
+
_emit_sync(
|
|
262
|
+
GovernanceEvent(
|
|
263
|
+
event_type=EventType.COMPLIANCE_CHECK,
|
|
264
|
+
domain="compliance",
|
|
265
|
+
metadata={
|
|
266
|
+
"operation": "gap_analysis",
|
|
267
|
+
"framework": framework,
|
|
268
|
+
"applicable": report.applicable,
|
|
269
|
+
"compliance_score": report.compliance_score,
|
|
270
|
+
"gap_count": len(report.gaps),
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return report
|
|
276
|
+
|
|
277
|
+
def generate_report(
|
|
278
|
+
self,
|
|
279
|
+
system_name: str,
|
|
280
|
+
description: str,
|
|
281
|
+
use_case: str,
|
|
282
|
+
data_types: list[str] | None = None,
|
|
283
|
+
current_compliance: dict[str, list[bool]] | None = None,
|
|
284
|
+
framework: str = "eu_ai_act",
|
|
285
|
+
) -> Report:
|
|
286
|
+
"""Generate a full compliance report.
|
|
287
|
+
|
|
288
|
+
Runs risk classification and gap analysis, then produces a
|
|
289
|
+
structured report with recommendations.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
system_name: Name of the AI system.
|
|
293
|
+
description: Description of the AI system.
|
|
294
|
+
use_case: The use case or application domain.
|
|
295
|
+
data_types: Types of data processed.
|
|
296
|
+
current_compliance: Current compliance state for gap analysis.
|
|
297
|
+
framework: Compliance framework to use.
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Report with classification, gaps, and recommendations.
|
|
301
|
+
"""
|
|
302
|
+
engine = self._get_engine(framework)
|
|
303
|
+
|
|
304
|
+
classification = engine.classify_risk(
|
|
305
|
+
description,
|
|
306
|
+
use_case,
|
|
307
|
+
data_types or [],
|
|
308
|
+
)
|
|
309
|
+
gap_result = engine.gap_analysis(
|
|
310
|
+
classification["risk_category"],
|
|
311
|
+
current_compliance or {},
|
|
312
|
+
)
|
|
313
|
+
report_data = engine.generate_report(
|
|
314
|
+
system_name,
|
|
315
|
+
classification,
|
|
316
|
+
gap_result,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
report = Report(
|
|
320
|
+
title=report_data.get("report_title", ""),
|
|
321
|
+
generated_at=report_data.get("generated_at", ""),
|
|
322
|
+
framework=framework,
|
|
323
|
+
classification=classification,
|
|
324
|
+
gap_analysis=gap_result,
|
|
325
|
+
recommendations=report_data.get("recommendations", []),
|
|
326
|
+
content=report_data,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
if self._audit:
|
|
330
|
+
_emit_sync(
|
|
331
|
+
GovernanceEvent(
|
|
332
|
+
event_type=EventType.COMPLIANCE_CHECK,
|
|
333
|
+
domain="compliance",
|
|
334
|
+
metadata={
|
|
335
|
+
"operation": "generate_report",
|
|
336
|
+
"framework": framework,
|
|
337
|
+
"system_name": system_name,
|
|
338
|
+
"risk_category": classification["risk_category"],
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
return report
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def supported_frameworks(self) -> list[str]:
|
|
347
|
+
"""Return list of supported framework names."""
|
|
348
|
+
return list(self._engines.keys())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _emit_sync(event: GovernanceEvent) -> None:
|
|
352
|
+
"""Emit an event synchronously.
|
|
353
|
+
|
|
354
|
+
Uses an event loop if available, otherwise creates one.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
event: The governance event to emit.
|
|
358
|
+
"""
|
|
359
|
+
run_sync(bus.emit(event))
|