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.
@@ -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()
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ examples
2
+ iris_crewai
3
+ tests
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")