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.
@@ -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