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,63 @@
|
|
|
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
|
+
"""Shared lazy-loaded governance engines for integration callbacks.
|
|
16
|
+
|
|
17
|
+
Thread-safe via module-level lock. Engines are created once on first use.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
|
|
24
|
+
_lock = threading.Lock()
|
|
25
|
+
_firewall = None
|
|
26
|
+
_pii_redactor = None
|
|
27
|
+
_loop_breaker = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_firewall():
|
|
31
|
+
"""Return the shared InjectionFirewall instance."""
|
|
32
|
+
global _firewall
|
|
33
|
+
if _firewall is None:
|
|
34
|
+
with _lock:
|
|
35
|
+
if _firewall is None:
|
|
36
|
+
from admina.domains.agent_security.firewall import InjectionFirewall
|
|
37
|
+
|
|
38
|
+
_firewall = InjectionFirewall()
|
|
39
|
+
return _firewall
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_pii_redactor():
|
|
43
|
+
"""Return the shared PIIRedactor instance."""
|
|
44
|
+
global _pii_redactor
|
|
45
|
+
if _pii_redactor is None:
|
|
46
|
+
with _lock:
|
|
47
|
+
if _pii_redactor is None:
|
|
48
|
+
from admina.domains.data_sovereignty.pii import PIIRedactor
|
|
49
|
+
|
|
50
|
+
_pii_redactor = PIIRedactor()
|
|
51
|
+
return _pii_redactor
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_loop_breaker():
|
|
55
|
+
"""Return the shared LoopBreaker instance."""
|
|
56
|
+
global _loop_breaker
|
|
57
|
+
if _loop_breaker is None:
|
|
58
|
+
with _lock:
|
|
59
|
+
if _loop_breaker is None:
|
|
60
|
+
from admina.domains.agent_security.loop_breaker import LoopBreaker
|
|
61
|
+
|
|
62
|
+
_loop_breaker = LoopBreaker()
|
|
63
|
+
return _loop_breaker
|
|
@@ -0,0 +1,13 @@
|
|
|
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.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Admina governance plugin for Cheshire Cat AI.
|
|
16
|
+
|
|
17
|
+
Intercepts the Cat's message pipeline via hooks to validate content
|
|
18
|
+
through an Admina sidecar proxy before sending replies or recalling
|
|
19
|
+
memories. Every interaction is audited to a forensic black box.
|
|
20
|
+
|
|
21
|
+
Install:
|
|
22
|
+
1. Start the Admina sidecar: ./setup.sh
|
|
23
|
+
2. Copy this plugin folder into the Cat's plugins/ directory
|
|
24
|
+
3. Activate from the Cat admin panel
|
|
25
|
+
|
|
26
|
+
Environment:
|
|
27
|
+
ADMINA_PROXY_URL — Admina sidecar URL (default: http://localhost:18790)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
from urllib.error import URLError
|
|
36
|
+
from urllib.request import Request, urlopen
|
|
37
|
+
|
|
38
|
+
from cat.mad_hatter.decorators import hook
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger("admina.cheshirecat")
|
|
41
|
+
|
|
42
|
+
_PROXY_URL = os.environ.get("ADMINA_PROXY_URL", "http://localhost:18790")
|
|
43
|
+
_TIMEOUT = int(os.environ.get("ADMINA_TIMEOUT", "5"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Helpers ──────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _call_admina(endpoint: str, payload: dict) -> dict | None:
|
|
50
|
+
"""Call an Admina sidecar endpoint. Returns None on failure."""
|
|
51
|
+
url = f"{_PROXY_URL}{endpoint}"
|
|
52
|
+
data = json.dumps(payload).encode("utf-8")
|
|
53
|
+
req = Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
54
|
+
try:
|
|
55
|
+
with urlopen(req, timeout=_TIMEOUT) as resp:
|
|
56
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
57
|
+
except (URLError, OSError, json.JSONDecodeError) as exc:
|
|
58
|
+
logger.warning("Admina proxy unreachable (%s): %s", url, exc)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _validate(content: str, session_id: str = "cheshirecat") -> dict | None:
|
|
63
|
+
"""Validate content through Admina governance pipeline."""
|
|
64
|
+
return _call_admina(
|
|
65
|
+
"/api/v1/validate",
|
|
66
|
+
{
|
|
67
|
+
"content": content,
|
|
68
|
+
"session_id": session_id,
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _audit(event: dict) -> dict | None:
|
|
74
|
+
"""Log an event to the Admina forensic black box."""
|
|
75
|
+
return _call_admina("/api/v1/audit", {"event": event})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── Cheshire Cat Hooks ───────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@hook(priority=0)
|
|
82
|
+
def agent_fast_reply(fast_reply: dict, cat) -> dict:
|
|
83
|
+
"""Intercept user messages BEFORE the agent processes them.
|
|
84
|
+
|
|
85
|
+
If the user message contains an injection or PII, the agent
|
|
86
|
+
receives a sanitised version (or a block notice).
|
|
87
|
+
"""
|
|
88
|
+
user_msg = cat.working_memory.get("user_message_json", {})
|
|
89
|
+
text = user_msg.get("text", "")
|
|
90
|
+
if not text:
|
|
91
|
+
return fast_reply
|
|
92
|
+
|
|
93
|
+
session_id = user_msg.get("user_id", "cheshirecat")
|
|
94
|
+
result = _validate(text, session_id=session_id)
|
|
95
|
+
|
|
96
|
+
if result is None:
|
|
97
|
+
# Proxy unreachable — fail open with warning
|
|
98
|
+
logger.warning("Admina proxy unreachable; allowing message without governance")
|
|
99
|
+
return fast_reply
|
|
100
|
+
|
|
101
|
+
if result.get("action") == "BLOCK":
|
|
102
|
+
# Block the message entirely — return a governance notice
|
|
103
|
+
_audit(
|
|
104
|
+
{
|
|
105
|
+
"action": "message_blocked",
|
|
106
|
+
"input": text[:200],
|
|
107
|
+
"risk_level": result.get("risk_level", "HIGH"),
|
|
108
|
+
"reason": "injection_detected",
|
|
109
|
+
"session_id": session_id,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
return {
|
|
113
|
+
"output": (
|
|
114
|
+
"This message was blocked by Admina governance: "
|
|
115
|
+
f"{result.get('risk_level', 'HIGH')} risk detected. "
|
|
116
|
+
"Please rephrase your request."
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if result.get("action") == "MODIFY" and result.get("redacted_content"):
|
|
121
|
+
# Replace PII in the working memory so the LLM never sees it
|
|
122
|
+
user_msg["text"] = result["redacted_content"]
|
|
123
|
+
cat.working_memory["user_message_json"] = user_msg
|
|
124
|
+
_audit(
|
|
125
|
+
{
|
|
126
|
+
"action": "pii_redacted",
|
|
127
|
+
"session_id": session_id,
|
|
128
|
+
"entities": result.get("checks", {}).get("pii_redaction", {}).get("entities", []),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return fast_reply
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@hook(priority=0)
|
|
136
|
+
def before_cat_sends_message(message: dict, cat) -> dict:
|
|
137
|
+
"""Govern the Cat's outgoing reply before it reaches the user.
|
|
138
|
+
|
|
139
|
+
Validates the response text for PII leakage and audits the
|
|
140
|
+
interaction to the forensic black box.
|
|
141
|
+
"""
|
|
142
|
+
text = message.get("content", "")
|
|
143
|
+
if not text:
|
|
144
|
+
return message
|
|
145
|
+
|
|
146
|
+
user_msg = cat.working_memory.get("user_message_json", {})
|
|
147
|
+
session_id = user_msg.get("user_id", "cheshirecat")
|
|
148
|
+
|
|
149
|
+
result = _validate(text, session_id=session_id)
|
|
150
|
+
|
|
151
|
+
if result is not None:
|
|
152
|
+
if result.get("action") == "MODIFY" and result.get("redacted_content"):
|
|
153
|
+
message["content"] = result["redacted_content"]
|
|
154
|
+
|
|
155
|
+
if result.get("action") == "BLOCK":
|
|
156
|
+
message["content"] = (
|
|
157
|
+
"[Response blocked by Admina governance — "
|
|
158
|
+
f"{result.get('risk_level', 'HIGH')} risk content detected]"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Audit the full interaction
|
|
162
|
+
_audit(
|
|
163
|
+
{
|
|
164
|
+
"action": "cat_reply",
|
|
165
|
+
"input": user_msg.get("text", "")[:200],
|
|
166
|
+
"output": message.get("content", "")[:200],
|
|
167
|
+
"status": "governed",
|
|
168
|
+
"session_id": session_id,
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return message
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@hook(priority=0)
|
|
176
|
+
def before_cat_recalls_memories(default_query: str, cat) -> str:
|
|
177
|
+
"""Validate the RAG query before it hits the vector store.
|
|
178
|
+
|
|
179
|
+
Ensures no injected content is used as a retrieval query and
|
|
180
|
+
redacts any PII from the search terms.
|
|
181
|
+
"""
|
|
182
|
+
if not default_query:
|
|
183
|
+
return default_query
|
|
184
|
+
|
|
185
|
+
user_msg = cat.working_memory.get("user_message_json", {})
|
|
186
|
+
session_id = user_msg.get("user_id", "cheshirecat")
|
|
187
|
+
|
|
188
|
+
result = _validate(default_query, session_id=session_id)
|
|
189
|
+
|
|
190
|
+
if result is None:
|
|
191
|
+
return default_query
|
|
192
|
+
|
|
193
|
+
if result.get("action") == "BLOCK":
|
|
194
|
+
_audit(
|
|
195
|
+
{
|
|
196
|
+
"action": "rag_query_blocked",
|
|
197
|
+
"input": default_query[:200],
|
|
198
|
+
"risk_level": result.get("risk_level", "HIGH"),
|
|
199
|
+
"session_id": session_id,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
return "" # empty query = no retrieval
|
|
203
|
+
|
|
204
|
+
if result.get("action") == "MODIFY" and result.get("redacted_content"):
|
|
205
|
+
return result["redacted_content"]
|
|
206
|
+
|
|
207
|
+
return default_query
|
|
@@ -0,0 +1,13 @@
|
|
|
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.
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# Copyright © 2025–2026 Stefano Noferi & Admina contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Admina governance callbacks for CrewAI.
|
|
16
|
+
|
|
17
|
+
Provides :func:`admina_step_callback` and :func:`admina_task_callback`
|
|
18
|
+
for use with CrewAI's ``step_callback`` and ``task_callback`` hooks.
|
|
19
|
+
Every agent step (LLM reasoning, tool use) is validated through the
|
|
20
|
+
Admina governance pipeline.
|
|
21
|
+
|
|
22
|
+
Works **in-process** via the Admina SDK — no sidecar proxy needed.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
from crewai import Agent, Task, Crew
|
|
27
|
+
from admina.integrations.crewai.callbacks import admina_step_callback, admina_task_callback
|
|
28
|
+
|
|
29
|
+
agent = Agent(
|
|
30
|
+
role="Researcher",
|
|
31
|
+
goal="Find quarterly revenue",
|
|
32
|
+
step_callback=admina_step_callback,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
crew = Crew(
|
|
36
|
+
agents=[agent],
|
|
37
|
+
tasks=[task],
|
|
38
|
+
task_callback=admina_task_callback,
|
|
39
|
+
)
|
|
40
|
+
crew.kickoff()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import logging
|
|
46
|
+
import time
|
|
47
|
+
import uuid
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from admina.core.event_bus import GovernanceEvent, bus
|
|
52
|
+
from admina.core.types import EventType
|
|
53
|
+
from admina.integrations._engines import get_firewall, get_loop_breaker, get_pii_redactor
|
|
54
|
+
from admina.sdk._compat import run_sync
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger("admina.integrations.crewai")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GovernanceBlockedError(Exception):
|
|
60
|
+
"""Raised when Admina blocks a CrewAI step."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, action: str, risk_level: str, details: dict | None = None):
|
|
63
|
+
self.action = action
|
|
64
|
+
self.risk_level = risk_level
|
|
65
|
+
self.details = details or {}
|
|
66
|
+
super().__init__(f"Admina governance {action}: risk_level={risk_level}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class GovernanceResult:
|
|
71
|
+
"""Result of an Admina governance check on a CrewAI step."""
|
|
72
|
+
|
|
73
|
+
action: str = "ALLOW"
|
|
74
|
+
risk_level: str = "LOW"
|
|
75
|
+
pii_count: int = 0
|
|
76
|
+
redacted_text: str | None = None
|
|
77
|
+
checks: dict[str, Any] = field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _govern(
|
|
81
|
+
text: str,
|
|
82
|
+
session_id: str,
|
|
83
|
+
direction: str = "inbound",
|
|
84
|
+
*,
|
|
85
|
+
firewall: bool = True,
|
|
86
|
+
loop_detection: bool = True,
|
|
87
|
+
pii_redaction: bool = True,
|
|
88
|
+
) -> GovernanceResult:
|
|
89
|
+
"""Run the Admina governance pipeline on text."""
|
|
90
|
+
result = GovernanceResult()
|
|
91
|
+
start = time.perf_counter()
|
|
92
|
+
|
|
93
|
+
# Firewall (inbound only)
|
|
94
|
+
if firewall and direction == "inbound":
|
|
95
|
+
fw = get_firewall()
|
|
96
|
+
fw_result = fw.check(text)
|
|
97
|
+
result.checks["firewall"] = fw_result
|
|
98
|
+
if fw_result.get("is_injection"):
|
|
99
|
+
result.action = "BLOCK"
|
|
100
|
+
rl = fw_result.get("risk_level", "HIGH")
|
|
101
|
+
result.risk_level = rl.value if hasattr(rl, "value") else str(rl)
|
|
102
|
+
|
|
103
|
+
# Loop detection (inbound only)
|
|
104
|
+
if loop_detection and direction == "inbound":
|
|
105
|
+
lb = get_loop_breaker()
|
|
106
|
+
lb_result = lb.check(session_id, text)
|
|
107
|
+
result.checks["loop_breaker"] = lb_result
|
|
108
|
+
if lb_result.get("is_loop"):
|
|
109
|
+
result.action = "CIRCUIT_BREAK"
|
|
110
|
+
result.risk_level = "HIGH"
|
|
111
|
+
|
|
112
|
+
# PII redaction (both directions)
|
|
113
|
+
if pii_redaction:
|
|
114
|
+
pii = get_pii_redactor()
|
|
115
|
+
pii_result = pii.redact(text)
|
|
116
|
+
result.checks["pii_redaction"] = {
|
|
117
|
+
"count": pii_result["count"],
|
|
118
|
+
"entities": [e["type"] for e in pii_result.get("entities", [])],
|
|
119
|
+
}
|
|
120
|
+
if pii_result["count"] > 0:
|
|
121
|
+
result.pii_count = pii_result["count"]
|
|
122
|
+
result.redacted_text = pii_result["redacted_text"]
|
|
123
|
+
if result.action == "ALLOW":
|
|
124
|
+
result.action = "REDACT"
|
|
125
|
+
|
|
126
|
+
result.checks["latency_ms"] = round((time.perf_counter() - start) * 1000, 2)
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _emit(event_type: EventType, session_id: str, **kwargs: Any) -> None:
|
|
131
|
+
"""Emit a governance event to the bus."""
|
|
132
|
+
event = GovernanceEvent(
|
|
133
|
+
event_type=event_type,
|
|
134
|
+
session_id=session_id,
|
|
135
|
+
domain="crewai",
|
|
136
|
+
**kwargs,
|
|
137
|
+
)
|
|
138
|
+
run_sync(bus.emit(event))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── CrewAI Callbacks ─────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AdminaStepCallback:
|
|
145
|
+
"""Callable that governs each CrewAI agent step.
|
|
146
|
+
|
|
147
|
+
Use as ``step_callback`` on a CrewAI ``Agent`` or ``Crew``.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
session_id: Session ID for loop detection.
|
|
151
|
+
pii_redaction: Enable PII redaction.
|
|
152
|
+
firewall: Enable injection firewall.
|
|
153
|
+
loop_detection: Enable loop breaker.
|
|
154
|
+
on_block: ``"raise"`` or ``"warn"``.
|
|
155
|
+
audit: Emit events to governance bus.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
session_id: str | None = None,
|
|
161
|
+
pii_redaction: bool = True,
|
|
162
|
+
firewall: bool = True,
|
|
163
|
+
loop_detection: bool = True,
|
|
164
|
+
on_block: str = "raise",
|
|
165
|
+
audit: bool = True,
|
|
166
|
+
) -> None:
|
|
167
|
+
self.session_id = session_id or f"crewai-{uuid.uuid4().hex[:8]}"
|
|
168
|
+
self.pii_redaction = pii_redaction
|
|
169
|
+
self.firewall = firewall
|
|
170
|
+
self.loop_detection = loop_detection
|
|
171
|
+
self.on_block = on_block
|
|
172
|
+
self.audit = audit
|
|
173
|
+
|
|
174
|
+
self.last_result: GovernanceResult | None = None
|
|
175
|
+
self._step_count = 0
|
|
176
|
+
self._block_count = 0
|
|
177
|
+
self._redact_count = 0
|
|
178
|
+
|
|
179
|
+
def __call__(self, step_output: Any) -> Any:
|
|
180
|
+
"""Called by CrewAI after each agent step.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
step_output: The CrewAI ``AgentAction`` or ``AgentFinish`` object.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The (possibly modified) step output.
|
|
187
|
+
"""
|
|
188
|
+
self._step_count += 1
|
|
189
|
+
|
|
190
|
+
# Extract text from CrewAI step output
|
|
191
|
+
text = self._extract_text(step_output)
|
|
192
|
+
if not text:
|
|
193
|
+
return step_output
|
|
194
|
+
|
|
195
|
+
result = _govern(
|
|
196
|
+
text,
|
|
197
|
+
self.session_id,
|
|
198
|
+
direction="inbound",
|
|
199
|
+
firewall=self.firewall,
|
|
200
|
+
loop_detection=self.loop_detection,
|
|
201
|
+
pii_redaction=self.pii_redaction,
|
|
202
|
+
)
|
|
203
|
+
self.last_result = result
|
|
204
|
+
|
|
205
|
+
if self.audit:
|
|
206
|
+
_emit(
|
|
207
|
+
EventType.AGENT_REQUEST,
|
|
208
|
+
session_id=self.session_id,
|
|
209
|
+
action=result.action,
|
|
210
|
+
risk_level=result.risk_level,
|
|
211
|
+
metadata={
|
|
212
|
+
"step": self._step_count,
|
|
213
|
+
"text_length": len(text),
|
|
214
|
+
"pii_count": result.pii_count,
|
|
215
|
+
},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if result.action in ("BLOCK", "CIRCUIT_BREAK"):
|
|
219
|
+
self._block_count += 1
|
|
220
|
+
if self.on_block == "raise":
|
|
221
|
+
raise GovernanceBlockedError(result.action, result.risk_level, result.checks)
|
|
222
|
+
logger.warning(
|
|
223
|
+
"[BLOCKED] CrewAI step %d: action=%s risk=%s",
|
|
224
|
+
self._step_count,
|
|
225
|
+
result.action,
|
|
226
|
+
result.risk_level,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if result.pii_count > 0:
|
|
230
|
+
self._redact_count += 1
|
|
231
|
+
|
|
232
|
+
return step_output
|
|
233
|
+
|
|
234
|
+
@staticmethod
|
|
235
|
+
def _extract_text(step_output: Any) -> str:
|
|
236
|
+
"""Extract text content from a CrewAI step output."""
|
|
237
|
+
if isinstance(step_output, str):
|
|
238
|
+
return step_output
|
|
239
|
+
if isinstance(step_output, dict):
|
|
240
|
+
return step_output.get("text", step_output.get("output", ""))
|
|
241
|
+
# CrewAI AgentAction has .tool_input, AgentFinish has .return_values
|
|
242
|
+
if hasattr(step_output, "tool_input"):
|
|
243
|
+
return str(step_output.tool_input)
|
|
244
|
+
if hasattr(step_output, "return_values"):
|
|
245
|
+
vals = step_output.return_values
|
|
246
|
+
if isinstance(vals, dict):
|
|
247
|
+
return vals.get("output", str(vals))
|
|
248
|
+
return str(vals)
|
|
249
|
+
if hasattr(step_output, "text"):
|
|
250
|
+
return step_output.text
|
|
251
|
+
return str(step_output)
|
|
252
|
+
|
|
253
|
+
def get_stats(self) -> dict[str, Any]:
|
|
254
|
+
"""Return governance statistics."""
|
|
255
|
+
return {
|
|
256
|
+
"session_id": self.session_id,
|
|
257
|
+
"step_count": self._step_count,
|
|
258
|
+
"block_count": self._block_count,
|
|
259
|
+
"redact_count": self._redact_count,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class AdminaTaskCallback:
|
|
264
|
+
"""Callable that governs CrewAI task outputs.
|
|
265
|
+
|
|
266
|
+
Use as ``task_callback`` on a CrewAI ``Crew``.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
session_id: Session ID for auditing.
|
|
270
|
+
pii_redaction: Enable PII redaction on task output.
|
|
271
|
+
audit: Emit events to governance bus.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(
|
|
275
|
+
self,
|
|
276
|
+
session_id: str | None = None,
|
|
277
|
+
pii_redaction: bool = True,
|
|
278
|
+
audit: bool = True,
|
|
279
|
+
) -> None:
|
|
280
|
+
self.session_id = session_id or f"crewai-task-{uuid.uuid4().hex[:8]}"
|
|
281
|
+
self.pii_redaction = pii_redaction
|
|
282
|
+
self.audit = audit
|
|
283
|
+
|
|
284
|
+
self.last_result: GovernanceResult | None = None
|
|
285
|
+
self._task_count = 0
|
|
286
|
+
|
|
287
|
+
def __call__(self, task_output: Any) -> Any:
|
|
288
|
+
"""Called by CrewAI after each task completes.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
task_output: The CrewAI ``TaskOutput`` object.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
The (possibly modified) task output.
|
|
295
|
+
"""
|
|
296
|
+
self._task_count += 1
|
|
297
|
+
|
|
298
|
+
# Extract text from task output
|
|
299
|
+
text = ""
|
|
300
|
+
if isinstance(task_output, str):
|
|
301
|
+
text = task_output
|
|
302
|
+
elif hasattr(task_output, "raw"):
|
|
303
|
+
text = str(task_output.raw)
|
|
304
|
+
elif hasattr(task_output, "output"):
|
|
305
|
+
text = str(task_output.output)
|
|
306
|
+
|
|
307
|
+
if not text:
|
|
308
|
+
return task_output
|
|
309
|
+
|
|
310
|
+
result = _govern(
|
|
311
|
+
text,
|
|
312
|
+
self.session_id,
|
|
313
|
+
direction="outbound",
|
|
314
|
+
pii_redaction=self.pii_redaction,
|
|
315
|
+
)
|
|
316
|
+
self.last_result = result
|
|
317
|
+
|
|
318
|
+
if self.audit:
|
|
319
|
+
_emit(
|
|
320
|
+
EventType.AGENT_RESPONSE,
|
|
321
|
+
session_id=self.session_id,
|
|
322
|
+
action=result.action,
|
|
323
|
+
risk_level=result.risk_level,
|
|
324
|
+
metadata={
|
|
325
|
+
"task": self._task_count,
|
|
326
|
+
"output_length": len(text),
|
|
327
|
+
"pii_redacted": result.pii_count,
|
|
328
|
+
},
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
return task_output
|
|
332
|
+
|
|
333
|
+
def get_stats(self) -> dict[str, Any]:
|
|
334
|
+
"""Return task governance statistics."""
|
|
335
|
+
return {
|
|
336
|
+
"session_id": self.session_id,
|
|
337
|
+
"task_count": self._task_count,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ── Convenience instances ────────────────────────────────────
|
|
342
|
+
# These can be used directly without instantiation:
|
|
343
|
+
# Agent(step_callback=admina_step_callback)
|
|
344
|
+
# Crew(task_callback=admina_task_callback)
|
|
345
|
+
|
|
346
|
+
admina_step_callback = AdminaStepCallback()
|
|
347
|
+
admina_task_callback = AdminaTaskCallback()
|
|
@@ -0,0 +1,13 @@
|
|
|
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.
|