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,153 @@
|
|
|
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
|
+
"""External integration REST endpoints.
|
|
16
|
+
|
|
17
|
+
Provides a simpler REST interface for non-MCP callers:
|
|
18
|
+
POST /api/v1/validate — validate an action payload
|
|
19
|
+
POST /api/v1/audit — log an action result to forensic black box
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import time
|
|
26
|
+
import uuid
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from fastapi import APIRouter, HTTPException
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("admina.api.integration")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_integration_endpoints(
|
|
36
|
+
*,
|
|
37
|
+
get_firewall: Any,
|
|
38
|
+
get_pii_scanner: Any,
|
|
39
|
+
get_loop_breaker: Any,
|
|
40
|
+
get_forensic_box: Any,
|
|
41
|
+
) -> APIRouter:
|
|
42
|
+
"""Create a new APIRouter with integration endpoints.
|
|
43
|
+
|
|
44
|
+
A fresh router is created on each call for test isolation.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
get_firewall: Callable returning the firewall checker.
|
|
48
|
+
get_pii_scanner: Callable returning the PII redactor.
|
|
49
|
+
get_loop_breaker: Callable returning the loop breaker.
|
|
50
|
+
get_forensic_box: Callable returning ForensicBlackBox | None.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The configured APIRouter.
|
|
54
|
+
"""
|
|
55
|
+
router = APIRouter(prefix="/api/v1", tags=["integration"])
|
|
56
|
+
|
|
57
|
+
@router.post("/validate")
|
|
58
|
+
async def validate_action(body: dict) -> dict[str, Any]:
|
|
59
|
+
"""Validate an action payload through the governance pipeline.
|
|
60
|
+
|
|
61
|
+
Expects JSON body with at least ``content`` (str). Optional:
|
|
62
|
+
``session_id``, ``method``.
|
|
63
|
+
|
|
64
|
+
Returns ``action`` (ALLOW / BLOCK / MODIFY), ``risk_level``,
|
|
65
|
+
and per-domain ``checks``.
|
|
66
|
+
"""
|
|
67
|
+
content = body.get("content", "")
|
|
68
|
+
if not content:
|
|
69
|
+
raise HTTPException(status_code=400, detail="'content' field is required")
|
|
70
|
+
|
|
71
|
+
session_id = body.get("session_id", "rest-" + uuid.uuid4().hex[:8])
|
|
72
|
+
|
|
73
|
+
start = time.perf_counter()
|
|
74
|
+
action = "ALLOW"
|
|
75
|
+
risk_level = "LOW"
|
|
76
|
+
checks: dict[str, Any] = {}
|
|
77
|
+
|
|
78
|
+
# Loop breaker
|
|
79
|
+
lb = get_loop_breaker()
|
|
80
|
+
lb_result = lb.check(session_id, content)
|
|
81
|
+
checks["loop_breaker"] = lb_result
|
|
82
|
+
if lb_result.get("is_loop"):
|
|
83
|
+
action = "BLOCK"
|
|
84
|
+
risk_level = "HIGH"
|
|
85
|
+
|
|
86
|
+
# Firewall
|
|
87
|
+
if action == "ALLOW":
|
|
88
|
+
fw = get_firewall()
|
|
89
|
+
fw_result = fw.check(content)
|
|
90
|
+
checks["firewall"] = fw_result
|
|
91
|
+
if fw_result.get("is_injection"):
|
|
92
|
+
action = "BLOCK"
|
|
93
|
+
risk_level = fw_result.get("risk_level", "HIGH")
|
|
94
|
+
|
|
95
|
+
# PII redaction
|
|
96
|
+
redacted_content = content
|
|
97
|
+
pii = get_pii_scanner()
|
|
98
|
+
pii_result = pii.redact(content)
|
|
99
|
+
checks["pii_redaction"] = {
|
|
100
|
+
"count": pii_result["count"],
|
|
101
|
+
"entities": pii_result["entities"],
|
|
102
|
+
}
|
|
103
|
+
if pii_result["count"] > 0:
|
|
104
|
+
redacted_content = pii_result["redacted_text"]
|
|
105
|
+
if action == "ALLOW":
|
|
106
|
+
action = "MODIFY"
|
|
107
|
+
|
|
108
|
+
latency_ms = round((time.perf_counter() - start) * 1000, 2)
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
"action": action,
|
|
112
|
+
"risk_level": risk_level,
|
|
113
|
+
"checks": checks,
|
|
114
|
+
"redacted_content": redacted_content if action == "MODIFY" else None,
|
|
115
|
+
"latency_ms": latency_ms,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@router.post("/audit")
|
|
119
|
+
async def audit_action(body: dict) -> dict[str, Any]:
|
|
120
|
+
"""Log an action result to the forensic black box.
|
|
121
|
+
|
|
122
|
+
Expects JSON body with ``event`` (dict) containing the
|
|
123
|
+
action details to record.
|
|
124
|
+
|
|
125
|
+
Returns forensic record metadata (sequence number, hash).
|
|
126
|
+
"""
|
|
127
|
+
event_data = body.get("event")
|
|
128
|
+
if not event_data or not isinstance(event_data, dict):
|
|
129
|
+
raise HTTPException(
|
|
130
|
+
status_code=400,
|
|
131
|
+
detail="'event' field is required and must be a dict",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
fbox = get_forensic_box()
|
|
135
|
+
if fbox is None:
|
|
136
|
+
return {
|
|
137
|
+
"recorded": False,
|
|
138
|
+
"error": "Forensic black box not available (MinIO not connected)",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
event_data.setdefault("event_id", str(uuid.uuid4()))
|
|
142
|
+
event_data.setdefault("timestamp", datetime.now(UTC).isoformat())
|
|
143
|
+
event_data.setdefault("source", "api_v1_audit")
|
|
144
|
+
|
|
145
|
+
record = fbox.record(event_data)
|
|
146
|
+
return {
|
|
147
|
+
"recorded": True,
|
|
148
|
+
"sequence_number": record["sequence_number"],
|
|
149
|
+
"record_hash": record["record_hash"],
|
|
150
|
+
"previous_hash": record["previous_hash"],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return router
|
admina/proxy/config.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
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 — Configuration & Data Models
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import warnings
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel, Field, field_validator
|
|
23
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
24
|
+
|
|
25
|
+
from admina.core.types import EventType, GovernanceAction, RiskLevel
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── Environment Config ──────────────────────────────────────
|
|
29
|
+
class Settings(BaseSettings):
|
|
30
|
+
model_config = SettingsConfigDict(
|
|
31
|
+
env_file=".env",
|
|
32
|
+
env_file_encoding="utf-8",
|
|
33
|
+
extra="ignore",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Storage
|
|
37
|
+
REDIS_URL: str = "redis://localhost:6379/0"
|
|
38
|
+
CLICKHOUSE_HOST: str = "localhost"
|
|
39
|
+
CLICKHOUSE_PORT: int = 8123
|
|
40
|
+
CLICKHOUSE_DB: str = "admina"
|
|
41
|
+
CLICKHOUSE_PASSWORD: str = ""
|
|
42
|
+
MINIO_ENDPOINT: str = "localhost:9000"
|
|
43
|
+
MINIO_ACCESS_KEY: str = "admina"
|
|
44
|
+
MINIO_SECRET_KEY: str = ""
|
|
45
|
+
MINIO_BUCKET: str = "forensic-blackbox"
|
|
46
|
+
MINIO_SECURE: bool = False
|
|
47
|
+
|
|
48
|
+
# Forensic blackbox backend selection.
|
|
49
|
+
# "memory" (default): in-memory ledger, hashed and chained but
|
|
50
|
+
# LOST ON RESTART. Default so the proxy never writes
|
|
51
|
+
# files unbidden. Switch to "filesystem" or "s3" for
|
|
52
|
+
# persistence — that's an explicit operator decision.
|
|
53
|
+
# "filesystem": local JSON files with SHA-256 chained hashes, no
|
|
54
|
+
# external service required. Path: FORENSIC_BASE_DIR
|
|
55
|
+
# (must be set explicitly to opt in).
|
|
56
|
+
# "s3": generic S3-compatible via boto3 — works with AWS S3,
|
|
57
|
+
# Cloudflare R2, Backblaze B2, SeaweedFS, Garage,
|
|
58
|
+
# Ceph RGW, etc. Recommended replacement for "minio"
|
|
59
|
+
# since the MinIO Python SDK has been archived.
|
|
60
|
+
# Configure via FORENSIC_S3_* env vars.
|
|
61
|
+
# "minio": legacy MinIO SDK. Kept for backward compatibility,
|
|
62
|
+
# will be removed in a future release.
|
|
63
|
+
FORENSIC_BACKEND: str = "memory"
|
|
64
|
+
# Empty by default — when FORENSIC_BACKEND="filesystem" the operator
|
|
65
|
+
# MUST set this. Bare-metal / k8s typical: /var/lib/admina/forensic
|
|
66
|
+
# mounted as a persistent volume.
|
|
67
|
+
FORENSIC_BASE_DIR: str = ""
|
|
68
|
+
# Generic S3 settings (used when FORENSIC_BACKEND="s3"). All
|
|
69
|
+
# standard boto3 / AWS env vars (AWS_ACCESS_KEY_ID, etc.) are also
|
|
70
|
+
# honoured if the FORENSIC_S3_* equivalents are empty.
|
|
71
|
+
FORENSIC_S3_ENDPOINT: str = "" # e.g. http://seaweedfs:8333
|
|
72
|
+
FORENSIC_S3_REGION: str = "us-east-1"
|
|
73
|
+
FORENSIC_S3_ACCESS_KEY: str = ""
|
|
74
|
+
FORENSIC_S3_SECRET_KEY: str = ""
|
|
75
|
+
FORENSIC_S3_BUCKET: str = "forensic-blackbox"
|
|
76
|
+
# Object Lock — when "true", every forensic record written to S3 is
|
|
77
|
+
# locked in COMPLIANCE mode for FORENSIC_S3_LOCK_DAYS days. The bucket
|
|
78
|
+
# MUST have been created with ObjectLockEnabledForBucket=true (set
|
|
79
|
+
# FORENSIC_S3_LOCK_AUTO_BUCKET=true to do this automatically the
|
|
80
|
+
# first time the proxy starts and the bucket does not exist).
|
|
81
|
+
# WORM = Write Once Read Many — required for many compliance regimes
|
|
82
|
+
# (eIDAS, EU AI Act forensic evidence, FINRA, HIPAA).
|
|
83
|
+
FORENSIC_S3_LOCK: bool = False
|
|
84
|
+
FORENSIC_S3_LOCK_DAYS: int = 365 * 7 # default: 7 years
|
|
85
|
+
FORENSIC_S3_LOCK_AUTO_BUCKET: bool = False
|
|
86
|
+
# Retry / backoff for transient S3 failures (network blip, throttling).
|
|
87
|
+
FORENSIC_S3_MAX_RETRIES: int = 5
|
|
88
|
+
FORENSIC_S3_BASE_DELAY_S: float = 0.2
|
|
89
|
+
|
|
90
|
+
# Telemetry
|
|
91
|
+
OTEL_ENDPOINT: str = "http://localhost:4317"
|
|
92
|
+
|
|
93
|
+
# Proxy
|
|
94
|
+
UPSTREAM_MCP_URL: str = "http://localhost:9000"
|
|
95
|
+
LOG_LEVEL: str = "INFO"
|
|
96
|
+
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8080"
|
|
97
|
+
|
|
98
|
+
# Auth — set a strong random key in production: openssl rand -hex 32
|
|
99
|
+
# If empty, auth is disabled (local development only).
|
|
100
|
+
ADMINA_API_KEY: str = ""
|
|
101
|
+
ALLOW_UNAUTHENTICATED: bool = False
|
|
102
|
+
|
|
103
|
+
# Rate limiting (per session, requires Redis)
|
|
104
|
+
RATE_LIMIT_MAX_REQUESTS: int = 100 # requests per window
|
|
105
|
+
RATE_LIMIT_WINDOW_SECONDS: int = 60 # window in seconds
|
|
106
|
+
RATE_LIMIT_IP_MULTIPLIER: int = 5 # IP limit = session limit * this
|
|
107
|
+
|
|
108
|
+
# Governance mode — controls how the pipeline reacts to detections.
|
|
109
|
+
# "enforce" (default, recommended for production): block flagged
|
|
110
|
+
# requests, redact PII, raise alerts.
|
|
111
|
+
# "observe": never block. Run the full pipeline, log every decision
|
|
112
|
+
# with what would have happened, and let traffic through
|
|
113
|
+
# unchanged. Ideal for the first 1-2 weeks of a new
|
|
114
|
+
# deployment to tune thresholds without breaking users.
|
|
115
|
+
# "dry-run": same as observe but additionally tag the response so
|
|
116
|
+
# downstream tools know the request was analysed.
|
|
117
|
+
# Restrictive default — opt out explicitly via ADMINA_GOVERNANCE_MODE=observe.
|
|
118
|
+
GOVERNANCE_MODE: str = "enforce"
|
|
119
|
+
|
|
120
|
+
# Governance thresholds
|
|
121
|
+
LOOP_WINDOW_SIZE: int = 10
|
|
122
|
+
LOOP_SIMILARITY_THRESHOLD: float = 0.85
|
|
123
|
+
LOOP_MAX_CONSECUTIVE: int = 3
|
|
124
|
+
INJECTION_FAST_PATH_ENABLED: bool = True
|
|
125
|
+
INJECTION_DEEP_PATH_ENABLED: bool = True
|
|
126
|
+
PII_REDACTION_ENABLED: bool = True
|
|
127
|
+
MAX_REQUEST_TOKENS: int = 100000
|
|
128
|
+
|
|
129
|
+
@field_validator("GOVERNANCE_MODE")
|
|
130
|
+
@classmethod
|
|
131
|
+
def validate_governance_mode(cls, v: str) -> str:
|
|
132
|
+
v = v.lower().strip()
|
|
133
|
+
if v not in {"enforce", "observe", "dry-run", "dry_run"}:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"GOVERNANCE_MODE must be one of: enforce | observe | dry-run (got {v!r})"
|
|
136
|
+
)
|
|
137
|
+
return "dry-run" if v == "dry_run" else v
|
|
138
|
+
|
|
139
|
+
@field_validator("FORENSIC_BACKEND")
|
|
140
|
+
@classmethod
|
|
141
|
+
def validate_forensic_backend(cls, v: str) -> str:
|
|
142
|
+
v = v.lower().strip()
|
|
143
|
+
if v not in {"memory", "filesystem", "s3", "minio"}:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"FORENSIC_BACKEND must be 'memory' | 'filesystem' | 's3' | 'minio' (got {v!r})"
|
|
146
|
+
)
|
|
147
|
+
return v
|
|
148
|
+
|
|
149
|
+
@field_validator("CORS_ORIGINS")
|
|
150
|
+
@classmethod
|
|
151
|
+
def warn_wildcard_cors(cls, v: str) -> str:
|
|
152
|
+
origins = [o.strip() for o in v.split(",")]
|
|
153
|
+
if "*" in origins:
|
|
154
|
+
warnings.warn(
|
|
155
|
+
"CORS_ORIGINS contains '*' — this allows any domain to make "
|
|
156
|
+
"cross-origin requests to the proxy. Use specific origins in production.",
|
|
157
|
+
stacklevel=2,
|
|
158
|
+
)
|
|
159
|
+
return v
|
|
160
|
+
|
|
161
|
+
@field_validator("ADMINA_API_KEY")
|
|
162
|
+
@classmethod
|
|
163
|
+
def warn_short_api_key(cls, v: str) -> str:
|
|
164
|
+
if v and len(v) < 16:
|
|
165
|
+
warnings.warn(
|
|
166
|
+
"ADMINA_API_KEY is shorter than 16 characters — use a stronger key in production",
|
|
167
|
+
stacklevel=2,
|
|
168
|
+
)
|
|
169
|
+
return v
|
|
170
|
+
|
|
171
|
+
@field_validator("MINIO_SECRET_KEY")
|
|
172
|
+
@classmethod
|
|
173
|
+
def warn_empty_minio_secret(cls, v: str) -> str:
|
|
174
|
+
if not v:
|
|
175
|
+
warnings.warn(
|
|
176
|
+
"MINIO_SECRET_KEY is not set — forensic storage will fail. Set it in .env.",
|
|
177
|
+
stacklevel=2,
|
|
178
|
+
)
|
|
179
|
+
return v
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
settings = Settings()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── MCP Protocol Models ─────────────────────────────────────
|
|
186
|
+
class MCPRequest(BaseModel):
|
|
187
|
+
jsonrpc: str = "2.0"
|
|
188
|
+
id: int | str | None = None
|
|
189
|
+
method: str
|
|
190
|
+
params: dict[str, Any] | None = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class MCPResponse(BaseModel):
|
|
194
|
+
jsonrpc: str = "2.0"
|
|
195
|
+
id: int | str | None = None
|
|
196
|
+
result: Any | None = None
|
|
197
|
+
error: dict[str, Any] | None = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── Governance Event ─────────────────────────────────────────
|
|
201
|
+
class GovernanceEvent(BaseModel):
|
|
202
|
+
event_id: str
|
|
203
|
+
timestamp: str
|
|
204
|
+
event_type: EventType
|
|
205
|
+
agent_id: str = "unknown"
|
|
206
|
+
session_id: str = "unknown"
|
|
207
|
+
method: str = ""
|
|
208
|
+
tool_name: str = ""
|
|
209
|
+
action: GovernanceAction = GovernanceAction.ALLOW
|
|
210
|
+
risk_level: RiskLevel = RiskLevel.LOW
|
|
211
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
212
|
+
latency_ms: float = 0.0
|
|
213
|
+
request_hash: str = ""
|
|
214
|
+
response_hash: str = ""
|
|
@@ -0,0 +1,306 @@
|
|
|
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 — Hybrid Engine Bridge
|
|
17
|
+
Auto-detects Rust engine, falls back to pure Python.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from datetime import UTC
|
|
24
|
+
from typing import Any, Protocol
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("admina.engine")
|
|
27
|
+
|
|
28
|
+
# ── Detect Rust engine ──────────────────────────────────────
|
|
29
|
+
ENGINE = "python"
|
|
30
|
+
_rust_available = False
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import admina_core
|
|
34
|
+
|
|
35
|
+
_rust_available = True
|
|
36
|
+
ENGINE = "rust"
|
|
37
|
+
_info = admina_core.engine_info()
|
|
38
|
+
logger.info(
|
|
39
|
+
"[RUST] Rust engine loaded: v%s — modules: %s", admina_core.version(), _info["modules"]
|
|
40
|
+
)
|
|
41
|
+
except ImportError:
|
|
42
|
+
logger.info(
|
|
43
|
+
"[PYTHON] Rust engine not found, using pure Python (install admina-core for 10-100x speedup)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Firewall Bridge ─────────────────────────────────────────
|
|
48
|
+
def _load_firewall_yaml_overrides() -> tuple[list, list]:
|
|
49
|
+
"""Read agent_security.firewall.{custom_patterns,disabled_categories}
|
|
50
|
+
from admina.yaml if present. Falls back to no overrides on any error.
|
|
51
|
+
Each custom pattern in YAML is `{regex, category, risk_level}`.
|
|
52
|
+
"""
|
|
53
|
+
extras: list = []
|
|
54
|
+
disabled: list = []
|
|
55
|
+
try:
|
|
56
|
+
from admina.core.config import load_config
|
|
57
|
+
from admina.core.types import RiskLevel
|
|
58
|
+
|
|
59
|
+
cfg = load_config()
|
|
60
|
+
fw_cfg = (
|
|
61
|
+
cfg.raw.get("domains", {}).get("agent_security", {}).get("firewall", {})
|
|
62
|
+
if hasattr(cfg, "raw") and isinstance(cfg.raw, dict)
|
|
63
|
+
else {}
|
|
64
|
+
)
|
|
65
|
+
disabled = list(fw_cfg.get("disabled_categories") or [])
|
|
66
|
+
for entry in fw_cfg.get("custom_patterns") or []:
|
|
67
|
+
try:
|
|
68
|
+
extras.append(
|
|
69
|
+
(
|
|
70
|
+
entry["regex"],
|
|
71
|
+
entry.get("category", "user_custom"),
|
|
72
|
+
RiskLevel(entry.get("risk_level", "medium").lower()),
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
except (KeyError, ValueError, TypeError) as exc:
|
|
76
|
+
logger.warning("Skipping malformed custom_pattern %r: %s", entry, exc)
|
|
77
|
+
except (ImportError, AttributeError, OSError):
|
|
78
|
+
pass
|
|
79
|
+
return extras, disabled
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _PythonFirewallBridge:
|
|
83
|
+
"""Wraps the existing Python InjectionFirewall with a compatible interface."""
|
|
84
|
+
|
|
85
|
+
def __init__(self):
|
|
86
|
+
from admina.domains.agent_security.firewall import InjectionFirewall
|
|
87
|
+
|
|
88
|
+
extras, disabled = _load_firewall_yaml_overrides()
|
|
89
|
+
if extras or disabled:
|
|
90
|
+
logger.info(
|
|
91
|
+
"Loaded %d custom firewall pattern(s); disabled: %s",
|
|
92
|
+
len(extras),
|
|
93
|
+
disabled or "(none)",
|
|
94
|
+
)
|
|
95
|
+
self._impl = InjectionFirewall(
|
|
96
|
+
extra_patterns=extras or None,
|
|
97
|
+
disabled_categories=disabled or None,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def check(self, text: str) -> dict:
|
|
101
|
+
return self._impl.check(text)
|
|
102
|
+
|
|
103
|
+
def get_stats(self) -> dict:
|
|
104
|
+
stats = self._impl.get_stats()
|
|
105
|
+
stats["engine"] = "python"
|
|
106
|
+
return stats
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _RustFirewallBridge:
|
|
110
|
+
"""Wraps Rust RustFirewall, returns dicts for compatibility."""
|
|
111
|
+
|
|
112
|
+
def __init__(self):
|
|
113
|
+
self._impl = admina_core.RustFirewall()
|
|
114
|
+
|
|
115
|
+
def check(self, text: str) -> dict:
|
|
116
|
+
result = self._impl.check(text)
|
|
117
|
+
return {
|
|
118
|
+
"is_injection": result.is_injection,
|
|
119
|
+
"risk_level": result.risk_level,
|
|
120
|
+
"matched_patterns": result.matched_patterns,
|
|
121
|
+
"heuristic_score": result.heuristic_score,
|
|
122
|
+
"heuristic_signals": result.heuristic_signals,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
def get_stats(self) -> dict:
|
|
126
|
+
return self._impl.get_stats()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ── PII Scanner Bridge ──────────────────────────────────────
|
|
130
|
+
class _PythonPiiBridge:
|
|
131
|
+
def __init__(self):
|
|
132
|
+
from admina.domains.data_sovereignty.pii import PIIRedactor
|
|
133
|
+
|
|
134
|
+
self._impl = PIIRedactor()
|
|
135
|
+
|
|
136
|
+
def redact(self, text: str) -> dict:
|
|
137
|
+
return self._impl.redact(text)
|
|
138
|
+
|
|
139
|
+
def get_stats(self) -> dict:
|
|
140
|
+
stats = self._impl.get_stats()
|
|
141
|
+
stats["engine"] = "python"
|
|
142
|
+
return stats
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class _RustPiiBridge:
|
|
146
|
+
def __init__(self):
|
|
147
|
+
self._impl = admina_core.RustPiiScanner()
|
|
148
|
+
|
|
149
|
+
def redact(self, text: str) -> dict:
|
|
150
|
+
result = self._impl.redact(text)
|
|
151
|
+
return {
|
|
152
|
+
"redacted_text": result.redacted_text,
|
|
153
|
+
"count": result.count,
|
|
154
|
+
"categories": result.categories,
|
|
155
|
+
"entities": [{"type": cat, "method": "rust_regex"} for cat in result.categories],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def get_stats(self) -> dict:
|
|
159
|
+
return self._impl.get_stats()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ── Loop Breaker Bridge ─────────────────────────────────────
|
|
163
|
+
class _PythonLoopBridge:
|
|
164
|
+
def __init__(self, **kwargs):
|
|
165
|
+
from admina.domains.agent_security.loop_breaker import LoopBreaker
|
|
166
|
+
|
|
167
|
+
self._impl = LoopBreaker(**kwargs)
|
|
168
|
+
|
|
169
|
+
def check(self, session_id: str, content: str) -> dict:
|
|
170
|
+
return self._impl.check(session_id, content)
|
|
171
|
+
|
|
172
|
+
def get_stats(self) -> dict:
|
|
173
|
+
stats = self._impl.get_stats()
|
|
174
|
+
stats["engine"] = "python"
|
|
175
|
+
return stats
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _RustLoopBridge:
|
|
179
|
+
def __init__(self, window_size=10, similarity_threshold=0.85, max_consecutive=3, **kwargs):
|
|
180
|
+
self._impl = admina_core.RustLoopBreaker(
|
|
181
|
+
window_size=window_size,
|
|
182
|
+
similarity_threshold=similarity_threshold,
|
|
183
|
+
max_consecutive=max_consecutive,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def check(self, session_id: str, content: str) -> dict:
|
|
187
|
+
return self._impl.check(session_id, content)
|
|
188
|
+
|
|
189
|
+
def get_stats(self) -> dict:
|
|
190
|
+
return self._impl.get_stats()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ── Hash Chain Bridge ────────────────────────────────────────
|
|
194
|
+
class _PythonHashChainBridge:
|
|
195
|
+
def __init__(self):
|
|
196
|
+
# Minimal Python fallback
|
|
197
|
+
self._prev = "genesis"
|
|
198
|
+
self._seq = 0
|
|
199
|
+
self._total = 0
|
|
200
|
+
|
|
201
|
+
def record(self, event_id: str, data: str) -> dict:
|
|
202
|
+
import hashlib
|
|
203
|
+
from datetime import datetime
|
|
204
|
+
|
|
205
|
+
self._seq += 1
|
|
206
|
+
self._total += 1
|
|
207
|
+
now = datetime.now(UTC)
|
|
208
|
+
hash_input = f"{self._seq}:{self._prev}:{event_id}:{data}:{int(now.timestamp() * 1000)}"
|
|
209
|
+
h = hashlib.sha256(hash_input.encode()).hexdigest()
|
|
210
|
+
prev = self._prev
|
|
211
|
+
self._prev = h
|
|
212
|
+
return {
|
|
213
|
+
"hash": h,
|
|
214
|
+
"previous_hash": prev,
|
|
215
|
+
"sequence": self._seq,
|
|
216
|
+
"event_id": event_id,
|
|
217
|
+
"timestamp_iso": now.isoformat(),
|
|
218
|
+
"timestamp_ms": int(now.timestamp() * 1000),
|
|
219
|
+
"engine": "python",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def get_stats(self) -> dict:
|
|
223
|
+
return {"total_records": self._total, "current_sequence": self._seq, "engine": "python"}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _RustHashChainBridge:
|
|
227
|
+
def __init__(self):
|
|
228
|
+
self._impl = admina_core.RustHashChain()
|
|
229
|
+
|
|
230
|
+
def record(self, event_id: str, data: str) -> dict:
|
|
231
|
+
return self._impl.record(event_id, data)
|
|
232
|
+
|
|
233
|
+
def verify_chain(self, chain: list) -> dict[str, Any]:
|
|
234
|
+
return self._impl.verify_chain(chain)
|
|
235
|
+
|
|
236
|
+
def get_stats(self) -> dict:
|
|
237
|
+
return self._impl.get_stats()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── Bridge Protocols ────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class FirewallBridge(Protocol):
|
|
244
|
+
"""Protocol for firewall bridge implementations."""
|
|
245
|
+
|
|
246
|
+
def check(self, text: str) -> dict[str, Any]: ...
|
|
247
|
+
def get_stats(self) -> dict[str, Any]: ...
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class PIIBridge(Protocol):
|
|
251
|
+
"""Protocol for PII scanner bridge implementations."""
|
|
252
|
+
|
|
253
|
+
def redact(self, text: str) -> dict[str, Any]: ...
|
|
254
|
+
def get_stats(self) -> dict[str, Any]: ...
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class LoopBreakerBridge(Protocol):
|
|
258
|
+
"""Protocol for loop breaker bridge implementations."""
|
|
259
|
+
|
|
260
|
+
def check(self, session_id: str, content: str) -> dict[str, Any]: ...
|
|
261
|
+
def get_stats(self) -> dict[str, Any]: ...
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class HashChainBridge(Protocol):
|
|
265
|
+
"""Protocol for hash chain bridge implementations."""
|
|
266
|
+
|
|
267
|
+
def record(self, event_id: str, data: str) -> dict[str, Any]: ...
|
|
268
|
+
def get_stats(self) -> dict[str, Any]: ...
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── Factory Functions ────────────────────────────────────────
|
|
272
|
+
def get_firewall() -> FirewallBridge:
|
|
273
|
+
"""Get the best available firewall engine."""
|
|
274
|
+
if _rust_available:
|
|
275
|
+
return _RustFirewallBridge()
|
|
276
|
+
return _PythonFirewallBridge()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_pii_scanner() -> PIIBridge:
|
|
280
|
+
"""Get the best available PII scanner."""
|
|
281
|
+
if _rust_available:
|
|
282
|
+
return _RustPiiBridge()
|
|
283
|
+
return _PythonPiiBridge()
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def get_loop_breaker(**kwargs: Any) -> LoopBreakerBridge:
|
|
287
|
+
"""Get the best available loop breaker."""
|
|
288
|
+
if _rust_available:
|
|
289
|
+
return _RustLoopBridge(**kwargs)
|
|
290
|
+
return _PythonLoopBridge(**kwargs)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def get_hash_chain() -> HashChainBridge:
|
|
294
|
+
"""Get the best available hash chain."""
|
|
295
|
+
if _rust_available:
|
|
296
|
+
return _RustHashChainBridge()
|
|
297
|
+
return _PythonHashChainBridge()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def engine_status() -> dict[str, Any]:
|
|
301
|
+
"""Get engine status for diagnostics."""
|
|
302
|
+
return {
|
|
303
|
+
"engine": ENGINE,
|
|
304
|
+
"rust_available": _rust_available,
|
|
305
|
+
"rust_version": admina_core.version() if _rust_available else None,
|
|
306
|
+
}
|