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.
- examples/governed_gpt.py +61 -0
- iris_openai/__init__.py +46 -0
- iris_openai/_governance.py +368 -0
- iris_openai/client.py +343 -0
- iris_openai/tool_guard.py +97 -0
- iris_security_openai-0.1.0.dist-info/METADATA +52 -0
- iris_security_openai-0.1.0.dist-info/RECORD +11 -0
- iris_security_openai-0.1.0.dist-info/WHEEL +5 -0
- iris_security_openai-0.1.0.dist-info/top_level.txt +3 -0
- tests/conftest.py +11 -0
- tests/test_openai_integration.py +322 -0
examples/governed_gpt.py
ADDED
|
@@ -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
|
+
)
|
iris_openai/__init__.py
ADDED
|
@@ -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,,
|
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"
|