iris-security-cli 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.
- iris_cli/__init__.py +0 -0
- iris_cli/assess.py +498 -0
- iris_cli/cedar_parser.py +454 -0
- iris_cli/compiler_config.py +54 -0
- iris_cli/evidence.py +822 -0
- iris_cli/main.py +542 -0
- iris_cli/mcp_server.py +567 -0
- iris_cli/policy_cache.py +116 -0
- iris_cli/policy_diff.py +467 -0
- iris_cli/scan_report.py +146 -0
- iris_security_cli-0.1.0.dist-info/METADATA +45 -0
- iris_security_cli-0.1.0.dist-info/RECORD +17 -0
- iris_security_cli-0.1.0.dist-info/WHEEL +5 -0
- iris_security_cli-0.1.0.dist-info/entry_points.txt +2 -0
- iris_security_cli-0.1.0.dist-info/top_level.txt +2 -0
- tests/test_evidence.py +296 -0
- tests/test_policy_diff.py +250 -0
iris_cli/cedar_parser.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse Cedar policy strings into structured rule objects and compute diffs.
|
|
3
|
+
|
|
4
|
+
Rule identity is determined by principal + action + resource (deterministic).
|
|
5
|
+
Plain-English summaries use templates only — no LLM required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
COMPLIANCE_REF_PATTERN = re.compile(
|
|
16
|
+
r"\b(CO-\d{3}|GDPR|HIPAA|SOC2|CCPA|PIPL|CJIS|FedRAMP)\b",
|
|
17
|
+
re.IGNORECASE,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
RULE_START_PATTERN = re.compile(r"^\s*(permit|forbid)\s*\(", re.MULTILINE | re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CedarRule:
|
|
25
|
+
type: str # "permit" | "forbid"
|
|
26
|
+
principal: str
|
|
27
|
+
action: str
|
|
28
|
+
resource: str
|
|
29
|
+
conditions: List[str] = field(default_factory=list)
|
|
30
|
+
compliance_refs: List[str] = field(default_factory=list)
|
|
31
|
+
plain_english: str = ""
|
|
32
|
+
raw_block: str = ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def identity(self) -> Tuple[str, str, str]:
|
|
36
|
+
return (self.principal, self.action, self.resource)
|
|
37
|
+
|
|
38
|
+
def identity_key(self) -> str:
|
|
39
|
+
return f"{self.principal}|{self.action}|{self.resource}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CedarDiff:
|
|
44
|
+
status: str # ADDED | REMOVED | MODIFIED | UNCHANGED
|
|
45
|
+
old_rule: Optional[CedarRule]
|
|
46
|
+
new_rule: Optional[CedarRule]
|
|
47
|
+
risk_delta: str # INCREASED | DECREASED | NEUTRAL
|
|
48
|
+
risk_reason: str
|
|
49
|
+
compliance_affected: List[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_cedar(cedar_str: str) -> List[CedarRule]:
|
|
53
|
+
"""Parse a Cedar policy string into structured rule objects."""
|
|
54
|
+
if not cedar_str or not cedar_str.strip():
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
rules: List[CedarRule] = []
|
|
58
|
+
for match in RULE_START_PATTERN.finditer(cedar_str):
|
|
59
|
+
start = match.start()
|
|
60
|
+
rule_type = match.group(1).lower()
|
|
61
|
+
block_end = _find_block_end(cedar_str, match.end() - 1)
|
|
62
|
+
block = cedar_str[start:block_end]
|
|
63
|
+
comment_start = _find_comment_start(cedar_str, start)
|
|
64
|
+
comment_block = cedar_str[comment_start:start]
|
|
65
|
+
rules.append(_parse_rule_block(rule_type, block, comment_block))
|
|
66
|
+
|
|
67
|
+
return rules
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def diff_cedar(old: List[CedarRule], new: List[CedarRule]) -> List[CedarDiff]:
|
|
71
|
+
"""Diff two Cedar rule lists. Returns results sorted deterministically."""
|
|
72
|
+
old_map = {r.identity_key(): r for r in old}
|
|
73
|
+
new_map = {r.identity_key(): r for r in new}
|
|
74
|
+
all_keys = sorted(set(old_map) | set(new_map))
|
|
75
|
+
|
|
76
|
+
diffs: List[CedarDiff] = []
|
|
77
|
+
for key in all_keys:
|
|
78
|
+
old_rule = old_map.get(key)
|
|
79
|
+
new_rule = new_map.get(key)
|
|
80
|
+
|
|
81
|
+
if old_rule is None and new_rule is not None:
|
|
82
|
+
status = "ADDED"
|
|
83
|
+
risk_delta, risk_reason = _assess_added_risk(new_rule)
|
|
84
|
+
compliance = list(new_rule.compliance_refs)
|
|
85
|
+
elif new_rule is None and old_rule is not None:
|
|
86
|
+
status = "REMOVED"
|
|
87
|
+
risk_delta, risk_reason = _assess_removed_risk(old_rule)
|
|
88
|
+
compliance = list(old_rule.compliance_refs)
|
|
89
|
+
elif _rules_equal(old_rule, new_rule):
|
|
90
|
+
status = "UNCHANGED"
|
|
91
|
+
risk_delta, risk_reason = "NEUTRAL", "No change to this rule"
|
|
92
|
+
compliance = list(dict.fromkeys(
|
|
93
|
+
(old_rule.compliance_refs if old_rule else [])
|
|
94
|
+
+ (new_rule.compliance_refs if new_rule else [])
|
|
95
|
+
))
|
|
96
|
+
else:
|
|
97
|
+
status = "MODIFIED"
|
|
98
|
+
risk_delta, risk_reason = _assess_modified_risk(old_rule, new_rule)
|
|
99
|
+
compliance = list(dict.fromkeys(
|
|
100
|
+
old_rule.compliance_refs + new_rule.compliance_refs
|
|
101
|
+
))
|
|
102
|
+
|
|
103
|
+
diffs.append(CedarDiff(
|
|
104
|
+
status=status,
|
|
105
|
+
old_rule=old_rule,
|
|
106
|
+
new_rule=new_rule,
|
|
107
|
+
risk_delta=risk_delta,
|
|
108
|
+
risk_reason=risk_reason,
|
|
109
|
+
compliance_affected=compliance,
|
|
110
|
+
))
|
|
111
|
+
|
|
112
|
+
return diffs
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _find_block_end(text: str, open_paren_idx: int) -> int:
|
|
116
|
+
depth = 0
|
|
117
|
+
i = open_paren_idx
|
|
118
|
+
while i < len(text):
|
|
119
|
+
if text[i] == "(":
|
|
120
|
+
depth += 1
|
|
121
|
+
elif text[i] == ")":
|
|
122
|
+
depth -= 1
|
|
123
|
+
if depth == 0:
|
|
124
|
+
j = i + 1
|
|
125
|
+
while j < len(text) and text[j] in " \t\n\r":
|
|
126
|
+
j += 1
|
|
127
|
+
if j < len(text) and text[j:].lower().startswith(("when", "unless")):
|
|
128
|
+
clause_end = _find_clause_end(text, j)
|
|
129
|
+
if text[clause_end - 1] == ";":
|
|
130
|
+
return clause_end
|
|
131
|
+
return clause_end
|
|
132
|
+
if j < len(text) and text[j] == ";":
|
|
133
|
+
return j + 1
|
|
134
|
+
return i + 1
|
|
135
|
+
i += 1
|
|
136
|
+
return len(text)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_clause_end(text: str, start: int) -> int:
|
|
140
|
+
brace_idx = text.find("{", start)
|
|
141
|
+
if brace_idx == -1:
|
|
142
|
+
return len(text)
|
|
143
|
+
depth = 0
|
|
144
|
+
for i in range(brace_idx, len(text)):
|
|
145
|
+
if text[i] == "{":
|
|
146
|
+
depth += 1
|
|
147
|
+
elif text[i] == "}":
|
|
148
|
+
depth -= 1
|
|
149
|
+
if depth == 0:
|
|
150
|
+
j = i + 1
|
|
151
|
+
while j < len(text) and text[j] in " \t\n\r":
|
|
152
|
+
j += 1
|
|
153
|
+
if j < len(text) and text[j] == ";":
|
|
154
|
+
return j + 1
|
|
155
|
+
return i + 1
|
|
156
|
+
return len(text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _find_comment_start(text: str, rule_start: int) -> int:
|
|
160
|
+
line_start = text.rfind("\n", 0, rule_start)
|
|
161
|
+
line_start = 0 if line_start == -1 else line_start + 1
|
|
162
|
+
|
|
163
|
+
while line_start > 0:
|
|
164
|
+
prev_end = line_start - 1
|
|
165
|
+
prev_start = text.rfind("\n", 0, prev_end)
|
|
166
|
+
prev_start = 0 if prev_start == -1 else prev_start + 1
|
|
167
|
+
prev_line = text[prev_start:prev_end + 1].strip()
|
|
168
|
+
if prev_line.startswith("//") or prev_line == "":
|
|
169
|
+
line_start = prev_start
|
|
170
|
+
else:
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
return line_start
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_rule_block(rule_type: str, block: str, comment_block: str) -> CedarRule:
|
|
177
|
+
principal = _extract_field(block, "principal")
|
|
178
|
+
action = _extract_field(block, "action")
|
|
179
|
+
resource = _extract_field(block, "resource")
|
|
180
|
+
conditions = _extract_conditions(block)
|
|
181
|
+
compliance_refs = _extract_compliance_refs(comment_block)
|
|
182
|
+
|
|
183
|
+
rule = CedarRule(
|
|
184
|
+
type=rule_type,
|
|
185
|
+
principal=principal,
|
|
186
|
+
action=action,
|
|
187
|
+
resource=resource,
|
|
188
|
+
conditions=conditions,
|
|
189
|
+
compliance_refs=compliance_refs,
|
|
190
|
+
raw_block=block.strip(),
|
|
191
|
+
)
|
|
192
|
+
rule.plain_english = _generate_plain_english(rule)
|
|
193
|
+
return rule
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _extract_field(block: str, field_name: str) -> str:
|
|
197
|
+
pattern = rf"{field_name}\s*(==|in)\s*(.+?)(?:,\s*\n|\))"
|
|
198
|
+
match = re.search(pattern, block, re.DOTALL | re.IGNORECASE)
|
|
199
|
+
if not match:
|
|
200
|
+
return ""
|
|
201
|
+
op = match.group(1).strip()
|
|
202
|
+
value = match.group(2).strip()
|
|
203
|
+
value = re.sub(r"\s+", " ", value)
|
|
204
|
+
if op == "in":
|
|
205
|
+
return f"in [{value}]"
|
|
206
|
+
return _normalize_cedar_value(value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _normalize_cedar_value(value: str) -> str:
|
|
210
|
+
"""Preserve iris::Type::\"name\" form for stable rule identity."""
|
|
211
|
+
value = value.strip().rstrip(",")
|
|
212
|
+
match = re.match(r'(iris::[\w]+::"[^"]+")', value)
|
|
213
|
+
if match:
|
|
214
|
+
return match.group(1)
|
|
215
|
+
quoted = re.search(r'::"([^"]+)"', value)
|
|
216
|
+
if quoted:
|
|
217
|
+
return quoted.group(1)
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _extract_conditions(block: str) -> List[str]:
|
|
222
|
+
conditions: List[str] = []
|
|
223
|
+
for clause_type in ("when", "unless"):
|
|
224
|
+
pattern = rf"{clause_type}\s*\{{(.*?)\}};"
|
|
225
|
+
match = re.search(pattern, block, re.DOTALL | re.IGNORECASE)
|
|
226
|
+
if match:
|
|
227
|
+
body = match.group(1).strip()
|
|
228
|
+
for part in body.split("&&"):
|
|
229
|
+
part = part.strip()
|
|
230
|
+
if part:
|
|
231
|
+
conditions.append(f"{clause_type}: {part}")
|
|
232
|
+
return conditions
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _extract_compliance_refs(comment_block: str) -> List[str]:
|
|
236
|
+
refs = COMPLIANCE_REF_PATTERN.findall(comment_block)
|
|
237
|
+
normalized = []
|
|
238
|
+
seen = set()
|
|
239
|
+
for ref in refs:
|
|
240
|
+
upper = ref.upper()
|
|
241
|
+
if upper.startswith("CO-"):
|
|
242
|
+
upper = upper # keep CO-001 format
|
|
243
|
+
if upper not in seen:
|
|
244
|
+
seen.add(upper)
|
|
245
|
+
normalized.append(upper if not upper.startswith("CO-") else ref.upper())
|
|
246
|
+
return sorted(normalized, key=lambda r: (not r.startswith("CO-"), r))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _generate_plain_english(rule: CedarRule) -> str:
|
|
250
|
+
action_phrase = _action_phrase(rule.action, rule.type)
|
|
251
|
+
resource_phrase = _resource_phrase(rule.resource)
|
|
252
|
+
|
|
253
|
+
if rule.type == "permit":
|
|
254
|
+
base = f"Agent may {action_phrase} {resource_phrase}".strip()
|
|
255
|
+
if _has_consent_gate(rule.conditions):
|
|
256
|
+
base += " with consent"
|
|
257
|
+
return base
|
|
258
|
+
|
|
259
|
+
if rule.conditions and any(c.lower().startswith("unless:") for c in rule.conditions):
|
|
260
|
+
return (
|
|
261
|
+
f"Agent is forbidden from {action_phrase} {resource_phrase} "
|
|
262
|
+
f"unless conditions are met"
|
|
263
|
+
).strip()
|
|
264
|
+
return (
|
|
265
|
+
f"Agent is forbidden from {action_phrase} {resource_phrase} "
|
|
266
|
+
f"when conditions are met"
|
|
267
|
+
).strip()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _action_phrase(action: str, rule_type: str) -> str:
|
|
271
|
+
if action.startswith("in ["):
|
|
272
|
+
inner = action[4:-1]
|
|
273
|
+
actions = re.findall(r'"([^"]+)"', inner)
|
|
274
|
+
if len(actions) == 1:
|
|
275
|
+
return _single_action_word(actions[0], rule_type)
|
|
276
|
+
if actions:
|
|
277
|
+
words = [_single_action_word(a, rule_type) for a in actions]
|
|
278
|
+
return ", ".join(words[:-1]) + f" and {words[-1]}"
|
|
279
|
+
return "perform actions on"
|
|
280
|
+
|
|
281
|
+
type_match = re.match(r'iris::Action::"([^"]+)"', action)
|
|
282
|
+
if type_match:
|
|
283
|
+
return _single_action_word(type_match.group(1), rule_type)
|
|
284
|
+
|
|
285
|
+
quoted = re.search(r'"([^"]+)"', action)
|
|
286
|
+
if quoted:
|
|
287
|
+
return _single_action_word(quoted.group(1), rule_type)
|
|
288
|
+
return action or "access"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _single_action_word(action: str, rule_type: str) -> str:
|
|
292
|
+
mapping = {
|
|
293
|
+
"read": "read from",
|
|
294
|
+
"write": "write to",
|
|
295
|
+
"call": "call",
|
|
296
|
+
"execute": "execute on",
|
|
297
|
+
}
|
|
298
|
+
return mapping.get(action, action)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _resource_phrase(resource: str) -> str:
|
|
302
|
+
if resource.startswith("in ["):
|
|
303
|
+
inner = resource[4:-1]
|
|
304
|
+
names = re.findall(r'"([^"]+)"', inner)
|
|
305
|
+
if not names:
|
|
306
|
+
return "specified resources"
|
|
307
|
+
if len(names) == 1:
|
|
308
|
+
return _single_resource_phrase(names[0], inner)
|
|
309
|
+
return ", ".join(_single_resource_phrase(n, inner) for n in names)
|
|
310
|
+
|
|
311
|
+
type_match = re.match(r'iris::(\w+)::"([^"]+)"', resource)
|
|
312
|
+
if type_match:
|
|
313
|
+
type_name, name = type_match.group(1), type_match.group(2)
|
|
314
|
+
return _single_resource_phrase(name, type_name)
|
|
315
|
+
|
|
316
|
+
quoted = re.search(r'"([^"]+)"', resource)
|
|
317
|
+
name = quoted.group(1) if quoted else resource
|
|
318
|
+
return _single_resource_phrase(name, resource)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _single_resource_phrase(name: str, raw: str) -> str:
|
|
322
|
+
raw_lower = raw.lower()
|
|
323
|
+
if "dataclass" in raw_lower or name.lower() == "pii":
|
|
324
|
+
if name.lower() == "pii":
|
|
325
|
+
return "PII data"
|
|
326
|
+
return f"{name} data"
|
|
327
|
+
if "api" in raw_lower or "API" in raw:
|
|
328
|
+
label = name.replace("-", " ")
|
|
329
|
+
if label.lower().endswith(" api"):
|
|
330
|
+
return label
|
|
331
|
+
return f"{label} API"
|
|
332
|
+
if "Storage" in raw:
|
|
333
|
+
return f"{name.replace('-', ' ')} storage"
|
|
334
|
+
if "Tool" in raw:
|
|
335
|
+
return f"{name.replace('-', ' ')} tool"
|
|
336
|
+
return name.replace("-", " ")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _has_consent_gate(conditions: List[str]) -> bool:
|
|
340
|
+
return any("user_consent_logged" in c for c in conditions)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _rules_equal(a: Optional[CedarRule], b: Optional[CedarRule]) -> bool:
|
|
344
|
+
if a is None or b is None:
|
|
345
|
+
return False
|
|
346
|
+
return (
|
|
347
|
+
a.type == b.type
|
|
348
|
+
and a.principal == b.principal
|
|
349
|
+
and a.action == b.action
|
|
350
|
+
and a.resource == b.resource
|
|
351
|
+
and a.conditions == b.conditions
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _environment_scope(rule: CedarRule) -> str:
|
|
356
|
+
for cond in rule.conditions:
|
|
357
|
+
if "environment" in cond.lower():
|
|
358
|
+
if "production" in cond and "dev" not in cond:
|
|
359
|
+
return "production"
|
|
360
|
+
if "dev" in cond and "test" in cond:
|
|
361
|
+
return "all environments"
|
|
362
|
+
return "unspecified scope"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _assess_added_risk(rule: CedarRule) -> Tuple[str, str]:
|
|
366
|
+
if rule.type == "permit":
|
|
367
|
+
if _has_consent_gate(rule.conditions):
|
|
368
|
+
return "NEUTRAL", "new capability with consent gate enforced"
|
|
369
|
+
return "NEUTRAL", "new capability added, review conditions"
|
|
370
|
+
return "DECREASED", "new restriction added, attack surface reduced"
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _assess_removed_risk(rule: CedarRule) -> Tuple[str, str]:
|
|
374
|
+
if rule.type == "permit":
|
|
375
|
+
return "INCREASED", "capability removed, agent may break at runtime"
|
|
376
|
+
return "INCREASED", "restriction removed, more exposure"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _assess_modified_risk(
|
|
380
|
+
old: CedarRule,
|
|
381
|
+
new: CedarRule,
|
|
382
|
+
) -> Tuple[str, str]:
|
|
383
|
+
old_strict = _strictness_score(old)
|
|
384
|
+
new_strict = _strictness_score(new)
|
|
385
|
+
|
|
386
|
+
if new.type != old.type:
|
|
387
|
+
if new.type == "forbid":
|
|
388
|
+
return "DECREASED", "rule changed to forbid, scope narrowed"
|
|
389
|
+
return "INCREASED", "rule changed to permit, scope widened"
|
|
390
|
+
|
|
391
|
+
old_scope = _environment_scope(old)
|
|
392
|
+
new_scope = _environment_scope(new)
|
|
393
|
+
if old_scope == "all environments" and new_scope == "production":
|
|
394
|
+
return "DECREASED", "narrower scope, less dev/test exposure"
|
|
395
|
+
|
|
396
|
+
if new_strict > old_strict:
|
|
397
|
+
return "DECREASED", "conditions became stricter, less exposure"
|
|
398
|
+
if new_strict < old_strict:
|
|
399
|
+
return "INCREASED", "conditions became looser, more exposure"
|
|
400
|
+
return "NEUTRAL", "conditions changed without clear risk shift"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _strictness_score(rule: CedarRule) -> int:
|
|
404
|
+
score = 0
|
|
405
|
+
if rule.type == "forbid":
|
|
406
|
+
score += 10
|
|
407
|
+
for cond in rule.conditions:
|
|
408
|
+
lower = cond.lower()
|
|
409
|
+
if "environment" in lower:
|
|
410
|
+
if re.search(r'in \[[^\]]*"production"[^\]]*\]', lower):
|
|
411
|
+
if "dev" in lower or "test" in lower or "staging" in lower:
|
|
412
|
+
score += 2
|
|
413
|
+
else:
|
|
414
|
+
score += 8
|
|
415
|
+
elif 'environment == "production"' in lower:
|
|
416
|
+
score += 8
|
|
417
|
+
if "user_consent_logged == true" in lower:
|
|
418
|
+
score += 3
|
|
419
|
+
if "user_consent_logged == false" in lower:
|
|
420
|
+
score += 4
|
|
421
|
+
if "unless:" in lower:
|
|
422
|
+
score += 2
|
|
423
|
+
if "in [" in lower:
|
|
424
|
+
score += 1
|
|
425
|
+
return score
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def summarize_diffs(diffs: List[CedarDiff]) -> dict:
|
|
429
|
+
"""Return counts and compliance impact summary."""
|
|
430
|
+
counts = {"ADDED": 0, "REMOVED": 0, "MODIFIED": 0, "UNCHANGED": 0}
|
|
431
|
+
for d in diffs:
|
|
432
|
+
counts[d.status] = counts.get(d.status, 0) + 1
|
|
433
|
+
|
|
434
|
+
violations_opened = sum(
|
|
435
|
+
1 for d in diffs
|
|
436
|
+
if d.status != "UNCHANGED" and d.risk_delta == "INCREASED"
|
|
437
|
+
)
|
|
438
|
+
violations_closed = sum(
|
|
439
|
+
1 for d in diffs
|
|
440
|
+
if d.status != "UNCHANGED" and d.risk_delta == "DECREASED"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
coverage: dict[str, int] = {}
|
|
444
|
+
for d in diffs:
|
|
445
|
+
if d.status != "UNCHANGED" and d.risk_delta == "DECREASED":
|
|
446
|
+
for ref in d.compliance_affected:
|
|
447
|
+
coverage[ref] = coverage.get(ref, 0) + 1
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
"counts": counts,
|
|
451
|
+
"violations_opened": violations_opened,
|
|
452
|
+
"violations_closed": violations_closed,
|
|
453
|
+
"coverage_strengthened": coverage,
|
|
454
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Load the developer's LLM compiler settings from ~/.iris/config.yaml.
|
|
3
|
+
|
|
4
|
+
Each developer brings their own API key and chooses their provider.
|
|
5
|
+
Keys are never stored in the repo or shipped with the SDK.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from iris_core.engine.compiler import PolicyCompiler
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".iris" / "config.yaml"
|
|
17
|
+
|
|
18
|
+
EXAMPLE_CONFIG = """\
|
|
19
|
+
# IRIS local configuration — stored on your machine only
|
|
20
|
+
# Copy to ~/.iris/config.yaml and add your API key via environment variable.
|
|
21
|
+
|
|
22
|
+
compiler:
|
|
23
|
+
backend: anthropic # anthropic | openai
|
|
24
|
+
model: claude-sonnet-4-6
|
|
25
|
+
|
|
26
|
+
# API keys are read from environment variables (never stored here):
|
|
27
|
+
# export ANTHROPIC_API_KEY=sk-ant-...
|
|
28
|
+
# export OPENAI_API_KEY=sk-...
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_iris_config(config_path: Path | None = None) -> dict[str, Any]:
|
|
33
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
34
|
+
if not path.exists():
|
|
35
|
+
return {}
|
|
36
|
+
try:
|
|
37
|
+
import yaml
|
|
38
|
+
data = yaml.safe_load(path.read_text())
|
|
39
|
+
return data if isinstance(data, dict) else {}
|
|
40
|
+
except Exception:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_policy_compiler(config_path: Path | None = None) -> PolicyCompiler:
|
|
45
|
+
"""Build a PolicyCompiler using the developer's ~/.iris/config.yaml settings."""
|
|
46
|
+
cfg = load_iris_config(config_path).get("compiler", {})
|
|
47
|
+
return PolicyCompiler(
|
|
48
|
+
llm_backend=cfg.get("backend", "anthropic"),
|
|
49
|
+
model=cfg.get("model", "claude-sonnet-4-6"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def compiler_info(compiler: PolicyCompiler) -> tuple[str, str]:
|
|
54
|
+
return compiler._llm_backend, compiler._model
|