iris-security-crewai 0.1.1__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.
- examples/governed_crew.py +84 -0
- iris_crewai/__init__.py +47 -0
- iris_crewai/_governance.py +431 -0
- iris_crewai/agent.py +121 -0
- iris_crewai/crew.py +144 -0
- iris_crewai/tools.py +92 -0
- iris_security_crewai-0.1.1.dist-info/METADATA +16 -0
- iris_security_crewai-0.1.1.dist-info/RECORD +12 -0
- iris_security_crewai-0.1.1.dist-info/WHEEL +5 -0
- iris_security_crewai-0.1.1.dist-info/top_level.txt +3 -0
- tests/conftest.py +11 -0
- tests/test_crewai_integration.py +324 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Minimal two-agent CrewAI crew with IRIS governance.
|
|
3
|
+
|
|
4
|
+
Requires: pip install iris-crewai crewai[tools]
|
|
5
|
+
Set OPENAI_API_KEY before running.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from crewai import Crew, Task
|
|
11
|
+
from crewai.tools import tool
|
|
12
|
+
|
|
13
|
+
from iris import AgentPassport, ComplianceTag
|
|
14
|
+
from iris_crewai import IrisCrew, IrisCrewAgent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@tool
|
|
18
|
+
def web_search(query: str, data_region: str = "us-east-1") -> str:
|
|
19
|
+
"""Search the web for information on a topic."""
|
|
20
|
+
return f"Findings for '{query}' in {data_region}: AI governance is evolving rapidly."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@tool
|
|
24
|
+
def write_summary(content: str) -> str:
|
|
25
|
+
"""Write a polished summary from research notes."""
|
|
26
|
+
return f"Summary: {content[:200]}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> None:
|
|
30
|
+
researcher_passport = AgentPassport(
|
|
31
|
+
name="researcher-agent",
|
|
32
|
+
owner="team@company.com",
|
|
33
|
+
compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
|
|
34
|
+
)
|
|
35
|
+
writer_passport = AgentPassport(
|
|
36
|
+
name="writer-agent",
|
|
37
|
+
owner="team@company.com",
|
|
38
|
+
compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
researcher = IrisCrewAgent(
|
|
42
|
+
researcher_passport,
|
|
43
|
+
role="Researcher",
|
|
44
|
+
goal="Find accurate information on the assigned topic",
|
|
45
|
+
backstory="You are a diligent research analyst.",
|
|
46
|
+
tools=[web_search],
|
|
47
|
+
verbose=True,
|
|
48
|
+
)
|
|
49
|
+
writer = IrisCrewAgent(
|
|
50
|
+
writer_passport,
|
|
51
|
+
role="Writer",
|
|
52
|
+
goal="Produce a clear summary from research notes",
|
|
53
|
+
backstory="You are an experienced technical writer.",
|
|
54
|
+
tools=[write_summary],
|
|
55
|
+
verbose=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
research_task = Task(
|
|
59
|
+
description="Research the topic: {topic}",
|
|
60
|
+
expected_output="Bullet-point research notes",
|
|
61
|
+
agent=researcher,
|
|
62
|
+
)
|
|
63
|
+
write_task = Task(
|
|
64
|
+
description="Write a summary based on the research",
|
|
65
|
+
expected_output="A concise paragraph",
|
|
66
|
+
agent=writer,
|
|
67
|
+
context=[research_task],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
crew = IrisCrew.from_crew(
|
|
71
|
+
Crew(agents=[researcher, writer], tasks=[research_task, write_task], verbose=True),
|
|
72
|
+
passports={"Researcher": researcher_passport, "Writer": writer_passport},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
kickoff = crew.kickoff(inputs={"topic": "AI governance"})
|
|
76
|
+
report = crew.compliance_report()
|
|
77
|
+
|
|
78
|
+
print(kickoff["result"])
|
|
79
|
+
print(f"Crew pass rate: {report['crew_pass_rate']:.0%}")
|
|
80
|
+
print("Compliance report:", report)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
iris_crewai/__init__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IRIS CrewAI integration — governance in two lines per agent.
|
|
3
|
+
|
|
4
|
+
Quickstart:
|
|
5
|
+
from iris_crewai import IrisCrewAgent, IrisCrew
|
|
6
|
+
from iris import AgentPassport, ComplianceTag
|
|
7
|
+
|
|
8
|
+
passport = AgentPassport(
|
|
9
|
+
name="researcher-agent",
|
|
10
|
+
owner="team@company.com",
|
|
11
|
+
compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
|
|
12
|
+
)
|
|
13
|
+
agent = IrisCrewAgent(passport, role="Researcher", goal="...", backstory="...")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from iris import IrisViolationError
|
|
19
|
+
from iris_core.models.passport import (
|
|
20
|
+
AgentPassport,
|
|
21
|
+
ComplianceTag,
|
|
22
|
+
DataClassification,
|
|
23
|
+
Environment,
|
|
24
|
+
ToolPermission,
|
|
25
|
+
)
|
|
26
|
+
from iris_core.models.policy import PolicyResult, Severity, Violation
|
|
27
|
+
|
|
28
|
+
from iris_crewai.agent import IrisCrewAgent
|
|
29
|
+
from iris_crewai.crew import IrisCrew
|
|
30
|
+
from iris_crewai.tools import iris_crew_tool
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.0"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"IrisCrewAgent",
|
|
36
|
+
"IrisCrew",
|
|
37
|
+
"iris_crew_tool",
|
|
38
|
+
"IrisViolationError",
|
|
39
|
+
"AgentPassport",
|
|
40
|
+
"ComplianceTag",
|
|
41
|
+
"DataClassification",
|
|
42
|
+
"Environment",
|
|
43
|
+
"ToolPermission",
|
|
44
|
+
"PolicyResult",
|
|
45
|
+
"Severity",
|
|
46
|
+
"Violation",
|
|
47
|
+
]
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""Shared IRIS evaluation helpers for CrewAI integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from iris_core.cost import record_llm_cost_async
|
|
16
|
+
from iris_core.dlp import DLPScanner
|
|
17
|
+
from iris_core.dlp.enforcement import enforce_prompt_dlp
|
|
18
|
+
from iris_core.engine.cedar import CedarEngine, EvaluationContext
|
|
19
|
+
from iris_core.rbac.context import UserContext
|
|
20
|
+
from iris_core.evidence.vault import EvidenceVault
|
|
21
|
+
from iris_core.models.passport import AgentPassport, Environment
|
|
22
|
+
from iris_core.models.policy import PolicyResult
|
|
23
|
+
|
|
24
|
+
from iris import IrisViolationError
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("iris.crewai")
|
|
27
|
+
|
|
28
|
+
_VAULT_LOCK = threading.Lock()
|
|
29
|
+
_MOCK_SAFE_RESPONSE = "[IRIS] Action blocked by policy — mock safe response returned."
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class EvaluationRecord:
|
|
34
|
+
agent_name: str
|
|
35
|
+
action: str
|
|
36
|
+
resource: str
|
|
37
|
+
decision: str
|
|
38
|
+
violations: List[dict] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_environment(env: Optional[Environment] = None) -> Environment:
|
|
42
|
+
if env is not None:
|
|
43
|
+
return env
|
|
44
|
+
return Environment(os.environ.get("IRIS_ENV", "dev"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def has_policy_loaded(engine: CedarEngine, passport: AgentPassport) -> bool:
|
|
48
|
+
return bool(engine._policy_cache.get(passport.agent_id))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_passport_policy(engine: CedarEngine, passport: AgentPassport) -> None:
|
|
52
|
+
"""Load Cedar policy from passport.policy_ref when present on disk."""
|
|
53
|
+
if not passport.policy_ref:
|
|
54
|
+
return
|
|
55
|
+
policy_path = Path(passport.policy_ref)
|
|
56
|
+
if not policy_path.is_absolute():
|
|
57
|
+
policy_path = Path.cwd() / policy_path
|
|
58
|
+
if policy_path.exists():
|
|
59
|
+
engine.load_policy_file(passport.agent_id, policy_path)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def apply_no_policy_gate(
|
|
63
|
+
engine: CedarEngine,
|
|
64
|
+
passport: AgentPassport,
|
|
65
|
+
env: Environment,
|
|
66
|
+
result: PolicyResult,
|
|
67
|
+
) -> PolicyResult:
|
|
68
|
+
"""Fail open in dev/test when no policy is loaded; fail closed in staging/prod."""
|
|
69
|
+
if has_policy_loaded(engine, passport):
|
|
70
|
+
return result
|
|
71
|
+
if env in (Environment.DEV, Environment.TEST):
|
|
72
|
+
if result.decision == "DENY":
|
|
73
|
+
return PolicyResult(
|
|
74
|
+
decision="PERMIT_WITH_WARNINGS",
|
|
75
|
+
violations=result.violations,
|
|
76
|
+
agent_id=result.agent_id,
|
|
77
|
+
action=result.action,
|
|
78
|
+
resource=result.resource,
|
|
79
|
+
environment=result.environment,
|
|
80
|
+
)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def extract_regions(inputs: Optional[Dict[str, Any]]) -> tuple[Optional[str], Optional[str]]:
|
|
85
|
+
if not inputs:
|
|
86
|
+
return None, None
|
|
87
|
+
data_region = inputs.get("data_region")
|
|
88
|
+
destination_region = inputs.get("destination_region") or inputs.get("dest_region")
|
|
89
|
+
return (
|
|
90
|
+
str(data_region) if data_region is not None else None,
|
|
91
|
+
str(destination_region) if destination_region is not None else None,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_tool_input(tool_input: str) -> Optional[Dict[str, Any]]:
|
|
96
|
+
"""Parse CrewAI tool_input string into a dict when possible."""
|
|
97
|
+
if not tool_input:
|
|
98
|
+
return None
|
|
99
|
+
stripped = tool_input.strip()
|
|
100
|
+
if not stripped:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
parsed = json.loads(stripped)
|
|
104
|
+
if isinstance(parsed, dict):
|
|
105
|
+
return parsed
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
pass
|
|
108
|
+
return {"input": stripped}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def vault_partition_id(passport: AgentPassport) -> str:
|
|
112
|
+
"""Evidence vault partition key — one vault per agent name."""
|
|
113
|
+
return passport.name or passport.agent_id
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AgentGovernor:
|
|
117
|
+
"""Per-agent Cedar evaluation, evidence recording, and compliance tracking."""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
passport: AgentPassport,
|
|
122
|
+
environment: Optional[Environment] = None,
|
|
123
|
+
user_email: Optional[str] = None,
|
|
124
|
+
user_role: Optional[str] = None,
|
|
125
|
+
):
|
|
126
|
+
self.passport = passport
|
|
127
|
+
self.env = resolve_environment(environment)
|
|
128
|
+
self._user_email = user_email
|
|
129
|
+
self._user_role = user_role
|
|
130
|
+
self._engine = CedarEngine()
|
|
131
|
+
self._vault = EvidenceVault(agent_id=vault_partition_id(passport))
|
|
132
|
+
self._dlp = DLPScanner(passport)
|
|
133
|
+
load_passport_policy(self._engine, passport)
|
|
134
|
+
self.records: List[EvaluationRecord] = []
|
|
135
|
+
|
|
136
|
+
def evaluate_tool(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
action: str,
|
|
140
|
+
resource: str,
|
|
141
|
+
inputs: Optional[Dict[str, Any]] = None,
|
|
142
|
+
) -> PolicyResult:
|
|
143
|
+
data_region, destination_region = extract_regions(inputs)
|
|
144
|
+
data_classification = None
|
|
145
|
+
if inputs and inputs.get("data_classification") is not None:
|
|
146
|
+
data_classification = str(inputs["data_classification"])
|
|
147
|
+
prompt_text = ""
|
|
148
|
+
if inputs:
|
|
149
|
+
prompt_text = str(
|
|
150
|
+
inputs.get("input")
|
|
151
|
+
or inputs.get("prompt")
|
|
152
|
+
or inputs.get("tool_input")
|
|
153
|
+
or ""
|
|
154
|
+
)
|
|
155
|
+
dlp_result = (
|
|
156
|
+
enforce_prompt_dlp(
|
|
157
|
+
self._dlp,
|
|
158
|
+
self._vault,
|
|
159
|
+
self.passport,
|
|
160
|
+
self.env,
|
|
161
|
+
prompt_text,
|
|
162
|
+
resource=resource,
|
|
163
|
+
)
|
|
164
|
+
if prompt_text.strip()
|
|
165
|
+
else None
|
|
166
|
+
)
|
|
167
|
+
user_ctx = UserContext.from_params(self._user_email, self._user_role)
|
|
168
|
+
ctx = EvaluationContext(
|
|
169
|
+
agent_id=self.passport.agent_id,
|
|
170
|
+
action=action,
|
|
171
|
+
resource=resource,
|
|
172
|
+
resource_type="tool",
|
|
173
|
+
environment=self.env,
|
|
174
|
+
data_region=data_region,
|
|
175
|
+
destination_region=destination_region,
|
|
176
|
+
data_classification=data_classification,
|
|
177
|
+
user_consent_logged=bool(inputs.get("user_consent_logged")) if inputs else False,
|
|
178
|
+
dlp_prompt_findings=dlp_result.findings if dlp_result else None,
|
|
179
|
+
**user_ctx.evaluation_fields(),
|
|
180
|
+
)
|
|
181
|
+
result = self._engine.evaluate(self.passport, ctx)
|
|
182
|
+
result = apply_no_policy_gate(self._engine, self.passport, self.env, result)
|
|
183
|
+
with _VAULT_LOCK:
|
|
184
|
+
self._vault.record(ctx, result)
|
|
185
|
+
self._track(result)
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
def _track(self, result: PolicyResult) -> None:
|
|
189
|
+
self.records.append(
|
|
190
|
+
EvaluationRecord(
|
|
191
|
+
agent_name=vault_partition_id(self.passport),
|
|
192
|
+
action=result.action,
|
|
193
|
+
resource=result.resource,
|
|
194
|
+
decision=result.decision,
|
|
195
|
+
violations=[
|
|
196
|
+
{
|
|
197
|
+
"rule_id": v.rule_id,
|
|
198
|
+
"severity": v.severity.value,
|
|
199
|
+
"message": v.message,
|
|
200
|
+
}
|
|
201
|
+
for v in result.violations
|
|
202
|
+
],
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def evaluate_step_action(self, agent_action: Any) -> Optional[str]:
|
|
207
|
+
"""
|
|
208
|
+
Evaluate a CrewAI AgentAction step without altering crew output formatting.
|
|
209
|
+
|
|
210
|
+
Returns a mock safe response in dev when denied; raises in production.
|
|
211
|
+
"""
|
|
212
|
+
from crewai.agents.parser import AgentAction, AgentFinish
|
|
213
|
+
|
|
214
|
+
if isinstance(agent_action, AgentFinish):
|
|
215
|
+
logger.debug(
|
|
216
|
+
"IRIS step: agent=%s finished",
|
|
217
|
+
vault_partition_id(self.passport),
|
|
218
|
+
)
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
if not isinstance(agent_action, AgentAction):
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
tool_name = agent_action.tool
|
|
225
|
+
inputs = parse_tool_input(agent_action.tool_input)
|
|
226
|
+
result = self.evaluate_tool(action="call", resource=tool_name, inputs=inputs)
|
|
227
|
+
|
|
228
|
+
if result.decision == "DENY":
|
|
229
|
+
if self.env in (Environment.DEV, Environment.TEST):
|
|
230
|
+
for violation in result.violations:
|
|
231
|
+
msg = (
|
|
232
|
+
f"[IRIS WARNING] {violation.message} "
|
|
233
|
+
f"Remediation: {violation.remediation}"
|
|
234
|
+
)
|
|
235
|
+
logger.warning(msg)
|
|
236
|
+
print(msg, file=sys.stderr)
|
|
237
|
+
return _MOCK_SAFE_RESPONSE
|
|
238
|
+
raise IrisViolationError(result)
|
|
239
|
+
|
|
240
|
+
if result.decision == "PERMIT_WITH_WARNINGS":
|
|
241
|
+
for violation in result.violations:
|
|
242
|
+
msg = (
|
|
243
|
+
f"[IRIS WARNING] {violation.message} "
|
|
244
|
+
f"Remediation: {violation.remediation}"
|
|
245
|
+
)
|
|
246
|
+
logger.warning(msg)
|
|
247
|
+
print(msg, file=sys.stderr)
|
|
248
|
+
|
|
249
|
+
logger.debug(
|
|
250
|
+
"IRIS step: agent=%s tool=%s decision=%s",
|
|
251
|
+
vault_partition_id(self.passport),
|
|
252
|
+
tool_name,
|
|
253
|
+
result.decision,
|
|
254
|
+
)
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def record_step_llm_cost(self, agent_action: Any) -> None:
|
|
258
|
+
"""Record LLM cost when token usage is available on a CrewAI step."""
|
|
259
|
+
usage = getattr(agent_action, "usage", None) or getattr(agent_action, "token_usage", None)
|
|
260
|
+
if usage is None:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
model = (
|
|
264
|
+
getattr(agent_action, "model", None)
|
|
265
|
+
or getattr(agent_action, "model_name", None)
|
|
266
|
+
or "crewai-llm"
|
|
267
|
+
)
|
|
268
|
+
provider = "openai"
|
|
269
|
+
model_lower = str(model).lower()
|
|
270
|
+
if "claude" in model_lower:
|
|
271
|
+
provider = "anthropic"
|
|
272
|
+
elif "gemini" in model_lower:
|
|
273
|
+
provider = "google"
|
|
274
|
+
|
|
275
|
+
class _UsageResponse:
|
|
276
|
+
def __init__(self, usage_data: Any):
|
|
277
|
+
self.usage = usage_data
|
|
278
|
+
|
|
279
|
+
record_llm_cost_async(
|
|
280
|
+
agent_id=self.passport.agent_id,
|
|
281
|
+
agent_name=vault_partition_id(self.passport),
|
|
282
|
+
provider=provider,
|
|
283
|
+
model=str(model),
|
|
284
|
+
response=_UsageResponse(usage),
|
|
285
|
+
tool_name=getattr(agent_action, "tool", "crewai-llm"),
|
|
286
|
+
duration_ms=0.0,
|
|
287
|
+
environment=self.env.value,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def vault(self) -> EvidenceVault:
|
|
292
|
+
return self._vault
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def enforce_result(result: PolicyResult, env: Environment) -> None:
|
|
296
|
+
if result.decision == "DENY":
|
|
297
|
+
if env in (Environment.DEV, Environment.TEST):
|
|
298
|
+
for violation in result.violations:
|
|
299
|
+
msg = (
|
|
300
|
+
f"[IRIS WARNING] {violation.message} "
|
|
301
|
+
f"Remediation: {violation.remediation}"
|
|
302
|
+
)
|
|
303
|
+
logger.warning(msg)
|
|
304
|
+
print(msg, file=sys.stderr)
|
|
305
|
+
return
|
|
306
|
+
raise IrisViolationError(result)
|
|
307
|
+
if result.decision == "PERMIT_WITH_WARNINGS":
|
|
308
|
+
for violation in result.violations:
|
|
309
|
+
msg = (
|
|
310
|
+
f"[IRIS WARNING] {violation.message} "
|
|
311
|
+
f"Remediation: {violation.remediation}"
|
|
312
|
+
)
|
|
313
|
+
logger.warning(msg)
|
|
314
|
+
print(msg, file=sys.stderr)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def make_step_callback(
|
|
318
|
+
governor: AgentGovernor,
|
|
319
|
+
user_callback: Optional[Callable[..., Any]] = None,
|
|
320
|
+
) -> Callable[[Any], Any]:
|
|
321
|
+
"""Chain IRIS step evaluation with an optional user step_callback."""
|
|
322
|
+
|
|
323
|
+
def iris_step_callback(step_output: Any) -> Any:
|
|
324
|
+
mock_response = governor.evaluate_step_action(step_output)
|
|
325
|
+
governor.record_step_llm_cost(step_output)
|
|
326
|
+
if user_callback is not None:
|
|
327
|
+
user_result = user_callback(step_output)
|
|
328
|
+
return user_result if user_result is not None else mock_response
|
|
329
|
+
return mock_response
|
|
330
|
+
|
|
331
|
+
return iris_step_callback
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _agent_pass_rate(records: List[EvaluationRecord]) -> float:
|
|
335
|
+
if not records:
|
|
336
|
+
return 1.0
|
|
337
|
+
violations = sum(
|
|
338
|
+
len(r.violations) if r.violations else (1 if r.decision == "DENY" else 0)
|
|
339
|
+
for r in records
|
|
340
|
+
if r.decision != "PERMIT"
|
|
341
|
+
)
|
|
342
|
+
return max(0.0, (len(records) - violations) / len(records))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _agent_violation_count(records: List[EvaluationRecord]) -> int:
|
|
346
|
+
return sum(
|
|
347
|
+
len(r.violations) if r.violations else (1 if r.decision == "DENY" else 0)
|
|
348
|
+
for r in records
|
|
349
|
+
if r.decision != "PERMIT"
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _violations_by_severity(records: List[EvaluationRecord]) -> Dict[str, int]:
|
|
354
|
+
counter: Counter[str] = Counter()
|
|
355
|
+
for record in records:
|
|
356
|
+
if record.decision == "PERMIT":
|
|
357
|
+
continue
|
|
358
|
+
if record.violations:
|
|
359
|
+
for v in record.violations:
|
|
360
|
+
counter[v["severity"]] += 1
|
|
361
|
+
elif record.decision == "DENY":
|
|
362
|
+
counter["unknown"] += 1
|
|
363
|
+
return dict(counter)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _most_violated_rule(records: List[EvaluationRecord]) -> Optional[str]:
|
|
367
|
+
counter: Counter[str] = Counter()
|
|
368
|
+
for record in records:
|
|
369
|
+
if record.decision == "PERMIT":
|
|
370
|
+
continue
|
|
371
|
+
for v in record.violations:
|
|
372
|
+
counter[v["rule_id"]] += 1
|
|
373
|
+
if not counter:
|
|
374
|
+
return None
|
|
375
|
+
return counter.most_common(1)[0][0]
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def build_compliance_report(governors: Dict[str, AgentGovernor]) -> dict:
|
|
379
|
+
"""Aggregate per-agent evaluations into a crew compliance report."""
|
|
380
|
+
agents_report: Dict[str, dict] = {}
|
|
381
|
+
total_evaluations = 0
|
|
382
|
+
total_crew_violations = 0
|
|
383
|
+
violations_by_agent: Counter[str] = Counter()
|
|
384
|
+
|
|
385
|
+
for role, governor in governors.items():
|
|
386
|
+
records = governor.records
|
|
387
|
+
agent_name = vault_partition_id(governor.passport)
|
|
388
|
+
agent_violations = _agent_violation_count(records)
|
|
389
|
+
total_evaluations += len(records)
|
|
390
|
+
total_crew_violations += agent_violations
|
|
391
|
+
violations_by_agent[agent_name] = agent_violations
|
|
392
|
+
|
|
393
|
+
agents_report[role] = {
|
|
394
|
+
"agent_name": agent_name,
|
|
395
|
+
"total_evaluations": len(records),
|
|
396
|
+
"total_violations": agent_violations,
|
|
397
|
+
"violations_by_severity": _violations_by_severity(records),
|
|
398
|
+
"most_violated_rule": _most_violated_rule(records),
|
|
399
|
+
"pass_rate": round(_agent_pass_rate(records), 4),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
crew_pass_rate = (
|
|
403
|
+
round(max(0.0, (total_evaluations - total_crew_violations) / total_evaluations), 4)
|
|
404
|
+
if total_evaluations
|
|
405
|
+
else 1.0
|
|
406
|
+
)
|
|
407
|
+
most_problematic_agent = (
|
|
408
|
+
violations_by_agent.most_common(1)[0][0] if violations_by_agent else None
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
all_severity: Counter[str] = Counter()
|
|
412
|
+
all_rules: Counter[str] = Counter()
|
|
413
|
+
for role, governor in governors.items():
|
|
414
|
+
for severity, count in _violations_by_severity(governor.records).items():
|
|
415
|
+
all_severity[severity] += count
|
|
416
|
+
rule = _most_violated_rule(governor.records)
|
|
417
|
+
if rule:
|
|
418
|
+
all_rules[rule] += _agent_violation_count(governor.records)
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
"agents": agents_report,
|
|
422
|
+
"most_problematic_agent": most_problematic_agent,
|
|
423
|
+
"total_crew_violations": total_crew_violations,
|
|
424
|
+
"crew_pass_rate": crew_pass_rate,
|
|
425
|
+
"total_evaluations": total_evaluations,
|
|
426
|
+
"violations_by_severity": dict(all_severity),
|
|
427
|
+
"most_common_rule_violations": [
|
|
428
|
+
{"rule_id": rule_id, "count": count}
|
|
429
|
+
for rule_id, count in all_rules.most_common(10)
|
|
430
|
+
],
|
|
431
|
+
}
|
iris_crewai/agent.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""IrisCrewAgent — wrap any CrewAI agent with IRIS governance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from iris_core.models.passport import AgentPassport
|
|
8
|
+
|
|
9
|
+
from iris_crewai._governance import AgentGovernor, make_step_callback
|
|
10
|
+
from iris_crewai.tools import iris_crew_tool
|
|
11
|
+
|
|
12
|
+
_IRIS_CREW_AGENT_CLS: type | None = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _iris_crew_agent_class() -> type:
|
|
16
|
+
global _IRIS_CREW_AGENT_CLS
|
|
17
|
+
if _IRIS_CREW_AGENT_CLS is not None:
|
|
18
|
+
return _IRIS_CREW_AGENT_CLS
|
|
19
|
+
|
|
20
|
+
from crewai import Agent
|
|
21
|
+
|
|
22
|
+
class _IrisCrewAgentImpl(Agent):
|
|
23
|
+
"""
|
|
24
|
+
CrewAI Agent with per-agent IRIS governance.
|
|
25
|
+
|
|
26
|
+
Tool calls are evaluated against the agent's AgentPassport before execution.
|
|
27
|
+
step_callback records each step without altering crew output formatting.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, passport: AgentPassport, /, **crewai_agent_kwargs: Any):
|
|
31
|
+
governor = AgentGovernor(passport)
|
|
32
|
+
tools = crewai_agent_kwargs.get("tools")
|
|
33
|
+
if tools:
|
|
34
|
+
crewai_agent_kwargs["tools"] = _govern_tool_list(tools, passport, governor)
|
|
35
|
+
|
|
36
|
+
user_cb = crewai_agent_kwargs.pop("step_callback", None)
|
|
37
|
+
crewai_agent_kwargs["step_callback"] = make_step_callback(governor, user_cb)
|
|
38
|
+
|
|
39
|
+
super().__init__(**crewai_agent_kwargs)
|
|
40
|
+
object.__setattr__(self, "_iris_passport", passport)
|
|
41
|
+
object.__setattr__(self, "_iris_governor", governor)
|
|
42
|
+
|
|
43
|
+
def _iris_step_callback(self, agent_action: Any) -> Any:
|
|
44
|
+
"""Evaluate a CrewAI step against this agent's passport."""
|
|
45
|
+
result = self._iris_governor.evaluate_step_action(agent_action)
|
|
46
|
+
self._iris_governor.record_step_llm_cost(agent_action)
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_crew_agent(cls, agent: Agent, passport: AgentPassport) -> "_IrisCrewAgentImpl":
|
|
51
|
+
"""Wrap an existing CrewAI Agent with IRIS governance."""
|
|
52
|
+
kwargs = _agent_kwargs_from_instance(agent)
|
|
53
|
+
return cls(passport, **kwargs)
|
|
54
|
+
|
|
55
|
+
_IRIS_CREW_AGENT_CLS = _IrisCrewAgentImpl
|
|
56
|
+
return _IRIS_CREW_AGENT_CLS
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _govern_tool_list(
|
|
60
|
+
tools: List[Any],
|
|
61
|
+
passport: AgentPassport,
|
|
62
|
+
governor: AgentGovernor,
|
|
63
|
+
) -> List[Any]:
|
|
64
|
+
governed = []
|
|
65
|
+
for item in tools:
|
|
66
|
+
if hasattr(item, "_iris_governor"):
|
|
67
|
+
governed.append(item)
|
|
68
|
+
continue
|
|
69
|
+
wrapped = iris_crew_tool(item, passport, governor=governor)
|
|
70
|
+
governed.append(wrapped)
|
|
71
|
+
return governed
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _agent_kwargs_from_instance(agent: Any) -> Dict[str, Any]:
|
|
75
|
+
fields = (
|
|
76
|
+
"role",
|
|
77
|
+
"goal",
|
|
78
|
+
"backstory",
|
|
79
|
+
"llm",
|
|
80
|
+
"tools",
|
|
81
|
+
"verbose",
|
|
82
|
+
"allow_delegation",
|
|
83
|
+
"max_iter",
|
|
84
|
+
"max_rpm",
|
|
85
|
+
"max_execution_time",
|
|
86
|
+
"memory",
|
|
87
|
+
"cache",
|
|
88
|
+
"allow_code_execution",
|
|
89
|
+
"respect_context_window",
|
|
90
|
+
"max_retry_limit",
|
|
91
|
+
"function_calling_llm",
|
|
92
|
+
"embedder",
|
|
93
|
+
"system_template",
|
|
94
|
+
"prompt_template",
|
|
95
|
+
"response_template",
|
|
96
|
+
"use_system_prompt",
|
|
97
|
+
)
|
|
98
|
+
kwargs: Dict[str, Any] = {}
|
|
99
|
+
for field in fields:
|
|
100
|
+
value = getattr(agent, field, None)
|
|
101
|
+
if value is not None:
|
|
102
|
+
kwargs[field] = value
|
|
103
|
+
return kwargs
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class IrisCrewAgent:
|
|
107
|
+
"""
|
|
108
|
+
Drop-in governed CrewAI agent — two lines per agent in a crew.
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
researcher = IrisCrewAgent(researcher_passport, role="Researcher", goal="...", ...)
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __new__(cls, passport: AgentPassport, **crewai_agent_kwargs: Any) -> Any:
|
|
115
|
+
impl = _iris_crew_agent_class()
|
|
116
|
+
return impl(passport, **crewai_agent_kwargs)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_crew_agent(cls, agent: Any, passport: AgentPassport) -> Any:
|
|
120
|
+
impl = _iris_crew_agent_class()
|
|
121
|
+
return impl.from_crew_agent(agent, passport)
|
iris_crewai/crew.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""IrisCrew — govern a multi-agent CrewAI crew with per-agent passports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from iris_core.models.passport import AgentPassport, Environment
|
|
9
|
+
|
|
10
|
+
from iris import IrisViolationError
|
|
11
|
+
from iris_crewai._governance import AgentGovernor, build_compliance_report, resolve_environment
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("iris.crewai")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class IrisCrew:
|
|
17
|
+
"""
|
|
18
|
+
Wraps a CrewAI Crew and validates every agent has its own AgentPassport.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
crew = IrisCrew.from_crew(
|
|
22
|
+
Crew(agents=[researcher, writer], tasks=[...]),
|
|
23
|
+
passports={"Researcher": researcher_passport, "Writer": writer_passport},
|
|
24
|
+
)
|
|
25
|
+
result = crew.kickoff(inputs={"topic": "AI governance"})
|
|
26
|
+
report = crew.compliance_report()
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
crew: Any,
|
|
32
|
+
passports: Dict[str, AgentPassport],
|
|
33
|
+
governors: Dict[str, AgentGovernor],
|
|
34
|
+
):
|
|
35
|
+
self._crew = crew
|
|
36
|
+
self._passports = passports
|
|
37
|
+
self._governors = governors
|
|
38
|
+
self._last_report: Optional[dict] = None
|
|
39
|
+
self._last_violations: list[IrisViolationError] = []
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_crew(
|
|
43
|
+
cls,
|
|
44
|
+
crew: Any,
|
|
45
|
+
passports: Dict[str, AgentPassport],
|
|
46
|
+
user_email: Optional[str] = None,
|
|
47
|
+
user_role: Optional[str] = None,
|
|
48
|
+
) -> "IrisCrew":
|
|
49
|
+
from iris_core.dev_trust import print_dev_trust_message
|
|
50
|
+
|
|
51
|
+
print_dev_trust_message()
|
|
52
|
+
cls._validate_passports(crew, passports, strict=True)
|
|
53
|
+
governors = cls._collect_governors(crew, passports, user_email, user_role)
|
|
54
|
+
return cls(crew, passports, governors)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _validate_passports(
|
|
58
|
+
crew: Any,
|
|
59
|
+
passports: Dict[str, AgentPassport],
|
|
60
|
+
*,
|
|
61
|
+
strict: bool = True,
|
|
62
|
+
) -> None:
|
|
63
|
+
agent_roles = [agent.role for agent in crew.agents]
|
|
64
|
+
missing = [role for role in agent_roles if role not in passports]
|
|
65
|
+
if not missing:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
message = (
|
|
69
|
+
"Every agent in the crew must have an AgentPassport keyed by role name. "
|
|
70
|
+
f"Missing passports for: {', '.join(missing)}"
|
|
71
|
+
)
|
|
72
|
+
env = resolve_environment()
|
|
73
|
+
if strict or env in (Environment.STAGING, Environment.PRODUCTION):
|
|
74
|
+
raise ValueError(message)
|
|
75
|
+
logger.warning(message)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _collect_governors(
|
|
79
|
+
crew: Any,
|
|
80
|
+
passports: Dict[str, AgentPassport],
|
|
81
|
+
user_email: Optional[str] = None,
|
|
82
|
+
user_role: Optional[str] = None,
|
|
83
|
+
) -> Dict[str, AgentGovernor]:
|
|
84
|
+
governors: Dict[str, AgentGovernor] = {}
|
|
85
|
+
for agent in crew.agents:
|
|
86
|
+
role = agent.role
|
|
87
|
+
if hasattr(agent, "_iris_governor"):
|
|
88
|
+
governors[role] = agent._iris_governor
|
|
89
|
+
else:
|
|
90
|
+
governors[role] = AgentGovernor(
|
|
91
|
+
passports[role],
|
|
92
|
+
user_email=user_email,
|
|
93
|
+
user_role=user_role,
|
|
94
|
+
)
|
|
95
|
+
return governors
|
|
96
|
+
|
|
97
|
+
def kickoff(self, inputs: Optional[dict] = None, **kwargs: Any) -> dict:
|
|
98
|
+
"""
|
|
99
|
+
Run the governed crew.
|
|
100
|
+
|
|
101
|
+
Returns a dict with ``result`` (crew output) and ``compliance`` summary.
|
|
102
|
+
Compliance report is always generated, even when kickoff fails.
|
|
103
|
+
"""
|
|
104
|
+
self._last_violations = []
|
|
105
|
+
env = resolve_environment()
|
|
106
|
+
self._validate_passports(self._crew, self._passports, strict=False)
|
|
107
|
+
|
|
108
|
+
if env in (Environment.STAGING, Environment.PRODUCTION):
|
|
109
|
+
missing = [
|
|
110
|
+
role
|
|
111
|
+
for role in (agent.role for agent in self._crew.agents)
|
|
112
|
+
if role not in self._passports
|
|
113
|
+
]
|
|
114
|
+
if missing:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Cannot kickoff crew in production without passports for all agents. "
|
|
117
|
+
f"Missing passports for: {', '.join(missing)}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
crew_result: Any = None
|
|
121
|
+
try:
|
|
122
|
+
crew_result = self._crew.kickoff(inputs=inputs, **kwargs)
|
|
123
|
+
except IrisViolationError as exc:
|
|
124
|
+
self._last_violations.append(exc)
|
|
125
|
+
for violation in exc.result.violations:
|
|
126
|
+
logger.error(
|
|
127
|
+
"IRIS violation during crew kickoff [%s]: %s",
|
|
128
|
+
violation.rule_id,
|
|
129
|
+
violation.message,
|
|
130
|
+
)
|
|
131
|
+
finally:
|
|
132
|
+
self._last_report = build_compliance_report(self._governors)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"result": crew_result,
|
|
136
|
+
"compliance": self._last_report,
|
|
137
|
+
"violations": [exc.result.decision for exc in self._last_violations],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def compliance_report(self) -> dict:
|
|
141
|
+
"""Return crew compliance summary with per-agent violation counts."""
|
|
142
|
+
if self._last_report is not None:
|
|
143
|
+
return self._last_report
|
|
144
|
+
return build_compliance_report(self._governors)
|
iris_crewai/tools.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""iris_crew_tool — Cedar-governed CrewAI tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Optional
|
|
6
|
+
|
|
7
|
+
from iris_core.models.passport import AgentPassport, Environment
|
|
8
|
+
|
|
9
|
+
from iris_crewai._governance import AgentGovernor, enforce_result
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def iris_crew_tool(
|
|
13
|
+
tool_func: Any,
|
|
14
|
+
passport: AgentPassport,
|
|
15
|
+
action: str = "call",
|
|
16
|
+
environment: Optional[str] = None,
|
|
17
|
+
governor: Optional[AgentGovernor] = None,
|
|
18
|
+
) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
Wrap a CrewAI @tool-decorated function or Tool with IRIS policy evaluation.
|
|
21
|
+
|
|
22
|
+
Preserves tool name, description, and args_schema metadata.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
safe_search = iris_crew_tool(search_tool, passport, action="call")
|
|
26
|
+
"""
|
|
27
|
+
env = Environment(environment) if environment else None
|
|
28
|
+
active_governor = governor or AgentGovernor(passport, environment=env)
|
|
29
|
+
|
|
30
|
+
def _evaluate(inputs: Optional[dict]) -> None:
|
|
31
|
+
resource = _tool_resource_name(tool_func)
|
|
32
|
+
result = active_governor.evaluate_tool(action=action, resource=resource, inputs=inputs)
|
|
33
|
+
enforce_result(result, active_governor.env)
|
|
34
|
+
|
|
35
|
+
if _is_crewai_tool(tool_func):
|
|
36
|
+
return _wrap_base_tool(tool_func, _evaluate)
|
|
37
|
+
|
|
38
|
+
if callable(tool_func):
|
|
39
|
+
decorated = _ensure_crew_tool(tool_func)
|
|
40
|
+
return _wrap_base_tool(decorated, _evaluate)
|
|
41
|
+
|
|
42
|
+
raise TypeError(
|
|
43
|
+
"iris_crew_tool expects a crewai Tool/BaseTool instance or @tool-decorated callable."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_crewai_tool(obj: Any) -> bool:
|
|
48
|
+
try:
|
|
49
|
+
from crewai.tools.base_tool import BaseTool
|
|
50
|
+
|
|
51
|
+
return isinstance(obj, BaseTool)
|
|
52
|
+
except ImportError:
|
|
53
|
+
return hasattr(obj, "name") and hasattr(obj, "run") and hasattr(obj, "func")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ensure_crew_tool(func: Callable[..., Any]) -> Any:
|
|
57
|
+
if _is_crewai_tool(func):
|
|
58
|
+
return func
|
|
59
|
+
from crewai.tools import tool as crew_tool_decorator
|
|
60
|
+
|
|
61
|
+
return crew_tool_decorator(func)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tool_resource_name(tool: Any) -> str:
|
|
65
|
+
return getattr(tool, "name", getattr(tool, "__name__", "tool"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _normalize_inputs(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Optional[dict]:
|
|
69
|
+
if kwargs:
|
|
70
|
+
return kwargs
|
|
71
|
+
if args and isinstance(args[0], dict):
|
|
72
|
+
return args[0]
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _wrap_base_tool(tool: Any, evaluate: Callable[[Optional[dict]], None]) -> Any:
|
|
77
|
+
from crewai.tools.base_tool import Tool
|
|
78
|
+
|
|
79
|
+
original_func = tool.func
|
|
80
|
+
|
|
81
|
+
def guarded_func(*args: Any, **kwargs: Any) -> Any:
|
|
82
|
+
evaluate(_normalize_inputs(args, kwargs))
|
|
83
|
+
return original_func(*args, **kwargs)
|
|
84
|
+
|
|
85
|
+
return Tool(
|
|
86
|
+
name=tool.name,
|
|
87
|
+
description=tool.description,
|
|
88
|
+
func=guarded_func,
|
|
89
|
+
args_schema=tool.args_schema,
|
|
90
|
+
result_as_answer=getattr(tool, "result_as_answer", False),
|
|
91
|
+
max_usage_count=getattr(tool, "max_usage_count", None),
|
|
92
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iris-security-crewai
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: IRIS governance for CrewAI crews — Cedar policy on every agent tool call
|
|
5
|
+
Author-email: IRIS Platform <sdk@iris.ai>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/gimartinb/iris-sdk
|
|
8
|
+
Project-URL: Repository, https://github.com/gimartinb/iris-sdk
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: iris-security-core>=0.1.2
|
|
11
|
+
Requires-Dist: iris-security-sdk>=0.1.0
|
|
12
|
+
Provides-Extra: crewai
|
|
13
|
+
Requires-Dist: crewai>=0.30; extra == "crewai"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
16
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
examples/governed_crew.py,sha256=NrRVJOFL2JSMQ_ZjcJDFZxNL69Doem2yM9m17Esn_qw,2418
|
|
2
|
+
iris_crewai/__init__.py,sha256=Pv-XLny3Y1Qn_4jZ_QN9OdHC1cPr_Xlj9IJV07NCfns,1128
|
|
3
|
+
iris_crewai/_governance.py,sha256=Kj8RMTCOT0gFb0CbCkaGBApsBCB-3FKJnGlY2ryoU6s,14878
|
|
4
|
+
iris_crewai/agent.py,sha256=StrPdGm950gU0Rv_ae5sJ-ZaXjQOffH_PsCYwn96XHg,3856
|
|
5
|
+
iris_crewai/crew.py,sha256=YvRWxS2vgfdMPiA9Eiu1NC7IX6LJZAkDAJ20FRtvc-4,4974
|
|
6
|
+
iris_crewai/tools.py,sha256=uL8Cjb3kn-OhbM3moLuW3cWDEv5jJfAzYFMM1NkHiyk,2809
|
|
7
|
+
tests/conftest.py,sha256=sm1aXg5mjLSgCZa0zAEHg1KLIGJRTuXMJZ2h_eshOd0,309
|
|
8
|
+
tests/test_crewai_integration.py,sha256=rhrltYKiczEDfrTz8g5H8fhGaK4KmMAOIz4vBYeDxWg,10834
|
|
9
|
+
iris_security_crewai-0.1.1.dist-info/METADATA,sha256=aa-UnL1e9Gle6pDoPD89BNoaxexrI96235yBy7nMd4I,613
|
|
10
|
+
iris_security_crewai-0.1.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
iris_security_crewai-0.1.1.dist-info/top_level.txt,sha256=AVozfVQnYp2-_HkraadYbTpQgLkApkH3d-2GSMC1buo,27
|
|
12
|
+
iris_security_crewai-0.1.1.dist-info/RECORD,,
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Test fixtures — isolate Evidence Vault from the developer home directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture(autouse=True)
|
|
9
|
+
def iris_home_tmp(monkeypatch, tmp_path):
|
|
10
|
+
"""Write ~/.iris evidence under pytest tmp_path."""
|
|
11
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integration tests for iris-crewai agent wrapper, crew wrapper, and tool guard.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from unittest.mock import MagicMock
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from iris import IrisViolationError
|
|
13
|
+
from iris_core.engine.cedar import CedarEngine
|
|
14
|
+
from iris_core.evidence.vault import EvidenceVault
|
|
15
|
+
from iris_core.models.passport import (
|
|
16
|
+
AgentPassport,
|
|
17
|
+
ComplianceTag,
|
|
18
|
+
DataClassification,
|
|
19
|
+
Environment,
|
|
20
|
+
ToolPermission,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from iris_crewai import IrisCrew, IrisCrewAgent, iris_crew_tool
|
|
24
|
+
from iris_crewai._governance import EvaluationRecord, vault_partition_id
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def permitted_passport():
|
|
29
|
+
return AgentPassport(
|
|
30
|
+
name="researcher-agent",
|
|
31
|
+
owner="team@company.com",
|
|
32
|
+
data_classification=DataClassification.INTERNAL,
|
|
33
|
+
compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
|
|
34
|
+
environments=[Environment.DEV, Environment.PRODUCTION],
|
|
35
|
+
is_high_risk_ai=True,
|
|
36
|
+
evidence_vault_id="vault-abc",
|
|
37
|
+
intent_ref="governance/agents/researcher-agent/policy-intent.md",
|
|
38
|
+
tool_permissions=[
|
|
39
|
+
ToolPermission(
|
|
40
|
+
tool_id="web_search",
|
|
41
|
+
description="Web search",
|
|
42
|
+
allowed_actions=["call"],
|
|
43
|
+
),
|
|
44
|
+
],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.fixture
|
|
49
|
+
def writer_passport():
|
|
50
|
+
return AgentPassport(
|
|
51
|
+
name="writer-agent",
|
|
52
|
+
owner="team@company.com",
|
|
53
|
+
compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
|
|
54
|
+
tool_permissions=[
|
|
55
|
+
ToolPermission(
|
|
56
|
+
tool_id="write_summary",
|
|
57
|
+
description="Write summary",
|
|
58
|
+
allowed_actions=["call"],
|
|
59
|
+
),
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.fixture
|
|
65
|
+
def engine_with_policy(permitted_passport):
|
|
66
|
+
engine = CedarEngine()
|
|
67
|
+
engine.load_policy(permitted_passport.agent_id, "permit(principal, action, resource);")
|
|
68
|
+
return engine
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestIrisCrewAgent:
|
|
72
|
+
def test_crew_agent_blocks_denied_tool_in_production(
|
|
73
|
+
self, permitted_passport, engine_with_policy, tmp_path
|
|
74
|
+
):
|
|
75
|
+
from crewai.tools import tool
|
|
76
|
+
|
|
77
|
+
policy_file = tmp_path / "policy.cedar"
|
|
78
|
+
policy_file.write_text("permit(principal, action, resource);")
|
|
79
|
+
permitted_passport.policy_ref = str(policy_file)
|
|
80
|
+
|
|
81
|
+
@tool
|
|
82
|
+
def payments_api(action: str) -> str:
|
|
83
|
+
"""Execute a payment."""
|
|
84
|
+
return action
|
|
85
|
+
|
|
86
|
+
agent = IrisCrewAgent(
|
|
87
|
+
permitted_passport,
|
|
88
|
+
role="Researcher",
|
|
89
|
+
goal="Research",
|
|
90
|
+
backstory="Analyst",
|
|
91
|
+
tools=[payments_api],
|
|
92
|
+
)
|
|
93
|
+
agent._iris_governor._engine = engine_with_policy
|
|
94
|
+
agent._iris_governor.env = Environment.PRODUCTION
|
|
95
|
+
governed_tool = agent.tools[0]
|
|
96
|
+
|
|
97
|
+
with pytest.raises(IrisViolationError) as exc_info:
|
|
98
|
+
governed_tool.run(action="transfer")
|
|
99
|
+
|
|
100
|
+
assert exc_info.value.result.decision == "DENY"
|
|
101
|
+
assert any(v.rule_id == "IRIS-TOOL-001" for v in exc_info.value.result.violations)
|
|
102
|
+
|
|
103
|
+
def test_crew_agent_permits_allowed_tool(
|
|
104
|
+
self, permitted_passport, engine_with_policy, tmp_path
|
|
105
|
+
):
|
|
106
|
+
from crewai.tools import tool
|
|
107
|
+
|
|
108
|
+
policy_file = tmp_path / "policy.cedar"
|
|
109
|
+
policy_file.write_text("permit(principal, action, resource);")
|
|
110
|
+
permitted_passport.policy_ref = str(policy_file)
|
|
111
|
+
|
|
112
|
+
@tool
|
|
113
|
+
def web_search(query: str, data_region: str = "us-east-1") -> str:
|
|
114
|
+
"""Search the web."""
|
|
115
|
+
return query
|
|
116
|
+
|
|
117
|
+
agent = IrisCrewAgent(
|
|
118
|
+
permitted_passport,
|
|
119
|
+
role="Researcher",
|
|
120
|
+
goal="Research",
|
|
121
|
+
backstory="Analyst",
|
|
122
|
+
tools=[web_search],
|
|
123
|
+
)
|
|
124
|
+
agent._iris_governor._engine = engine_with_policy
|
|
125
|
+
governed_tool = agent.tools[0]
|
|
126
|
+
|
|
127
|
+
result = governed_tool.run(query="AI governance", data_region="us-east-1")
|
|
128
|
+
assert result == "AI governance"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TestIrisCrew:
|
|
132
|
+
def test_crew_requires_all_passports(self, permitted_passport, writer_passport):
|
|
133
|
+
from crewai import Crew
|
|
134
|
+
|
|
135
|
+
researcher = IrisCrewAgent(
|
|
136
|
+
permitted_passport,
|
|
137
|
+
role="Researcher",
|
|
138
|
+
goal="Research",
|
|
139
|
+
backstory="Analyst",
|
|
140
|
+
)
|
|
141
|
+
writer = IrisCrewAgent(
|
|
142
|
+
writer_passport,
|
|
143
|
+
role="Writer",
|
|
144
|
+
goal="Write",
|
|
145
|
+
backstory="Writer",
|
|
146
|
+
)
|
|
147
|
+
base_crew = Crew(agents=[researcher, writer], tasks=[], verbose=False)
|
|
148
|
+
|
|
149
|
+
with pytest.raises(ValueError, match="Missing passports"):
|
|
150
|
+
IrisCrew.from_crew(base_crew, passports={"Researcher": permitted_passport})
|
|
151
|
+
|
|
152
|
+
def test_missing_passport_raises_clear_error(self, permitted_passport, writer_passport):
|
|
153
|
+
from crewai import Crew
|
|
154
|
+
|
|
155
|
+
researcher = IrisCrewAgent(
|
|
156
|
+
permitted_passport,
|
|
157
|
+
role="Researcher",
|
|
158
|
+
goal="Research",
|
|
159
|
+
backstory="Analyst",
|
|
160
|
+
)
|
|
161
|
+
writer = IrisCrewAgent(
|
|
162
|
+
writer_passport,
|
|
163
|
+
role="Writer",
|
|
164
|
+
goal="Write",
|
|
165
|
+
backstory="Writer",
|
|
166
|
+
)
|
|
167
|
+
base_crew = Crew(agents=[researcher, writer], tasks=[], verbose=False)
|
|
168
|
+
|
|
169
|
+
with pytest.raises(ValueError) as exc_info:
|
|
170
|
+
IrisCrew.from_crew(base_crew, passports={"Researcher": permitted_passport})
|
|
171
|
+
|
|
172
|
+
message = str(exc_info.value)
|
|
173
|
+
assert "Missing passports for: Writer" in message
|
|
174
|
+
assert "AgentPassport keyed by role name" in message
|
|
175
|
+
|
|
176
|
+
def test_crew_generates_compliance_report(self, permitted_passport, writer_passport):
|
|
177
|
+
researcher = IrisCrewAgent(
|
|
178
|
+
permitted_passport,
|
|
179
|
+
role="Researcher",
|
|
180
|
+
goal="Research",
|
|
181
|
+
backstory="Analyst",
|
|
182
|
+
)
|
|
183
|
+
writer = IrisCrewAgent(
|
|
184
|
+
writer_passport,
|
|
185
|
+
role="Writer",
|
|
186
|
+
goal="Write",
|
|
187
|
+
backstory="Writer",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
researcher._iris_governor.records.append(
|
|
191
|
+
EvaluationRecord(
|
|
192
|
+
agent_name="researcher-agent",
|
|
193
|
+
action="call",
|
|
194
|
+
resource="web_search",
|
|
195
|
+
decision="PERMIT",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
writer._iris_governor.records.append(
|
|
199
|
+
EvaluationRecord(
|
|
200
|
+
agent_name="writer-agent",
|
|
201
|
+
action="call",
|
|
202
|
+
resource="secret-tool",
|
|
203
|
+
decision="DENY",
|
|
204
|
+
violations=[
|
|
205
|
+
{"rule_id": "IRIS-TOOL-001", "severity": "critical", "message": "blocked"}
|
|
206
|
+
],
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
base_crew = MagicMock()
|
|
211
|
+
base_crew.agents = [researcher, writer]
|
|
212
|
+
base_crew.kickoff.return_value = "done"
|
|
213
|
+
|
|
214
|
+
crew = IrisCrew.from_crew(
|
|
215
|
+
base_crew,
|
|
216
|
+
passports={"Researcher": permitted_passport, "Writer": writer_passport},
|
|
217
|
+
)
|
|
218
|
+
kickoff_payload = crew.kickoff(inputs={"topic": "AI governance"})
|
|
219
|
+
report = crew.compliance_report()
|
|
220
|
+
|
|
221
|
+
assert kickoff_payload["result"] == "done"
|
|
222
|
+
assert kickoff_payload["compliance"] == report
|
|
223
|
+
assert report["total_evaluations"] == 2
|
|
224
|
+
assert report["total_crew_violations"] == 1
|
|
225
|
+
assert report["crew_pass_rate"] == 0.5
|
|
226
|
+
assert report["most_problematic_agent"] == "writer-agent"
|
|
227
|
+
assert report["agents"]["Researcher"]["pass_rate"] == 1.0
|
|
228
|
+
assert report["agents"]["Writer"]["total_violations"] == 1
|
|
229
|
+
assert report["agents"]["Writer"]["most_violated_rule"] == "IRIS-TOOL-001"
|
|
230
|
+
assert report["violations_by_severity"]["critical"] == 1
|
|
231
|
+
|
|
232
|
+
def test_per_agent_evidence_vault_partition(
|
|
233
|
+
self, permitted_passport, writer_passport, tmp_path, monkeypatch
|
|
234
|
+
):
|
|
235
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
236
|
+
|
|
237
|
+
researcher = IrisCrewAgent(
|
|
238
|
+
permitted_passport,
|
|
239
|
+
role="Researcher",
|
|
240
|
+
goal="Research",
|
|
241
|
+
backstory="Analyst",
|
|
242
|
+
)
|
|
243
|
+
writer = IrisCrewAgent(
|
|
244
|
+
writer_passport,
|
|
245
|
+
role="Writer",
|
|
246
|
+
goal="Write",
|
|
247
|
+
backstory="Writer",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
researcher._iris_governor.evaluate_tool(
|
|
251
|
+
action="call",
|
|
252
|
+
resource="web_search",
|
|
253
|
+
inputs={"query": "AI governance", "data_region": "us-east-1"},
|
|
254
|
+
)
|
|
255
|
+
writer._iris_governor.evaluate_tool(
|
|
256
|
+
action="call",
|
|
257
|
+
resource="write_summary",
|
|
258
|
+
inputs={"content": "summary"},
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
researcher_vault_path = (
|
|
262
|
+
Path(tmp_path) / ".iris" / "evidence" / vault_partition_id(permitted_passport)
|
|
263
|
+
)
|
|
264
|
+
writer_vault_path = (
|
|
265
|
+
Path(tmp_path) / ".iris" / "evidence" / vault_partition_id(writer_passport)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
assert researcher_vault_path.exists()
|
|
269
|
+
assert writer_vault_path.exists()
|
|
270
|
+
assert researcher_vault_path != writer_vault_path
|
|
271
|
+
|
|
272
|
+
researcher_vault = EvidenceVault(agent_id=vault_partition_id(permitted_passport))
|
|
273
|
+
writer_vault = EvidenceVault(agent_id=vault_partition_id(writer_passport))
|
|
274
|
+
|
|
275
|
+
assert len(researcher_vault.get_events()) >= 1
|
|
276
|
+
assert len(writer_vault.get_events()) >= 1
|
|
277
|
+
assert all(
|
|
278
|
+
e["agent_id"] == vault_partition_id(permitted_passport)
|
|
279
|
+
for e in researcher_vault.get_events()
|
|
280
|
+
)
|
|
281
|
+
assert all(
|
|
282
|
+
e["agent_id"] == vault_partition_id(writer_passport)
|
|
283
|
+
for e in writer_vault.get_events()
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class TestIrisCrewTool:
|
|
288
|
+
def test_iris_crew_tool_decorator(self, permitted_passport, engine_with_policy, tmp_path):
|
|
289
|
+
from crewai.tools import tool
|
|
290
|
+
|
|
291
|
+
policy_file = tmp_path / "policy.cedar"
|
|
292
|
+
policy_file.write_text("permit(principal, action, resource);")
|
|
293
|
+
permitted_passport.policy_ref = str(policy_file)
|
|
294
|
+
|
|
295
|
+
@tool
|
|
296
|
+
def web_search(query: str, data_region: str = "us-east-1") -> str:
|
|
297
|
+
"""Search the web."""
|
|
298
|
+
return query
|
|
299
|
+
|
|
300
|
+
guarded = iris_crew_tool(web_search, permitted_passport, environment="dev")
|
|
301
|
+
assert guarded.name == "web_search"
|
|
302
|
+
assert "Search the web" in guarded.description
|
|
303
|
+
assert guarded.args_schema is not None
|
|
304
|
+
|
|
305
|
+
result = guarded.run(query="AI governance", data_region="us-east-1")
|
|
306
|
+
assert result == "AI governance"
|
|
307
|
+
|
|
308
|
+
def test_iris_crew_tool_blocks_undeclared_in_prod(
|
|
309
|
+
self, permitted_passport, engine_with_policy, tmp_path
|
|
310
|
+
):
|
|
311
|
+
from crewai.tools import tool
|
|
312
|
+
|
|
313
|
+
policy_file = tmp_path / "policy.cedar"
|
|
314
|
+
policy_file.write_text("permit(principal, action, resource);")
|
|
315
|
+
permitted_passport.policy_ref = str(policy_file)
|
|
316
|
+
|
|
317
|
+
@tool
|
|
318
|
+
def secret_tool(action: str) -> str:
|
|
319
|
+
"""Perform a sensitive action."""
|
|
320
|
+
return action
|
|
321
|
+
|
|
322
|
+
guarded = iris_crew_tool(secret_tool, permitted_passport, environment="production")
|
|
323
|
+
with pytest.raises(IrisViolationError):
|
|
324
|
+
guarded.run(action="transfer")
|