admina-framework 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. admina/__init__.py +34 -0
  2. admina/cli/__init__.py +14 -0
  3. admina/cli/commands/__init__.py +14 -0
  4. admina/cli/main.py +1522 -0
  5. admina/cli/templates/admina.yaml.j2 +77 -0
  6. admina/cli/templates/docker-compose.yml.j2 +254 -0
  7. admina/cli/templates/env.j2 +10 -0
  8. admina/cli/templates/main.py.j2 +95 -0
  9. admina/cli/templates/plugin.py.j2 +145 -0
  10. admina/cli/templates/plugin_pyproject.toml.j2 +15 -0
  11. admina/cli/templates/plugin_readme.md.j2 +27 -0
  12. admina/cli/templates/plugin_test.py.j2 +48 -0
  13. admina/core/__init__.py +14 -0
  14. admina/core/config.py +497 -0
  15. admina/core/event_bus.py +112 -0
  16. admina/core/secrets.py +257 -0
  17. admina/core/types.py +146 -0
  18. admina/dashboard/__init__.py +8 -0
  19. admina/dashboard/static/heimdall.png +0 -0
  20. admina/dashboard/static/index.html +1045 -0
  21. admina/dashboard/static/vendor/alpinejs.min.js +5 -0
  22. admina/domains/__init__.py +14 -0
  23. admina/domains/agent_security/__init__.py +41 -0
  24. admina/domains/agent_security/firewall.py +634 -0
  25. admina/domains/agent_security/loop_breaker.py +176 -0
  26. admina/domains/ai_infra/__init__.py +79 -0
  27. admina/domains/ai_infra/llm_engine.py +477 -0
  28. admina/domains/ai_infra/rag.py +817 -0
  29. admina/domains/ai_infra/webui.py +292 -0
  30. admina/domains/compliance/__init__.py +109 -0
  31. admina/domains/compliance/cross_regulation.py +314 -0
  32. admina/domains/compliance/eu_ai_act.py +367 -0
  33. admina/domains/compliance/forensic.py +380 -0
  34. admina/domains/compliance/gdpr.py +331 -0
  35. admina/domains/compliance/nis2.py +258 -0
  36. admina/domains/compliance/oisg.py +658 -0
  37. admina/domains/compliance/otel.py +101 -0
  38. admina/domains/data_sovereignty/__init__.py +42 -0
  39. admina/domains/data_sovereignty/classification.py +102 -0
  40. admina/domains/data_sovereignty/pii.py +260 -0
  41. admina/domains/data_sovereignty/residency.py +121 -0
  42. admina/integrations/__init__.py +14 -0
  43. admina/integrations/_engines.py +63 -0
  44. admina/integrations/cheshirecat/__init__.py +13 -0
  45. admina/integrations/cheshirecat/admina-plugin/admina_governance.py +207 -0
  46. admina/integrations/crewai/__init__.py +13 -0
  47. admina/integrations/crewai/callbacks.py +347 -0
  48. admina/integrations/langchain/__init__.py +13 -0
  49. admina/integrations/langchain/callbacks.py +341 -0
  50. admina/integrations/n8n/__init__.py +14 -0
  51. admina/integrations/openclaw/__init__.py +14 -0
  52. admina/plugins/__init__.py +49 -0
  53. admina/plugins/base.py +633 -0
  54. admina/plugins/builtin/__init__.py +14 -0
  55. admina/plugins/builtin/adapters/__init__.py +14 -0
  56. admina/plugins/builtin/adapters/ollama.py +120 -0
  57. admina/plugins/builtin/adapters/openai.py +138 -0
  58. admina/plugins/builtin/alerts/__init__.py +14 -0
  59. admina/plugins/builtin/alerts/log.py +66 -0
  60. admina/plugins/builtin/alerts/webhook.py +102 -0
  61. admina/plugins/builtin/auth/__init__.py +14 -0
  62. admina/plugins/builtin/auth/apikey.py +138 -0
  63. admina/plugins/builtin/compliance/__init__.py +14 -0
  64. admina/plugins/builtin/compliance/eu_ai_act.py +202 -0
  65. admina/plugins/builtin/connectors/__init__.py +14 -0
  66. admina/plugins/builtin/connectors/chromadb.py +137 -0
  67. admina/plugins/builtin/connectors/filesystem.py +111 -0
  68. admina/plugins/builtin/forensic/__init__.py +14 -0
  69. admina/plugins/builtin/forensic/filesystem.py +163 -0
  70. admina/plugins/builtin/forensic/minio.py +180 -0
  71. admina/plugins/builtin/guards/__init__.py +0 -0
  72. admina/plugins/builtin/guards/guardrailsai_guard.py +172 -0
  73. admina/plugins/builtin/pii/__init__.py +14 -0
  74. admina/plugins/builtin/pii/spacy_regex.py +160 -0
  75. admina/plugins/builtin/transports/__init__.py +14 -0
  76. admina/plugins/builtin/transports/http_rest.py +97 -0
  77. admina/plugins/builtin/transports/mcp.py +173 -0
  78. admina/plugins/registry.py +356 -0
  79. admina/proxy/__init__.py +15 -0
  80. admina/proxy/api/__init__.py +17 -0
  81. admina/proxy/api/dashboard.py +925 -0
  82. admina/proxy/api/integration.py +153 -0
  83. admina/proxy/config.py +214 -0
  84. admina/proxy/engine_bridge.py +306 -0
  85. admina/proxy/governance.py +232 -0
  86. admina/proxy/main.py +1484 -0
  87. admina/proxy/multi_upstream.py +156 -0
  88. admina/proxy/state.py +97 -0
  89. admina/py.typed +0 -0
  90. admina/sdk/__init__.py +34 -0
  91. admina/sdk/_compat.py +43 -0
  92. admina/sdk/compliance_kit.py +359 -0
  93. admina/sdk/governed_agent.py +391 -0
  94. admina/sdk/governed_data.py +434 -0
  95. admina/sdk/governed_model.py +241 -0
  96. admina_framework-0.9.0.dist-info/METADATA +575 -0
  97. admina_framework-0.9.0.dist-info/RECORD +102 -0
  98. admina_framework-0.9.0.dist-info/WHEEL +5 -0
  99. admina_framework-0.9.0.dist-info/entry_points.txt +2 -0
  100. admina_framework-0.9.0.dist-info/licenses/LICENSE +191 -0
  101. admina_framework-0.9.0.dist-info/licenses/NOTICE +16 -0
  102. admina_framework-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )