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,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.