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,202 @@
|
|
|
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 — EU AI Act compliance template plugin.
|
|
16
|
+
|
|
17
|
+
Loads requirements from ``eu_ai_act.yaml`` and implements the
|
|
18
|
+
:class:`BaseComplianceTemplate` interface for risk classification
|
|
19
|
+
and gap analysis.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import yaml # PyYAML — already a core dependency
|
|
29
|
+
|
|
30
|
+
from admina.plugins.base import BaseComplianceTemplate
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("admina.plugins.compliance.eu_ai_act")
|
|
33
|
+
|
|
34
|
+
_YAML_PATH = Path(__file__).parent / "eu_ai_act.yaml"
|
|
35
|
+
|
|
36
|
+
# Keywords used for risk classification (kept in code for performance)
|
|
37
|
+
_UNACCEPTABLE_KW = [
|
|
38
|
+
"social scoring",
|
|
39
|
+
"social credit",
|
|
40
|
+
"real-time biometric",
|
|
41
|
+
"mass surveillance",
|
|
42
|
+
"subliminal manipulation",
|
|
43
|
+
]
|
|
44
|
+
_HIGH_RISK_KW = [
|
|
45
|
+
"credit scor",
|
|
46
|
+
"recruitment",
|
|
47
|
+
"hiring",
|
|
48
|
+
"law enforcement",
|
|
49
|
+
"critical infrastructure",
|
|
50
|
+
"healthcare",
|
|
51
|
+
"medical",
|
|
52
|
+
"education",
|
|
53
|
+
"migration",
|
|
54
|
+
"border",
|
|
55
|
+
"judicial",
|
|
56
|
+
"biometric",
|
|
57
|
+
"financial",
|
|
58
|
+
"trading",
|
|
59
|
+
"insurance",
|
|
60
|
+
]
|
|
61
|
+
_SENSITIVE_DATA = {"health", "biometric", "financial", "criminal", "genetic"}
|
|
62
|
+
_LIMITED_KW = [
|
|
63
|
+
"chatbot",
|
|
64
|
+
"conversational",
|
|
65
|
+
"content generation",
|
|
66
|
+
"emotion recognition",
|
|
67
|
+
"deepfake",
|
|
68
|
+
"synthetic media",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EUAIActComplianceTemplate(BaseComplianceTemplate):
|
|
73
|
+
"""Compliance template for the EU AI Act.
|
|
74
|
+
|
|
75
|
+
Loads structured requirements from the bundled YAML file and
|
|
76
|
+
provides risk classification and gap analysis.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
self._data = self._load_yaml()
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _load_yaml() -> dict:
|
|
84
|
+
"""Load the bundled YAML requirements file."""
|
|
85
|
+
try:
|
|
86
|
+
return yaml.safe_load(_YAML_PATH.read_text(encoding="utf-8")) or {}
|
|
87
|
+
except (OSError, ValueError) as exc:
|
|
88
|
+
logger.warning("Failed to load EU AI Act YAML: %s", exc)
|
|
89
|
+
return {}
|
|
90
|
+
|
|
91
|
+
# ── BaseComplianceTemplate interface ────────────────────────
|
|
92
|
+
|
|
93
|
+
def get_requirements(self) -> list[dict]:
|
|
94
|
+
"""Return the list of EU AI Act requirements.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of ``{"id": str, "title": str, "article": str,
|
|
98
|
+
"checks": list[str]}``.
|
|
99
|
+
"""
|
|
100
|
+
reqs: list[dict] = []
|
|
101
|
+
for req_id, req_info in self._data.get("requirements", {}).items():
|
|
102
|
+
reqs.append(
|
|
103
|
+
{
|
|
104
|
+
"id": req_id,
|
|
105
|
+
"title": req_info["name"],
|
|
106
|
+
"article": req_info["article"],
|
|
107
|
+
"checks": req_info["checks"],
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return reqs
|
|
111
|
+
|
|
112
|
+
def evaluate(self, governance_state: dict) -> dict:
|
|
113
|
+
"""Evaluate current governance state against EU AI Act.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
governance_state: A dict with:
|
|
117
|
+
- ``risk_category`` (str): the classified risk level.
|
|
118
|
+
- ``current_compliance`` (dict[str, list[bool]]): check results.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
``{"score": float, "gaps": list, "covered": list,
|
|
122
|
+
"enforcement_deadline": str}``.
|
|
123
|
+
"""
|
|
124
|
+
risk = governance_state.get("risk_category", "minimal")
|
|
125
|
+
compliance = governance_state.get("current_compliance", {})
|
|
126
|
+
|
|
127
|
+
if risk not in ("high", "unacceptable"):
|
|
128
|
+
return {
|
|
129
|
+
"score": 1.0,
|
|
130
|
+
"gaps": [],
|
|
131
|
+
"covered": [],
|
|
132
|
+
"enforcement_deadline": self._data.get("enforcement_deadline", "2027-12-02"),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
gaps: list[dict] = []
|
|
136
|
+
covered: list[dict] = []
|
|
137
|
+
total = 0
|
|
138
|
+
passed = 0
|
|
139
|
+
|
|
140
|
+
for req_id, req_info in self._data.get("requirements", {}).items():
|
|
141
|
+
checks = compliance.get(req_id, [False] * len(req_info["checks"]))
|
|
142
|
+
for check_desc, is_met in zip(req_info["checks"], checks):
|
|
143
|
+
total += 1
|
|
144
|
+
entry = {
|
|
145
|
+
"requirement": req_info["name"],
|
|
146
|
+
"article": req_info["article"],
|
|
147
|
+
"check": check_desc,
|
|
148
|
+
}
|
|
149
|
+
if is_met:
|
|
150
|
+
passed += 1
|
|
151
|
+
covered.append(entry)
|
|
152
|
+
else:
|
|
153
|
+
gaps.append(entry)
|
|
154
|
+
|
|
155
|
+
score = round(passed / max(total, 1), 4)
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
"score": score,
|
|
159
|
+
"gaps": gaps,
|
|
160
|
+
"covered": covered,
|
|
161
|
+
"total_checks": total,
|
|
162
|
+
"passed_checks": passed,
|
|
163
|
+
"enforcement_deadline": self._data.get("enforcement_deadline", "2027-12-02"),
|
|
164
|
+
"assessed_at": datetime.now(UTC).isoformat(),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def framework_name(self) -> str:
|
|
169
|
+
"""Framework name."""
|
|
170
|
+
return "EU AI Act"
|
|
171
|
+
|
|
172
|
+
# ── Extra utility (not part of ABC) ─────────────────────────
|
|
173
|
+
|
|
174
|
+
def classify_risk(
|
|
175
|
+
self,
|
|
176
|
+
system_description: str,
|
|
177
|
+
use_case: str,
|
|
178
|
+
data_types: list[str] | None = None,
|
|
179
|
+
) -> str:
|
|
180
|
+
"""Classify an AI system's risk under the EU AI Act.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
One of ``"unacceptable"``, ``"high"``, ``"limited"``, ``"minimal"``.
|
|
184
|
+
"""
|
|
185
|
+
desc = system_description.lower()
|
|
186
|
+
uc = use_case.lower()
|
|
187
|
+
|
|
188
|
+
if any(kw in desc or kw in uc for kw in _UNACCEPTABLE_KW):
|
|
189
|
+
return "unacceptable"
|
|
190
|
+
|
|
191
|
+
score = 0
|
|
192
|
+
if any(kw in desc or kw in uc for kw in _HIGH_RISK_KW):
|
|
193
|
+
score += 2
|
|
194
|
+
if data_types and any(d.lower() in _SENSITIVE_DATA for d in data_types):
|
|
195
|
+
score += 1
|
|
196
|
+
if score >= 2:
|
|
197
|
+
return "high"
|
|
198
|
+
|
|
199
|
+
if any(kw in desc or kw in uc for kw in _LIMITED_KW):
|
|
200
|
+
return "limited"
|
|
201
|
+
|
|
202
|
+
return "minimal"
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
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 — ChromaDB data connector.
|
|
16
|
+
|
|
17
|
+
Wraps the ``chromadb`` Python client for vector-store operations.
|
|
18
|
+
|
|
19
|
+
Requires: ``pip install chromadb`` (optional dependency).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from admina.plugins.base import BaseDataConnector
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("admina.plugins.connectors.chromadb")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChromaDBConnector(BaseDataConnector):
|
|
34
|
+
"""Data connector for ChromaDB vector store.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
host: ChromaDB server host.
|
|
38
|
+
port: ChromaDB server port.
|
|
39
|
+
collection_name: Default collection to operate on.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
host: str | None = None,
|
|
45
|
+
port: int | None = None,
|
|
46
|
+
collection_name: str | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._host = host or os.environ.get("ADMINA_CHROMA_HOST", "localhost")
|
|
49
|
+
self._port = port if port is not None else int(os.environ.get("ADMINA_CHROMA_PORT", "8000"))
|
|
50
|
+
self._collection_name = collection_name or os.environ.get(
|
|
51
|
+
"ADMINA_CHROMA_COLLECTION", "admina_default"
|
|
52
|
+
)
|
|
53
|
+
self._client: Any = None
|
|
54
|
+
self._collection: Any = None
|
|
55
|
+
|
|
56
|
+
def _get_collection(self) -> Any:
|
|
57
|
+
"""Lazily initialise the ChromaDB client and collection."""
|
|
58
|
+
if self._collection is None:
|
|
59
|
+
try:
|
|
60
|
+
import chromadb # type: ignore[import-untyped]
|
|
61
|
+
|
|
62
|
+
self._client = chromadb.HttpClient(host=self._host, port=self._port)
|
|
63
|
+
self._collection = self._client.get_or_create_collection(name=self._collection_name)
|
|
64
|
+
except ImportError as exc:
|
|
65
|
+
raise ImportError(
|
|
66
|
+
"The 'chromadb' package is required for ChromaDBConnector. "
|
|
67
|
+
"Install it with: pip install chromadb"
|
|
68
|
+
) from exc
|
|
69
|
+
return self._collection
|
|
70
|
+
|
|
71
|
+
# ── BaseDataConnector interface ─────────────────────────────
|
|
72
|
+
|
|
73
|
+
async def ingest(self, source: Any, **kwargs: Any) -> dict:
|
|
74
|
+
"""Ingest documents into ChromaDB.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
source: A list of dicts, each with ``"id"``, ``"text"``, and
|
|
78
|
+
optional ``"metadata"``.
|
|
79
|
+
**kwargs: Extra options (``collection_name``, etc.).
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
``{"doc_count": int, "chunk_count": int, "collection": str}``.
|
|
83
|
+
"""
|
|
84
|
+
collection = self._get_collection()
|
|
85
|
+
|
|
86
|
+
if isinstance(source, list):
|
|
87
|
+
docs = source
|
|
88
|
+
else:
|
|
89
|
+
docs = [{"id": "doc_0", "text": str(source)}]
|
|
90
|
+
|
|
91
|
+
ids = [d.get("id", f"doc_{i}") for i, d in enumerate(docs)]
|
|
92
|
+
documents = [d.get("text", str(d)) for d in docs]
|
|
93
|
+
metadatas = [d.get("metadata", {}) for d in docs]
|
|
94
|
+
|
|
95
|
+
collection.add(ids=ids, documents=documents, metadatas=metadatas)
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"doc_count": len(docs),
|
|
99
|
+
"chunk_count": len(docs),
|
|
100
|
+
"collection": self._collection_name,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async def query(self, query: str, **kwargs: Any) -> list[dict]:
|
|
104
|
+
"""Query ChromaDB for similar documents.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
query: Search query string.
|
|
108
|
+
**kwargs: Supports ``top_k`` (default 5).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Ranked list of ``{"text": str, "metadata": dict, "score": float}``.
|
|
112
|
+
"""
|
|
113
|
+
collection = self._get_collection()
|
|
114
|
+
top_k = kwargs.get("top_k", 5)
|
|
115
|
+
|
|
116
|
+
results = collection.query(query_texts=[query], n_results=top_k)
|
|
117
|
+
|
|
118
|
+
output: list[dict] = []
|
|
119
|
+
documents = results.get("documents", [[]])[0]
|
|
120
|
+
metadatas = results.get("metadatas", [[]])[0]
|
|
121
|
+
distances = results.get("distances", [[]])[0]
|
|
122
|
+
|
|
123
|
+
for text, meta, dist in zip(documents, metadatas, distances):
|
|
124
|
+
output.append(
|
|
125
|
+
{
|
|
126
|
+
"text": text,
|
|
127
|
+
"metadata": meta or {},
|
|
128
|
+
"score": round(1.0 / (1.0 + dist), 4),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return output
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def name(self) -> str:
|
|
136
|
+
"""Connector name."""
|
|
137
|
+
return "chromadb"
|
|
@@ -0,0 +1,111 @@
|
|
|
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 — Filesystem data connector.
|
|
16
|
+
|
|
17
|
+
A simple data connector that reads files from the local filesystem.
|
|
18
|
+
Useful as a zero-dependency fallback and for development.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from admina.plugins.base import BaseDataConnector
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("admina.plugins.connectors.filesystem")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FilesystemConnector(BaseDataConnector):
|
|
33
|
+
"""Data connector that reads plain-text files from a directory.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
base_dir: Root directory for file operations.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, base_dir: str = ".") -> None:
|
|
40
|
+
self._base_dir = Path(base_dir).resolve()
|
|
41
|
+
|
|
42
|
+
# ── BaseDataConnector interface ─────────────────────────────
|
|
43
|
+
|
|
44
|
+
async def ingest(self, source: Any, **kwargs: Any) -> dict:
|
|
45
|
+
"""Read files from *source* path(s).
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
source: A file path, directory path, or list of file paths.
|
|
49
|
+
**kwargs: Supports ``glob`` pattern (default ``"*"``) when
|
|
50
|
+
*source* is a directory.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
``{"doc_count": int, "chunk_count": int}``.
|
|
54
|
+
"""
|
|
55
|
+
paths: list[Path] = []
|
|
56
|
+
if isinstance(source, (list, tuple)):
|
|
57
|
+
paths = [Path(p) for p in source]
|
|
58
|
+
else:
|
|
59
|
+
p = Path(source)
|
|
60
|
+
if p.is_dir():
|
|
61
|
+
pattern = kwargs.get("glob", "*")
|
|
62
|
+
paths = list(p.glob(pattern))
|
|
63
|
+
else:
|
|
64
|
+
paths = [p]
|
|
65
|
+
|
|
66
|
+
chunk_count = 0
|
|
67
|
+
for fp in paths:
|
|
68
|
+
if fp.is_file():
|
|
69
|
+
chunk_count += 1
|
|
70
|
+
|
|
71
|
+
return {"doc_count": len(paths), "chunk_count": chunk_count}
|
|
72
|
+
|
|
73
|
+
async def query(self, query: str, **kwargs: Any) -> list[dict]:
|
|
74
|
+
"""Search files in the base directory for *query*.
|
|
75
|
+
|
|
76
|
+
A naive substring search — production use should prefer a
|
|
77
|
+
vector store connector like ChromaDB.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
query: Search string.
|
|
81
|
+
**kwargs: Supports ``glob`` pattern (default ``"**/*"``).
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of matching files with content excerpts.
|
|
85
|
+
"""
|
|
86
|
+
pattern = kwargs.get("glob", "**/*")
|
|
87
|
+
results: list[dict] = []
|
|
88
|
+
|
|
89
|
+
for fp in self._base_dir.glob(pattern):
|
|
90
|
+
if not fp.is_file():
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
text = fp.read_text(errors="replace")
|
|
94
|
+
except OSError:
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
if query.lower() in text.lower():
|
|
98
|
+
results.append(
|
|
99
|
+
{
|
|
100
|
+
"text": text[:500],
|
|
101
|
+
"metadata": {"path": str(fp)},
|
|
102
|
+
"score": 1.0,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
return results
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def name(self) -> str:
|
|
110
|
+
"""Connector name."""
|
|
111
|
+
return "filesystem"
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
|
|
@@ -0,0 +1,163 @@
|
|
|
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 — Filesystem forensic store plugin.
|
|
16
|
+
|
|
17
|
+
A zero-dependency fallback forensic store that writes JSON records to
|
|
18
|
+
the local filesystem. Suitable for development, testing, and
|
|
19
|
+
single-node deployments where S3/MinIO is not available.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import time
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from admina.plugins.base import BaseForensicStore
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger("admina.plugins.forensic.filesystem")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FilesystemForensicStore(BaseForensicStore):
|
|
37
|
+
"""Forensic store backed by local JSON files.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
base_dir: Directory to store forensic records.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, base_dir: str = ".admina/forensic") -> None:
|
|
44
|
+
self._base_dir = Path(base_dir).resolve()
|
|
45
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self._chain_head: str = "GENESIS"
|
|
47
|
+
self._record_count: int = 0
|
|
48
|
+
self._restore_chain_state()
|
|
49
|
+
|
|
50
|
+
# ── BaseForensicStore interface ─────────────────────────────
|
|
51
|
+
|
|
52
|
+
async def append(self, record: dict) -> str:
|
|
53
|
+
"""Write a governance record to a local JSON file.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
record: The governance event dict.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The SHA-256 hash of the stored record.
|
|
60
|
+
"""
|
|
61
|
+
self._record_count += 1
|
|
62
|
+
|
|
63
|
+
forensic_record = {
|
|
64
|
+
"sequence_number": self._record_count,
|
|
65
|
+
"timestamp_utc": datetime.now(UTC).isoformat(),
|
|
66
|
+
"timestamp_unix_ms": int(time.time() * 1000),
|
|
67
|
+
"previous_hash": self._chain_head,
|
|
68
|
+
"event": record,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
record_json = json.dumps(forensic_record, sort_keys=True, default=str)
|
|
72
|
+
record_hash = hashlib.sha256(record_json.encode("utf-8")).hexdigest()
|
|
73
|
+
forensic_record["record_hash"] = record_hash
|
|
74
|
+
self._chain_head = record_hash
|
|
75
|
+
|
|
76
|
+
# Write record file
|
|
77
|
+
record_file = self._base_dir / f"{self._record_count:08d}.json"
|
|
78
|
+
record_file.write_text(
|
|
79
|
+
json.dumps(forensic_record, indent=2, default=str),
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self._persist_chain_state()
|
|
84
|
+
return record_hash
|
|
85
|
+
|
|
86
|
+
async def verify_chain(self, last_n: int = 0) -> dict:
|
|
87
|
+
"""Verify hash-chain integrity by re-reading stored records.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
last_n: If > 0, verify only the last *n* records.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
``{"valid": bool, "records": int, "last_hash": str}``.
|
|
94
|
+
"""
|
|
95
|
+
files = sorted(self._base_dir.glob("[0-9]*.json"))
|
|
96
|
+
if last_n > 0:
|
|
97
|
+
files = files[-last_n:]
|
|
98
|
+
|
|
99
|
+
prev_hash: str | None = None
|
|
100
|
+
for fp in files:
|
|
101
|
+
try:
|
|
102
|
+
rec = json.loads(fp.read_text(encoding="utf-8"))
|
|
103
|
+
except (OSError, json.JSONDecodeError):
|
|
104
|
+
return {"valid": False, "records": 0, "last_hash": ""}
|
|
105
|
+
|
|
106
|
+
if prev_hash is not None and rec.get("previous_hash") != prev_hash:
|
|
107
|
+
return {
|
|
108
|
+
"valid": False,
|
|
109
|
+
"records": self._record_count,
|
|
110
|
+
"last_hash": self._chain_head,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Recompute hash to verify integrity
|
|
114
|
+
stored_hash = rec.pop("record_hash", "")
|
|
115
|
+
recomputed = hashlib.sha256(
|
|
116
|
+
json.dumps(rec, sort_keys=True, default=str).encode("utf-8")
|
|
117
|
+
).hexdigest()
|
|
118
|
+
if recomputed != stored_hash:
|
|
119
|
+
return {
|
|
120
|
+
"valid": False,
|
|
121
|
+
"records": self._record_count,
|
|
122
|
+
"last_hash": self._chain_head,
|
|
123
|
+
}
|
|
124
|
+
prev_hash = stored_hash
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"valid": True,
|
|
128
|
+
"records": self._record_count,
|
|
129
|
+
"last_hash": self._chain_head,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def store_name(self) -> str:
|
|
134
|
+
"""Store name."""
|
|
135
|
+
return "filesystem"
|
|
136
|
+
|
|
137
|
+
# ── Internal helpers ────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def _restore_chain_state(self) -> None:
|
|
140
|
+
"""Restore chain state from the state file on startup."""
|
|
141
|
+
state_file = self._base_dir / "_chain_state.json"
|
|
142
|
+
if state_file.exists():
|
|
143
|
+
try:
|
|
144
|
+
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
145
|
+
self._chain_head = state.get("chain_head", "GENESIS")
|
|
146
|
+
self._record_count = state.get("record_count", 0)
|
|
147
|
+
except (OSError, json.JSONDecodeError):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def _persist_chain_state(self) -> None:
|
|
151
|
+
"""Persist chain state to a JSON file."""
|
|
152
|
+
state_file = self._base_dir / "_chain_state.json"
|
|
153
|
+
state_file.write_text(
|
|
154
|
+
json.dumps(
|
|
155
|
+
{
|
|
156
|
+
"chain_head": self._chain_head,
|
|
157
|
+
"record_count": self._record_count,
|
|
158
|
+
"updated_at": datetime.now(UTC).isoformat(),
|
|
159
|
+
},
|
|
160
|
+
indent=2,
|
|
161
|
+
),
|
|
162
|
+
encoding="utf-8",
|
|
163
|
+
)
|