iris-security-openai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ """
2
+ Governed OpenAI — change one line, keep everything else identical.
3
+
4
+ Requires: pip install iris-openai
5
+ Set IRIS_ENV=dev (default) or production for fail-closed enforcement.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from iris import AgentPassport, ComplianceTag, ToolPermission
11
+ from iris_openai import IrisOpenAI
12
+
13
+ passport = AgentPassport(
14
+ name="analysis-agent",
15
+ owner="team@company.com",
16
+ compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
17
+ tool_permissions=[
18
+ ToolPermission(tool_id="search", description="Web search", allowed_actions=["call"]),
19
+ ],
20
+ )
21
+
22
+ # One line change from: client = openai.OpenAI()
23
+ client = IrisOpenAI(passport=passport)
24
+
25
+ response = client.chat.completions.create(
26
+ model="gpt-4o",
27
+ messages=[{"role": "user", "content": "Analyze this data."}],
28
+ )
29
+ print(response.choices[0].message.content)
30
+
31
+ search_tool = {
32
+ "type": "function",
33
+ "function": {
34
+ "name": "search",
35
+ "description": "Search the web",
36
+ "parameters": {"type": "object", "properties": {}},
37
+ },
38
+ }
39
+ payments_tool = {
40
+ "type": "function",
41
+ "function": {
42
+ "name": "payments",
43
+ "description": "Process payments",
44
+ "parameters": {"type": "object", "properties": {}},
45
+ },
46
+ }
47
+ email_tool = {
48
+ "type": "function",
49
+ "function": {
50
+ "name": "email",
51
+ "description": "Send email",
52
+ "parameters": {"type": "object", "properties": {}},
53
+ },
54
+ }
55
+
56
+ # IRIS filters to permitted tools; payments/email removed if not on passport
57
+ response = client.chat.completions.create(
58
+ model="gpt-4o",
59
+ messages=[{"role": "user", "content": "Look up the account."}],
60
+ tools=[search_tool, payments_tool, email_tool],
61
+ )
@@ -0,0 +1,46 @@
1
+ """
2
+ IRIS OpenAI integration — one-line drop-in for openai.OpenAI().
3
+
4
+ Quickstart:
5
+ from iris_openai import IrisOpenAI
6
+ from iris import AgentPassport, ComplianceTag
7
+
8
+ passport = AgentPassport(
9
+ name="analysis-agent",
10
+ owner="team@company.com",
11
+ compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
12
+ )
13
+ client = IrisOpenAI(passport=passport)
14
+ response = client.chat.completions.create(model="gpt-4o", messages=[...])
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from iris import IrisViolationError
20
+ from iris_core.models.passport import (
21
+ AgentPassport,
22
+ ComplianceTag,
23
+ DataClassification,
24
+ Environment,
25
+ ToolPermission,
26
+ )
27
+ from iris_core.models.policy import Violation
28
+
29
+ from iris_openai.client import IrisAzureOpenAI, IrisOpenAI, IrisOpenAIAsync
30
+ from iris_openai.tool_guard import guard_openai_tools
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "IrisOpenAI",
36
+ "IrisOpenAIAsync",
37
+ "IrisAzureOpenAI",
38
+ "IrisViolationError",
39
+ "AgentPassport",
40
+ "ComplianceTag",
41
+ "DataClassification",
42
+ "Environment",
43
+ "ToolPermission",
44
+ "Violation",
45
+ "guard_openai_tools",
46
+ ]
@@ -0,0 +1,368 @@
1
+ """Shared IRIS evaluation helpers for OpenAI SDK integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ import threading
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+ from urllib.parse import urlparse
12
+
13
+ import yaml
14
+
15
+ from iris import IrisViolationError
16
+ from iris_core.engine.cedar import CedarEngine, EvaluationContext
17
+ from iris_core.rbac.context import UserContext
18
+ from iris_core.evidence.vault import EvidenceVault
19
+ from iris_core.models.passport import AgentPassport, Environment
20
+ from iris_core.models.policy import PolicyResult, Severity, Violation
21
+ from iris_core.models.region import RegionPolicy, TransferRule
22
+
23
+ logger = logging.getLogger("iris.openai")
24
+
25
+ _VAULT_LOCK = threading.Lock()
26
+
27
+ _AZURE_LOCATION_TOKENS: Dict[str, str] = {
28
+ "westeurope": "eu-west-1",
29
+ "northeurope": "eu-north-1",
30
+ "swedencentral": "eu-north-1",
31
+ "francecentral": "eu-central-1",
32
+ "germanywestcentral": "eu-central-1",
33
+ "eastus": "us-east-1",
34
+ "eastus2": "us-east-2",
35
+ "westus": "us-west-1",
36
+ "westus2": "us-west-2",
37
+ "centralus": "us-central-1",
38
+ "southcentralus": "us-central-1",
39
+ }
40
+
41
+ _EU_REGION_PREFIXES = ("eu-",)
42
+
43
+
44
+ def current_environment() -> Environment:
45
+ return Environment(os.environ.get("IRIS_ENV", "dev"))
46
+
47
+
48
+ def has_policy_loaded(engine: CedarEngine, passport: AgentPassport) -> bool:
49
+ return bool(engine._policy_cache.get(passport.agent_id))
50
+
51
+
52
+ def load_passport_policy(engine: CedarEngine, passport: AgentPassport) -> None:
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 load_region_policy() -> Optional[RegionPolicy]:
63
+ """Load optional RegionPolicy from GitOps or ~/.iris."""
64
+ candidates = [
65
+ Path.cwd() / "governance" / "region-policy.yaml",
66
+ Path.home() / ".iris" / "region-policy.yaml",
67
+ ]
68
+ env_path = os.environ.get("IRIS_REGION_POLICY")
69
+ if env_path:
70
+ candidates.insert(0, Path(env_path))
71
+
72
+ for path in candidates:
73
+ if not path.exists():
74
+ continue
75
+ data = yaml.safe_load(path.read_text()) or {}
76
+ spec = data.get("spec", data)
77
+ transfers = []
78
+ for rule in spec.get("restricted_transfers", []):
79
+ transfers.append(
80
+ TransferRule(
81
+ from_region=rule["from_region"],
82
+ to_region=rule["to_region"],
83
+ compliance_ref=rule.get("compliance_ref", "iris:cross-region"),
84
+ action=rule.get("action", "block"),
85
+ note=rule.get("note"),
86
+ )
87
+ )
88
+ return RegionPolicy(
89
+ name=spec.get("name", data.get("metadata", {}).get("name", "default")),
90
+ restricted_transfers=transfers,
91
+ )
92
+ return None
93
+
94
+
95
+ def parse_azure_endpoint_region(azure_endpoint: Optional[str]) -> Optional[str]:
96
+ """
97
+ Extract a canonical region from an Azure OpenAI endpoint URL.
98
+
99
+ https://my-resource.openai.azure.com → no region in hostname
100
+ https://my-resource.cognitiveservices.azure.com/openai → location token in host
101
+ """
102
+ if not azure_endpoint:
103
+ return None
104
+ host = (urlparse(azure_endpoint).hostname or "").lower()
105
+ if not host:
106
+ return None
107
+ if host.endswith(".openai.azure.com"):
108
+ return None
109
+ for token, region in _AZURE_LOCATION_TOKENS.items():
110
+ if token in host:
111
+ return region
112
+ for part in host.split("."):
113
+ if part.startswith(("eu-", "us-", "ap-", "cn-")):
114
+ return part
115
+ return None
116
+
117
+
118
+ def is_eu_region(region: Optional[str]) -> bool:
119
+ return bool(region and region.startswith(_EU_REGION_PREFIXES))
120
+
121
+
122
+ def check_region_policy_transfer(
123
+ region_policy: RegionPolicy,
124
+ data_region: str,
125
+ destination_region: str,
126
+ ) -> Optional[Violation]:
127
+ for rule in region_policy.restricted_transfers:
128
+ if rule.from_region == data_region and rule.to_region == destination_region:
129
+ if rule.action == "block":
130
+ return Violation(
131
+ rule_id="IRIS-XR-001",
132
+ severity=Severity.CRITICAL,
133
+ message=(
134
+ f"Cross-region data transfer blocked by region policy "
135
+ f"'{region_policy.name}': {data_region} → {destination_region}. "
136
+ f"{rule.note or ''}".strip()
137
+ ),
138
+ compliance_refs=[rule.compliance_ref],
139
+ remediation=(
140
+ "Use an Azure endpoint in an approved region or update "
141
+ "governance/region-policy.yaml with a documented exception."
142
+ ),
143
+ )
144
+ return None
145
+
146
+
147
+ def azure_cross_region_violation(
148
+ passport: AgentPassport,
149
+ azure_endpoint: Optional[str],
150
+ region_policy: Optional[RegionPolicy] = None,
151
+ ) -> Optional[Violation]:
152
+ """Flag EU/US mismatches when Azure endpoint region is known."""
153
+ destination = parse_azure_endpoint_region(azure_endpoint)
154
+ if not destination:
155
+ return None
156
+
157
+ data_region = passport.allowed_regions[0] if passport.allowed_regions else None
158
+ if not data_region:
159
+ if is_eu_region(destination):
160
+ return Violation(
161
+ rule_id="IRIS-XR-002",
162
+ severity=Severity.HIGH,
163
+ message=(
164
+ f"Azure OpenAI endpoint resolves to EU region '{destination}' "
165
+ f"but agent '{passport.name}' has no allowed_regions declared. "
166
+ f"EU-hosted inference may process EU personal data under GDPR."
167
+ ),
168
+ compliance_refs=["gdpr:chapter-5-transfer", "iris:cross-region"],
169
+ remediation=(
170
+ "Set allowed_regions on the agent passport to document approved "
171
+ "data residency, or use a non-EU Azure endpoint."
172
+ ),
173
+ )
174
+ return None
175
+
176
+ policy = region_policy or load_region_policy()
177
+ if policy:
178
+ violation = check_region_policy_transfer(policy, data_region, destination)
179
+ if violation:
180
+ return violation
181
+
182
+ if is_eu_region(data_region) != is_eu_region(destination):
183
+ return Violation(
184
+ rule_id="IRIS-XR-001",
185
+ severity=Severity.CRITICAL,
186
+ message=(
187
+ f"Cross-region Azure OpenAI call blocked: passport data region "
188
+ f"'{data_region}' does not match endpoint region '{destination}'."
189
+ ),
190
+ compliance_refs=["gdpr:chapter-5-transfer", "iris:cross-region"],
191
+ remediation=(
192
+ "Point azure_endpoint at a region that matches the agent's "
193
+ "allowed_regions, or update the passport after security review."
194
+ ),
195
+ )
196
+ return None
197
+
198
+
199
+ def apply_no_policy_gate(
200
+ engine: CedarEngine,
201
+ passport: AgentPassport,
202
+ env: Environment,
203
+ result: PolicyResult,
204
+ ) -> PolicyResult:
205
+ if has_policy_loaded(engine, passport):
206
+ return result
207
+ if env in (Environment.DEV, Environment.TEST):
208
+ if result.decision == "DENY":
209
+ return PolicyResult(
210
+ decision="PERMIT_WITH_WARNINGS",
211
+ violations=result.violations,
212
+ agent_id=result.agent_id,
213
+ action=result.action,
214
+ resource=result.resource,
215
+ environment=result.environment,
216
+ )
217
+ return result
218
+
219
+
220
+ def merge_results(base: PolicyResult, extra_violations: List[Violation]) -> PolicyResult:
221
+ if not extra_violations:
222
+ return base
223
+ violations = list(base.violations) + list(extra_violations)
224
+ critical = [v for v in violations if v.severity == Severity.CRITICAL]
225
+ high = [v for v in violations if v.severity in (Severity.HIGH, Severity.CRITICAL)]
226
+ if critical:
227
+ decision = "DENY"
228
+ elif high and base.decision == "PERMIT":
229
+ decision = "DENY"
230
+ elif violations and base.decision == "PERMIT":
231
+ decision = "PERMIT_WITH_WARNINGS"
232
+ else:
233
+ decision = base.decision
234
+ return PolicyResult(
235
+ decision=decision,
236
+ violations=violations,
237
+ agent_id=base.agent_id,
238
+ action=base.action,
239
+ resource=base.resource,
240
+ environment=base.environment,
241
+ )
242
+
243
+
244
+ def evaluate_openai_call(
245
+ engine: CedarEngine,
246
+ vault: EvidenceVault,
247
+ passport: AgentPassport,
248
+ env: Environment,
249
+ *,
250
+ resource: str = "openai-api",
251
+ operation: str = "chat.completions",
252
+ model: Optional[str] = None,
253
+ tool_names: Optional[List[str]] = None,
254
+ data_classification: Optional[str] = None,
255
+ azure_endpoint: Optional[str] = None,
256
+ extra_violations: Optional[List[Violation]] = None,
257
+ dlp_prompt_findings: Optional[list] = None,
258
+ user_email: Optional[str] = None,
259
+ user_role: Optional[str] = None,
260
+ ) -> PolicyResult:
261
+ data_region = passport.allowed_regions[0] if passport.allowed_regions else None
262
+ destination_region = parse_azure_endpoint_region(azure_endpoint)
263
+ user_ctx = UserContext.from_params(user_email, user_role)
264
+ user_fields = user_ctx.evaluation_fields()
265
+
266
+ ctx = EvaluationContext(
267
+ agent_id=passport.agent_id,
268
+ action="call",
269
+ resource=resource,
270
+ resource_type="api",
271
+ environment=env,
272
+ data_region=data_region,
273
+ destination_region=destination_region,
274
+ data_classification=data_classification or passport.data_classification.value,
275
+ dlp_prompt_findings=dlp_prompt_findings,
276
+ additional={
277
+ "operation": operation,
278
+ "model": model,
279
+ "tool_names": tool_names or [],
280
+ "azure_endpoint": azure_endpoint,
281
+ },
282
+ **user_fields,
283
+ )
284
+
285
+ result = engine.evaluate(passport, ctx)
286
+ result = apply_no_policy_gate(engine, passport, env, result)
287
+
288
+ violations: List[Violation] = list(extra_violations or [])
289
+ azure_v = azure_cross_region_violation(passport, azure_endpoint)
290
+ if azure_v:
291
+ violations.append(azure_v)
292
+
293
+ for name in tool_names or []:
294
+ tool_ctx = EvaluationContext(
295
+ agent_id=passport.agent_id,
296
+ action="call",
297
+ resource=name,
298
+ resource_type="tool",
299
+ environment=env,
300
+ data_classification=data_classification or passport.data_classification.value,
301
+ **user_fields,
302
+ )
303
+ tool_result = engine.evaluate(passport, tool_ctx)
304
+ tool_result = apply_no_policy_gate(engine, passport, env, tool_result)
305
+ violations.extend(tool_result.violations)
306
+ if tool_result.decision == "DENY":
307
+ result = PolicyResult(
308
+ decision="DENY",
309
+ violations=list(result.violations) + violations,
310
+ agent_id=result.agent_id,
311
+ action=result.action,
312
+ resource=result.resource,
313
+ environment=result.environment,
314
+ )
315
+
316
+ if env == Environment.PRODUCTION and (tool_names or []) and not passport.tool_permissions:
317
+ for name in tool_names:
318
+ violations.append(
319
+ Violation(
320
+ rule_id="IRIS-TOOL-001",
321
+ severity=Severity.CRITICAL,
322
+ message=(
323
+ f"Agent '{passport.name}' invoked tool '{name}' in production "
324
+ f"with no tool_permissions declared on the passport."
325
+ ),
326
+ compliance_refs=["iris:tool-permission", "colorado-ai-act:transparency"],
327
+ remediation=(
328
+ "Declare permitted tools in passport.yaml tool_permissions "
329
+ "before enabling tools in production."
330
+ ),
331
+ )
332
+ )
333
+ result = PolicyResult(
334
+ decision="DENY",
335
+ violations=list(result.violations) + violations,
336
+ agent_id=result.agent_id,
337
+ action=result.action,
338
+ resource=result.resource,
339
+ environment=result.environment,
340
+ )
341
+
342
+ result = merge_results(result, violations)
343
+
344
+ with _VAULT_LOCK:
345
+ vault.record(ctx, result)
346
+ return result
347
+
348
+
349
+ def enforce_result(result: PolicyResult, env: Environment) -> None:
350
+ if result.decision == "DENY":
351
+ if env in (Environment.DEV, Environment.TEST):
352
+ for violation in result.violations:
353
+ msg = (
354
+ f"[IRIS WARNING] {violation.message} "
355
+ f"Remediation: {violation.remediation}"
356
+ )
357
+ logger.warning(msg)
358
+ print(msg, file=sys.stderr)
359
+ return
360
+ raise IrisViolationError(result)
361
+ if result.decision == "PERMIT_WITH_WARNINGS":
362
+ for violation in result.violations:
363
+ msg = (
364
+ f"[IRIS WARNING] {violation.message} "
365
+ f"Remediation: {violation.remediation}"
366
+ )
367
+ logger.warning(msg)
368
+ print(msg, file=sys.stderr)
iris_openai/client.py ADDED
@@ -0,0 +1,343 @@
1
+ """Drop-in OpenAI client wrapper with IRIS governance on every API call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, List, Optional
6
+
7
+ from iris_core.dlp import DLPScanner
8
+ from iris_core.dlp.enforcement import (
9
+ enforce_prompt_dlp,
10
+ extract_openai_response_text,
11
+ handle_response_dlp,
12
+ )
13
+ from iris_core.engine.cedar import CedarEngine
14
+ from iris_core.evidence.vault import EvidenceVault
15
+ from iris_core.models.passport import AgentPassport
16
+
17
+ from iris_openai._governance import (
18
+ current_environment,
19
+ enforce_result,
20
+ evaluate_openai_call,
21
+ load_passport_policy,
22
+ )
23
+ from iris_openai.tool_guard import guard_openai_tools
24
+
25
+
26
+ def _lazy_openai():
27
+ import openai
28
+
29
+ return openai
30
+
31
+
32
+ def _extract_tool_names_from_messages(messages: List[Any]) -> List[str]:
33
+ names: List[str] = []
34
+ for msg in messages or []:
35
+ if not isinstance(msg, dict):
36
+ continue
37
+ for call in msg.get("tool_calls") or []:
38
+ if not isinstance(call, dict):
39
+ continue
40
+ fn = call.get("function") or {}
41
+ name = fn.get("name")
42
+ if name:
43
+ names.append(name)
44
+ return names
45
+
46
+
47
+ def _extract_prompt_text(kwargs: dict) -> str:
48
+ parts: List[str] = []
49
+ for msg in kwargs.get("messages") or []:
50
+ if not isinstance(msg, dict):
51
+ continue
52
+ content = msg.get("content")
53
+ if isinstance(content, str):
54
+ parts.append(content)
55
+ elif isinstance(content, list):
56
+ for block in content:
57
+ if isinstance(block, dict):
58
+ text = block.get("text") or block.get("content")
59
+ if text:
60
+ parts.append(str(text))
61
+ return "\n".join(parts)
62
+
63
+
64
+ def _extract_tool_names_from_kwargs(kwargs: dict) -> List[str]:
65
+ names: List[str] = []
66
+ tools = kwargs.get("tools") or kwargs.get("functions") or []
67
+ for tool in tools:
68
+ if isinstance(tool, dict):
69
+ if tool.get("type") == "function":
70
+ fn = tool.get("function") or {}
71
+ if fn.get("name"):
72
+ names.append(fn["name"])
73
+ elif tool.get("name"):
74
+ names.append(tool["name"])
75
+ elif hasattr(tool, "name"):
76
+ names.append(getattr(tool, "name"))
77
+ names.extend(_extract_tool_names_from_messages(kwargs.get("messages") or []))
78
+ return list(dict.fromkeys(names))
79
+
80
+
81
+ class _IrisOpenAIClientBase:
82
+ _passport: AgentPassport
83
+ _engine: CedarEngine
84
+ _vault: EvidenceVault
85
+ _dlp: DLPScanner
86
+ _azure_endpoint: Optional[str] = None
87
+ _user_email: Optional[str] = None
88
+ _user_role: Optional[str] = None
89
+
90
+
91
+ class _GovernedCompletionsBase:
92
+ def __init__(self, parent: _IrisOpenAIClientBase, completions_resource: Any):
93
+ self._parent = parent
94
+ self._completions = completions_resource
95
+
96
+ @property
97
+ def _passport(self) -> AgentPassport:
98
+ return self._parent._passport
99
+
100
+ @property
101
+ def _engine(self) -> CedarEngine:
102
+ return self._parent._engine
103
+
104
+ @property
105
+ def _vault(self) -> EvidenceVault:
106
+ return self._parent._vault
107
+
108
+ def _govern_kwargs(self, kwargs: dict) -> None:
109
+ env = current_environment()
110
+ prompt = _extract_prompt_text(kwargs)
111
+ dlp_result = enforce_prompt_dlp(
112
+ self._parent._dlp,
113
+ self._vault,
114
+ self._passport,
115
+ env,
116
+ prompt,
117
+ resource="openai-api",
118
+ )
119
+ if kwargs.get("tools"):
120
+ kwargs["tools"] = guard_openai_tools(kwargs["tools"], self._passport, env)
121
+ tool_names = _extract_tool_names_from_kwargs(kwargs)
122
+ result = evaluate_openai_call(
123
+ self._engine,
124
+ self._vault,
125
+ self._passport,
126
+ env,
127
+ operation="chat.completions",
128
+ model=kwargs.get("model"),
129
+ tool_names=tool_names,
130
+ azure_endpoint=getattr(self._parent, "_azure_endpoint", None),
131
+ dlp_prompt_findings=dlp_result.findings,
132
+ user_email=getattr(self._parent, "_user_email", None),
133
+ user_role=getattr(self._parent, "_user_role", None),
134
+ )
135
+ enforce_result(result, env)
136
+
137
+ def _scan_response(self, response: Any) -> Any:
138
+ env = current_environment()
139
+ response_text = extract_openai_response_text(response)
140
+ blocked, _ = handle_response_dlp(
141
+ self._parent._dlp,
142
+ self._vault,
143
+ self._passport,
144
+ env,
145
+ response_text,
146
+ response,
147
+ resource="openai-api",
148
+ )
149
+ return blocked
150
+
151
+ def create(self, **kwargs: Any) -> Any:
152
+ self._govern_kwargs(kwargs)
153
+ response = self._completions.create(**kwargs)
154
+ return self._scan_response(response)
155
+
156
+ def stream(self, **kwargs: Any) -> Any:
157
+ self._govern_kwargs(kwargs)
158
+ return self._completions.stream(**kwargs)
159
+
160
+
161
+ class _GovernedCompletionsAsyncBase(_GovernedCompletionsBase):
162
+ async def create(self, **kwargs: Any) -> Any:
163
+ self._govern_kwargs(kwargs)
164
+ response = await self._completions.create(**kwargs)
165
+ return self._scan_response(response)
166
+
167
+ async def stream(self, **kwargs: Any) -> Any:
168
+ self._govern_kwargs(kwargs)
169
+ return await self._completions.stream(**kwargs)
170
+
171
+
172
+ class IrisChatCompletionsResource(_GovernedCompletionsBase):
173
+ pass
174
+
175
+
176
+ class IrisChatCompletionsResourceAsync(_GovernedCompletionsAsyncBase):
177
+ pass
178
+
179
+
180
+ class IrisChatResource:
181
+ def __init__(self, parent: _IrisOpenAIClientBase, chat_resource: Any):
182
+ self._parent = parent
183
+ self._chat = chat_resource
184
+ self._completions_resource = IrisChatCompletionsResource(
185
+ parent, self._chat.completions
186
+ )
187
+
188
+ @property
189
+ def completions(self) -> IrisChatCompletionsResource:
190
+ return self._completions_resource
191
+
192
+ def __getattr__(self, name: str) -> Any:
193
+ return getattr(self._chat, name)
194
+
195
+
196
+ class IrisChatResourceAsync:
197
+ def __init__(self, parent: _IrisOpenAIClientBase, chat_resource: Any):
198
+ self._parent = parent
199
+ self._chat = chat_resource
200
+ self._completions_resource = IrisChatCompletionsResourceAsync(
201
+ parent, self._chat.completions
202
+ )
203
+
204
+ @property
205
+ def completions(self) -> IrisChatCompletionsResourceAsync:
206
+ return self._completions_resource
207
+
208
+ def __getattr__(self, name: str) -> Any:
209
+ return getattr(self._chat, name)
210
+
211
+
212
+ class _GovernedEmbeddingsBase:
213
+ def __init__(self, parent: _IrisOpenAIClientBase, embeddings_resource: Any):
214
+ self._parent = parent
215
+ self._embeddings = embeddings_resource
216
+
217
+ def _govern_kwargs(self, kwargs: dict) -> None:
218
+ env = current_environment()
219
+ result = evaluate_openai_call(
220
+ self._parent._engine,
221
+ self._parent._vault,
222
+ self._parent._passport,
223
+ env,
224
+ operation="embeddings",
225
+ model=kwargs.get("model"),
226
+ data_classification=self._parent._passport.data_classification.value,
227
+ azure_endpoint=getattr(self._parent, "_azure_endpoint", None),
228
+ user_email=getattr(self._parent, "_user_email", None),
229
+ user_role=getattr(self._parent, "_user_role", None),
230
+ )
231
+ enforce_result(result, env)
232
+
233
+ def create(self, **kwargs: Any) -> Any:
234
+ self._govern_kwargs(kwargs)
235
+ return self._embeddings.create(**kwargs)
236
+
237
+
238
+ class _GovernedEmbeddingsAsyncBase(_GovernedEmbeddingsBase):
239
+ async def create(self, **kwargs: Any) -> Any:
240
+ self._govern_kwargs(kwargs)
241
+ return await self._embeddings.create(**kwargs)
242
+
243
+
244
+ class IrisEmbeddingsResource(_GovernedEmbeddingsBase):
245
+ pass
246
+
247
+
248
+ class IrisEmbeddingsResourceAsync(_GovernedEmbeddingsAsyncBase):
249
+ pass
250
+
251
+
252
+ class IrisOpenAI(_IrisOpenAIClientBase):
253
+ """
254
+ Drop-in replacement for openai.OpenAI() with IRIS governance.
255
+
256
+ Pass an AgentPassport and the same kwargs you would give OpenAI().
257
+ All attributes not defined here are proxied to the underlying client.
258
+ """
259
+
260
+ def __init__(
261
+ self,
262
+ passport: AgentPassport,
263
+ user_email: Optional[str] = None,
264
+ user_role: Optional[str] = None,
265
+ **openai_kwargs: Any,
266
+ ):
267
+ from iris_core.dev_trust import print_dev_trust_message
268
+
269
+ print_dev_trust_message()
270
+ openai = _lazy_openai()
271
+ self._passport = passport
272
+ self._user_email = user_email
273
+ self._user_role = user_role
274
+ self._engine = CedarEngine()
275
+ self._vault = EvidenceVault(agent_id=passport.agent_id)
276
+ self._dlp = DLPScanner(passport)
277
+ load_passport_policy(self._engine, passport)
278
+ self._client = openai.OpenAI(**openai_kwargs)
279
+ self._chat_resource = IrisChatResource(self, self._client.chat)
280
+ self._embeddings_resource = IrisEmbeddingsResource(self, self._client.embeddings)
281
+
282
+ @property
283
+ def chat(self) -> IrisChatResource:
284
+ return self._chat_resource
285
+
286
+ @property
287
+ def embeddings(self) -> IrisEmbeddingsResource:
288
+ return self._embeddings_resource
289
+
290
+ def __getattr__(self, name: str) -> Any:
291
+ return getattr(self._client, name)
292
+
293
+
294
+ class IrisOpenAIAsync(_IrisOpenAIClientBase):
295
+ """Async drop-in replacement for openai.AsyncOpenAI()."""
296
+
297
+ def __init__(
298
+ self,
299
+ passport: AgentPassport,
300
+ user_email: Optional[str] = None,
301
+ user_role: Optional[str] = None,
302
+ **openai_kwargs: Any,
303
+ ):
304
+ openai = _lazy_openai()
305
+ self._passport = passport
306
+ self._user_email = user_email
307
+ self._user_role = user_role
308
+ self._engine = CedarEngine()
309
+ self._vault = EvidenceVault(agent_id=passport.agent_id)
310
+ self._dlp = DLPScanner(passport)
311
+ load_passport_policy(self._engine, passport)
312
+ self._client = openai.AsyncOpenAI(**openai_kwargs)
313
+ self._chat_resource = IrisChatResourceAsync(self, self._client.chat)
314
+ self._embeddings_resource = IrisEmbeddingsResourceAsync(
315
+ self, self._client.embeddings
316
+ )
317
+
318
+ @property
319
+ def chat(self) -> IrisChatResourceAsync:
320
+ return self._chat_resource
321
+
322
+ @property
323
+ def embeddings(self) -> IrisEmbeddingsResourceAsync:
324
+ return self._embeddings_resource
325
+
326
+ def __getattr__(self, name: str) -> Any:
327
+ return getattr(self._client, name)
328
+
329
+
330
+ class IrisAzureOpenAI(IrisOpenAI):
331
+ """Drop-in replacement for openai.AzureOpenAI() with Azure region checks."""
332
+
333
+ def __init__(self, passport: AgentPassport, **openai_kwargs: Any):
334
+ self._azure_endpoint = openai_kwargs.get("azure_endpoint")
335
+ super().__init__(passport, **openai_kwargs)
336
+
337
+
338
+ class IrisAzureOpenAIAsync(IrisOpenAIAsync):
339
+ """Async Azure OpenAI client with IRIS governance."""
340
+
341
+ def __init__(self, passport: AgentPassport, **openai_kwargs: Any):
342
+ self._azure_endpoint = openai_kwargs.get("azure_endpoint")
343
+ super().__init__(passport, **openai_kwargs)
@@ -0,0 +1,97 @@
1
+ """Filter OpenAI tools[] to passport-declared permissions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+ from typing import Any, List, Optional
8
+
9
+ from iris_core.models.passport import AgentPassport, Environment
10
+ from iris_core.models.policy import Severity, Violation
11
+
12
+ from iris_openai._governance import current_environment
13
+
14
+ logger = logging.getLogger("iris.openai.tools")
15
+
16
+
17
+ def _tool_name(tool: Any) -> Optional[str]:
18
+ if not isinstance(tool, dict):
19
+ return getattr(tool, "name", None)
20
+ if tool.get("type") == "function":
21
+ fn = tool.get("function") or {}
22
+ return fn.get("name")
23
+ return tool.get("name")
24
+
25
+
26
+ def _log_tool_removal(violation: Violation) -> None:
27
+ msg = (
28
+ f"[IRIS TOOL FILTER] {violation.message} "
29
+ f"Remediation: {violation.remediation}"
30
+ )
31
+ logger.warning(msg)
32
+ # Always emit to stderr so removals are never silent (required in dev; auditable in prod).
33
+ print(msg, file=sys.stderr)
34
+
35
+
36
+ def guard_openai_tools(
37
+ tools: List[Any],
38
+ passport: AgentPassport,
39
+ environment: Optional[Environment] = None,
40
+ ) -> List[Any]:
41
+ """
42
+ Return only tools permitted by passport.tool_permissions.
43
+
44
+ Removed tools are logged as IRIS-TOOL-001 violations. In dev/test, removal
45
+ is never silent — every dropped tool is logged with a reason. In production
46
+ with no tool_permissions declared, all tools are removed.
47
+ """
48
+ if not tools:
49
+ return []
50
+
51
+ env = environment or current_environment()
52
+ allowed = {t.tool_id for t in passport.tool_permissions}
53
+ filtered: List[Any] = []
54
+
55
+ if env == Environment.PRODUCTION and not allowed:
56
+ for tool in tools:
57
+ name = _tool_name(tool) or "unknown"
58
+ violation = Violation(
59
+ rule_id="IRIS-TOOL-001",
60
+ severity=Severity.CRITICAL,
61
+ message=(
62
+ f"Tool '{name}' removed: agent '{passport.name}' has no "
63
+ f"tool_permissions — all tools are blocked in production."
64
+ ),
65
+ compliance_refs=["iris:tool-permission"],
66
+ remediation=(
67
+ "Add tool_permissions to the agent passport before using tools "
68
+ "in production."
69
+ ),
70
+ )
71
+ _log_tool_removal(violation)
72
+ return []
73
+
74
+ for tool in tools:
75
+ name = _tool_name(tool)
76
+ if not name:
77
+ filtered.append(tool)
78
+ continue
79
+ if name in allowed:
80
+ filtered.append(tool)
81
+ continue
82
+ violation = Violation(
83
+ rule_id="IRIS-TOOL-001",
84
+ severity=Severity.HIGH,
85
+ message=(
86
+ f"Tool '{name}' removed: not in agent '{passport.name}' "
87
+ f"tool_permissions. Allowed: {sorted(allowed) or ['none declared']}."
88
+ ),
89
+ compliance_refs=["iris:tool-permission", "colorado-ai-act:transparency"],
90
+ remediation=(
91
+ f"Add '{name}' to tool_permissions in passport.yaml and obtain "
92
+ f"security engineer approval."
93
+ ),
94
+ )
95
+ _log_tool_removal(violation)
96
+
97
+ return filtered
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: iris-security-openai
3
+ Version: 0.1.0
4
+ Summary: IRIS governance for OpenAI — Cedar policy on every API 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
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: iris-security-core>=0.1.0
12
+ Requires-Dist: iris-security-sdk>=0.1.0
13
+ Provides-Extra: openai
14
+ Requires-Dist: openai>=1.30; extra == "openai"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == "dev"
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
18
+ Requires-Dist: ruff>=0.4; extra == "dev"
19
+
20
+ # iris-openai
21
+
22
+ Drop-in IRIS governance for the [OpenAI Python SDK](https://github.com/openai/openai-python).
23
+
24
+ Replace one line:
25
+
26
+ ```python
27
+ # client = openai.OpenAI()
28
+ client = IrisOpenAI(passport=passport)
29
+ ```
30
+
31
+ Every `client.chat.completions.create()`, `stream()`, and `client.embeddings.create()` call is evaluated against Cedar policy, recorded in the Evidence Vault, and enforced per `IRIS_ENV` (warn in dev, block in production).
32
+
33
+ Tool arrays are filtered to `passport.tool_permissions`; removed tools are logged as `IRIS-TOOL-001` (never silently dropped in dev).
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install iris-openai
39
+ ```
40
+
41
+ ## Quickstart
42
+
43
+ See [examples/governed_gpt.py](examples/governed_gpt.py).
44
+
45
+ ## Environment
46
+
47
+ | `IRIS_ENV` | Behavior |
48
+ |-------------|-----------------------------------------------|
49
+ | `dev` | Fail open — warnings to stderr, never block |
50
+ | `production`| Fail closed — `IrisViolationError` on deny |
51
+
52
+ Defaults to `dev` when unset.
@@ -0,0 +1,11 @@
1
+ examples/governed_gpt.py,sha256=9hUgOwmkxFHT7aSR7_6ae3usW233zoH_bNTBAmOxD80,1689
2
+ iris_openai/__init__.py,sha256=Ki23Jtmu9Jb1XQmdqtOsTnghGXGw07IjVWlruOeTQZE,1127
3
+ iris_openai/_governance.py,sha256=sAzunRdikmaxP11xtSY-zZeLQ0zHSdxv_ZrbvBGxJ8g,13140
4
+ iris_openai/client.py,sha256=00JIXZiWs-p8aHNSjSTdm6wFIWb6ASbkmcIMcxUEItg,10992
5
+ iris_openai/tool_guard.py,sha256=a0aZB93TfTdUPou_19_oJFBnr1FgWwj98c6-lpSxGkc,3188
6
+ tests/conftest.py,sha256=sm1aXg5mjLSgCZa0zAEHg1KLIGJRTuXMJZ2h_eshOd0,309
7
+ tests/test_openai_integration.py,sha256=o1XmpuaU5ufjkR7S0lVddvSeCeKfVqc9GwsyOqd9nFI,12574
8
+ iris_security_openai-0.1.0.dist-info/METADATA,sha256=2D5WVdWkypzsIZvc_yHXFy3xBzG_z-pTG7TqS5tUSxQ,1693
9
+ iris_security_openai-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ iris_security_openai-0.1.0.dist-info/top_level.txt,sha256=MuZG3ngC-6xFGe0IKLD39hiDcWlgpK-_jHr7Ed2H42A,27
11
+ iris_security_openai-0.1.0.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_openai
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,322 @@
1
+ """
2
+ Integration tests for iris-openai — mocked OpenAI API, no network calls.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+
11
+ from iris import IrisViolationError
12
+ from iris_core.engine.cedar import CedarEngine
13
+ from iris_core.evidence.vault import EvidenceVault
14
+ from iris_core.models.passport import (
15
+ AgentPassport,
16
+ ComplianceTag,
17
+ DataClassification,
18
+ Environment,
19
+ ToolPermission,
20
+ )
21
+ from iris_openai import IrisAzureOpenAI, IrisOpenAI, guard_openai_tools
22
+ from iris_openai._governance import parse_azure_endpoint_region
23
+
24
+
25
+ @pytest.fixture
26
+ def compliant_passport():
27
+ return AgentPassport(
28
+ name="support-agent",
29
+ owner="team@company.com",
30
+ data_classification=DataClassification.INTERNAL,
31
+ compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
32
+ environments=[Environment.DEV, Environment.PRODUCTION],
33
+ is_high_risk_ai=True,
34
+ evidence_vault_id="vault-abc",
35
+ intent_ref="governance/agents/support-agent/policy-intent.md",
36
+ tool_permissions=[
37
+ ToolPermission(tool_id="search", description="Search", allowed_actions=["call"]),
38
+ ],
39
+ )
40
+
41
+
42
+ @pytest.fixture
43
+ def high_risk_incomplete_passport():
44
+ return AgentPassport(
45
+ name="loan-agent",
46
+ owner="gmoney@gmail.com",
47
+ compliance_tags=[ComplianceTag.COLORADO_AI_ACT],
48
+ environments=[Environment.DEV, Environment.PRODUCTION],
49
+ is_high_risk_ai=True,
50
+ evidence_vault_id=None,
51
+ intent_ref=None,
52
+ )
53
+
54
+
55
+ def _mock_openai_module():
56
+ mock_module = MagicMock()
57
+ mock_choice = MagicMock()
58
+ mock_choice.message.content = "Hello from GPT"
59
+ mock_response = MagicMock()
60
+ mock_response.choices = [mock_choice]
61
+ mock_client = MagicMock()
62
+ mock_client.chat.completions.create.return_value = mock_response
63
+ mock_client.chat.completions.stream.return_value = iter([mock_response])
64
+ mock_client.embeddings.create.return_value = MagicMock(data=[])
65
+ mock_module.OpenAI.return_value = mock_client
66
+ mock_module.AzureOpenAI.return_value = mock_client
67
+ return mock_module, mock_client
68
+
69
+
70
+ def _search_tool():
71
+ return {
72
+ "type": "function",
73
+ "function": {"name": "search", "description": "search", "parameters": {}},
74
+ }
75
+
76
+
77
+ def _payments_tool():
78
+ return {
79
+ "type": "function",
80
+ "function": {"name": "payments", "description": "pay", "parameters": {}},
81
+ }
82
+
83
+
84
+ class TestIrisOpenAIClient:
85
+ def test_client_permits_allowed_call(self, compliant_passport, tmp_path, monkeypatch):
86
+ monkeypatch.setenv("IRIS_ENV", "dev")
87
+ mock_module, mock_client = _mock_openai_module()
88
+ engine = CedarEngine()
89
+ engine.load_policy(compliant_passport.agent_id, "permit(principal, action, resource);")
90
+ vault = EvidenceVault(agent_id=compliant_passport.agent_id, vault_dir=tmp_path)
91
+
92
+ with patch.dict("sys.modules", {"openai": mock_module}):
93
+ client = IrisOpenAI(passport=compliant_passport)
94
+ client._engine = engine
95
+ client._vault = vault
96
+
97
+ result = client.chat.completions.create(
98
+ model="gpt-4o",
99
+ messages=[{"role": "user", "content": "Help this customer."}],
100
+ )
101
+
102
+ assert result.choices[0].message.content == "Hello from GPT"
103
+ mock_client.chat.completions.create.assert_called_once()
104
+ events = vault.get_events()
105
+ assert len(events) == 1
106
+ assert events[0]["decision"] in ("PERMIT", "PERMIT_WITH_WARNINGS")
107
+ assert events[0]["resource"] == "openai-api"
108
+
109
+ def test_client_blocks_in_production(
110
+ self, high_risk_incomplete_passport, tmp_path, monkeypatch
111
+ ):
112
+ monkeypatch.setenv("IRIS_ENV", "production")
113
+ mock_module, _ = _mock_openai_module()
114
+ engine = CedarEngine()
115
+ engine.load_policy(
116
+ high_risk_incomplete_passport.agent_id,
117
+ "permit(principal, action, resource);",
118
+ )
119
+ vault = EvidenceVault(
120
+ agent_id=high_risk_incomplete_passport.agent_id, vault_dir=tmp_path
121
+ )
122
+
123
+ with patch.dict("sys.modules", {"openai": mock_module}):
124
+ client = IrisOpenAI(passport=high_risk_incomplete_passport)
125
+ client._engine = engine
126
+ client._vault = vault
127
+
128
+ with pytest.raises(IrisViolationError) as exc_info:
129
+ client.chat.completions.create(
130
+ model="gpt-4o",
131
+ messages=[{"role": "user", "content": "Help this customer."}],
132
+ )
133
+
134
+ assert exc_info.value.result.decision == "DENY"
135
+ assert any(v.rule_id == "CO-001" for v in exc_info.value.result.violations)
136
+ mock_module.OpenAI.return_value.chat.completions.create.assert_not_called()
137
+
138
+ def test_client_warns_in_dev(
139
+ self, high_risk_incomplete_passport, tmp_path, monkeypatch, capsys
140
+ ):
141
+ monkeypatch.setenv("IRIS_ENV", "dev")
142
+ mock_module, mock_client = _mock_openai_module()
143
+ engine = CedarEngine()
144
+ engine.load_policy(
145
+ high_risk_incomplete_passport.agent_id,
146
+ "permit(principal, action, resource);",
147
+ )
148
+ vault = EvidenceVault(
149
+ agent_id=high_risk_incomplete_passport.agent_id, vault_dir=tmp_path
150
+ )
151
+
152
+ with patch.dict("sys.modules", {"openai": mock_module}):
153
+ client = IrisOpenAI(passport=high_risk_incomplete_passport)
154
+ client._engine = engine
155
+ client._vault = vault
156
+
157
+ client.chat.completions.create(
158
+ model="gpt-4o",
159
+ messages=[{"role": "user", "content": "Help this customer."}],
160
+ )
161
+
162
+ mock_client.chat.completions.create.assert_called_once()
163
+ captured = capsys.readouterr()
164
+ assert "[IRIS WARNING]" in captured.err
165
+ events = vault.get_events()
166
+ assert events[0]["decision"] == "DENY"
167
+
168
+ def test_tool_filtering_removes_unpermitted_tools(
169
+ self, compliant_passport, tmp_path, monkeypatch, capsys
170
+ ):
171
+ monkeypatch.setenv("IRIS_ENV", "dev")
172
+ mock_module, mock_client = _mock_openai_module()
173
+ engine = CedarEngine()
174
+ engine.load_policy(compliant_passport.agent_id, "permit(principal, action, resource);")
175
+ vault = EvidenceVault(agent_id=compliant_passport.agent_id, vault_dir=tmp_path)
176
+
177
+ with patch.dict("sys.modules", {"openai": mock_module}):
178
+ client = IrisOpenAI(passport=compliant_passport)
179
+ client._engine = engine
180
+ client._vault = vault
181
+
182
+ client.chat.completions.create(
183
+ model="gpt-4o",
184
+ messages=[{"role": "user", "content": "Run tools"}],
185
+ tools=[_search_tool(), _payments_tool()],
186
+ )
187
+
188
+ call_kwargs = mock_client.chat.completions.create.call_args.kwargs
189
+ tool_names = [t["function"]["name"] for t in call_kwargs["tools"]]
190
+ assert tool_names == ["search"]
191
+ assert "payments" not in tool_names
192
+ captured = capsys.readouterr()
193
+ assert "IRIS TOOL FILTER" in captured.err
194
+ assert "payments" in captured.err
195
+
196
+ def test_azure_openai_cross_region_detection(
197
+ self, compliant_passport, tmp_path, monkeypatch
198
+ ):
199
+ monkeypatch.setenv("IRIS_ENV", "production")
200
+ eu_passport = AgentPassport(
201
+ name=compliant_passport.name,
202
+ owner=compliant_passport.owner,
203
+ agent_id=compliant_passport.agent_id,
204
+ data_classification=DataClassification.PII,
205
+ allowed_regions=["eu-west-1"],
206
+ environments=[Environment.PRODUCTION],
207
+ compliance_tags=[ComplianceTag.GDPR],
208
+ )
209
+ mock_module, mock_client = _mock_openai_module()
210
+ engine = CedarEngine()
211
+ engine.load_policy(eu_passport.agent_id, "permit(principal, action, resource);")
212
+ vault = EvidenceVault(agent_id=eu_passport.agent_id, vault_dir=tmp_path)
213
+
214
+ endpoint = "https://my-resource-eastus.cognitiveservices.azure.com/openai"
215
+ assert parse_azure_endpoint_region(endpoint) == "us-east-1"
216
+
217
+ with patch.dict("sys.modules", {"openai": mock_module}):
218
+ client = IrisAzureOpenAI(
219
+ passport=eu_passport,
220
+ azure_endpoint=endpoint,
221
+ api_key="test",
222
+ )
223
+ client._engine = engine
224
+ client._vault = vault
225
+
226
+ with pytest.raises(IrisViolationError) as exc_info:
227
+ client.chat.completions.create(
228
+ model="gpt-4o",
229
+ messages=[{"role": "user", "content": "Process EU data."}],
230
+ )
231
+
232
+ assert any(v.rule_id == "IRIS-XR-001" for v in exc_info.value.result.violations)
233
+ mock_client.chat.completions.create.assert_not_called()
234
+
235
+ def test_embeddings_intercept(self, compliant_passport, tmp_path, monkeypatch):
236
+ monkeypatch.setenv("IRIS_ENV", "dev")
237
+ mock_module, mock_client = _mock_openai_module()
238
+ engine = CedarEngine()
239
+ engine.load_policy(compliant_passport.agent_id, "permit(principal, action, resource);")
240
+ vault = EvidenceVault(agent_id=compliant_passport.agent_id, vault_dir=tmp_path)
241
+
242
+ with patch.dict("sys.modules", {"openai": mock_module}):
243
+ client = IrisOpenAI(passport=compliant_passport)
244
+ client._engine = engine
245
+ client._vault = vault
246
+
247
+ client.embeddings.create(model="text-embedding-3-small", input="hello")
248
+
249
+ mock_client.embeddings.create.assert_called_once()
250
+ events = vault.get_events()
251
+ assert len(events) == 1
252
+ assert events[0]["resource"] == "openai-api"
253
+
254
+ def test_streaming_intercept(self, compliant_passport, tmp_path, monkeypatch):
255
+ monkeypatch.setenv("IRIS_ENV", "dev")
256
+ mock_module, mock_client = _mock_openai_module()
257
+ engine = CedarEngine()
258
+ engine.load_policy(compliant_passport.agent_id, "permit(principal, action, resource);")
259
+ vault = EvidenceVault(agent_id=compliant_passport.agent_id, vault_dir=tmp_path)
260
+
261
+ with patch.dict("sys.modules", {"openai": mock_module}):
262
+ client = IrisOpenAI(passport=compliant_passport)
263
+ client._engine = engine
264
+ client._vault = vault
265
+
266
+ stream = client.chat.completions.stream(
267
+ model="gpt-4o",
268
+ messages=[{"role": "user", "content": "Stream a reply."}],
269
+ )
270
+ list(stream)
271
+
272
+ mock_client.chat.completions.stream.assert_called_once()
273
+ assert len(vault.get_events()) == 1
274
+
275
+ def test_evidence_vault_records_call(self, compliant_passport, tmp_path, monkeypatch):
276
+ monkeypatch.setenv("IRIS_ENV", "dev")
277
+ mock_module, _ = _mock_openai_module()
278
+ engine = CedarEngine()
279
+ engine.load_policy(compliant_passport.agent_id, "permit(principal, action, resource);")
280
+ vault = EvidenceVault(agent_id=compliant_passport.agent_id, vault_dir=tmp_path)
281
+
282
+ with patch.dict("sys.modules", {"openai": mock_module}):
283
+ client = IrisOpenAI(passport=compliant_passport)
284
+ client._engine = engine
285
+ client._vault = vault
286
+
287
+ client.chat.completions.create(
288
+ model="gpt-4o",
289
+ messages=[{"role": "user", "content": "First"}],
290
+ )
291
+ client.chat.completions.create(
292
+ model="gpt-4o",
293
+ messages=[{"role": "user", "content": "Second"}],
294
+ )
295
+
296
+ events = vault.get_events()
297
+ assert len(events) == 2
298
+ assert all(e["action"] == "call" for e in events)
299
+ assert all(e["resource"] == "openai-api" for e in events)
300
+
301
+
302
+ class TestGuardOpenAITools:
303
+ def test_production_blocks_all_tools_without_permissions(self, monkeypatch, capsys):
304
+ monkeypatch.setenv("IRIS_ENV", "production")
305
+ passport = AgentPassport(
306
+ name="no-tools",
307
+ owner="team@company.com",
308
+ environments=[Environment.PRODUCTION],
309
+ )
310
+ filtered = guard_openai_tools([_search_tool(), _payments_tool()], passport)
311
+ assert filtered == []
312
+ assert "IRIS TOOL FILTER" in capsys.readouterr().err
313
+
314
+ def test_guard_returns_only_permitted(self, compliant_passport, monkeypatch):
315
+ monkeypatch.setenv("IRIS_ENV", "production")
316
+ filtered = guard_openai_tools(
317
+ [_search_tool(), _payments_tool()],
318
+ compliant_passport,
319
+ Environment.PRODUCTION,
320
+ )
321
+ assert len(filtered) == 1
322
+ assert filtered[0]["function"]["name"] == "search"