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/policy_diff.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""
|
|
2
|
+
iris policy diff — show Cedar rule changes before compiling.
|
|
3
|
+
|
|
4
|
+
Fully offline by default: compares policy.cedar on disk against a cached
|
|
5
|
+
policy-draft.cedar produced by the developer's last compile/dry-run.
|
|
6
|
+
|
|
7
|
+
To refresh the draft (uses the developer's own LLM key):
|
|
8
|
+
iris policy compile --agent <name> --dry-run
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import click
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from iris_cli.cedar_parser import (
|
|
21
|
+
CedarDiff,
|
|
22
|
+
CedarRule,
|
|
23
|
+
_environment_scope,
|
|
24
|
+
diff_cedar,
|
|
25
|
+
parse_cedar,
|
|
26
|
+
summarize_diffs,
|
|
27
|
+
)
|
|
28
|
+
from iris_cli.policy_cache import DraftCacheStatus, check_draft_cache, load_cached_draft
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PolicyDiffResult:
|
|
33
|
+
agent: str
|
|
34
|
+
diffs: List[CedarDiff]
|
|
35
|
+
summary: dict
|
|
36
|
+
compare_label: str = "policy.cedar → policy-draft.cedar"
|
|
37
|
+
old_source: str = "policy.cedar"
|
|
38
|
+
new_source: str = "policy-draft.cedar"
|
|
39
|
+
draft_stale: bool = False
|
|
40
|
+
draft_status: Optional[DraftCacheStatus] = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_policy_diff(
|
|
44
|
+
agent: str,
|
|
45
|
+
governance_dir: Optional[Path] = None,
|
|
46
|
+
from_branch: Optional[str] = None,
|
|
47
|
+
verbose: bool = False,
|
|
48
|
+
draft_cedar: Optional[str] = None,
|
|
49
|
+
draft_path: Optional[Path] = None,
|
|
50
|
+
compile: bool = False,
|
|
51
|
+
) -> PolicyDiffResult:
|
|
52
|
+
"""
|
|
53
|
+
Compare policy.cedar against a cached or explicit Cedar draft.
|
|
54
|
+
|
|
55
|
+
Offline by default — reads policy-draft.cedar from the last compile/dry-run.
|
|
56
|
+
Pass compile=True to regenerate the draft via the developer's LLM (opt-in).
|
|
57
|
+
"""
|
|
58
|
+
gov_dir = governance_dir or Path.cwd() / "governance" / "agents" / agent
|
|
59
|
+
passport_file = gov_dir / "passport.yaml"
|
|
60
|
+
intent_file = gov_dir / "policy-intent.md"
|
|
61
|
+
cedar_file = gov_dir / "policy.cedar"
|
|
62
|
+
|
|
63
|
+
if not passport_file.exists():
|
|
64
|
+
raise FileNotFoundError(f"Passport not found: {passport_file}")
|
|
65
|
+
if not intent_file.exists():
|
|
66
|
+
raise FileNotFoundError(f"Intent file not found: {intent_file}")
|
|
67
|
+
|
|
68
|
+
intent_text = intent_file.read_text()
|
|
69
|
+
draft_status = check_draft_cache(gov_dir, intent_text)
|
|
70
|
+
|
|
71
|
+
if from_branch:
|
|
72
|
+
old_cedar = _load_cedar_from_git(gov_dir, from_branch, agent)
|
|
73
|
+
compare_label = f"Current: policy.cedar@{from_branch} → Draft: compiled from intent"
|
|
74
|
+
old_source = f"policy.cedar@{from_branch}"
|
|
75
|
+
elif cedar_file.exists():
|
|
76
|
+
old_cedar = cedar_file.read_text()
|
|
77
|
+
compare_label = "Current: policy.cedar → Draft: compiled from intent"
|
|
78
|
+
old_source = "policy.cedar"
|
|
79
|
+
else:
|
|
80
|
+
old_cedar = ""
|
|
81
|
+
compare_label = "Current: (none) → Draft: compiled from intent"
|
|
82
|
+
old_source = "(none)"
|
|
83
|
+
|
|
84
|
+
if draft_cedar is not None:
|
|
85
|
+
new_cedar = draft_cedar
|
|
86
|
+
new_source = "injected draft"
|
|
87
|
+
elif draft_path is not None:
|
|
88
|
+
if not draft_path.exists():
|
|
89
|
+
raise FileNotFoundError(f"Draft file not found: {draft_path}")
|
|
90
|
+
new_cedar = draft_path.read_text()
|
|
91
|
+
new_source = str(draft_path)
|
|
92
|
+
elif compile:
|
|
93
|
+
new_cedar = _compile_draft(gov_dir, intent_text)
|
|
94
|
+
draft_status = check_draft_cache(gov_dir, intent_text)
|
|
95
|
+
new_source = "policy-draft.cedar (just compiled)"
|
|
96
|
+
else:
|
|
97
|
+
new_cedar, draft_status = load_cached_draft(gov_dir, intent_text)
|
|
98
|
+
new_source = "policy-draft.cedar"
|
|
99
|
+
|
|
100
|
+
old_rules = parse_cedar(old_cedar)
|
|
101
|
+
new_rules = parse_cedar(new_cedar)
|
|
102
|
+
diffs = diff_cedar(old_rules, new_rules)
|
|
103
|
+
|
|
104
|
+
if not verbose:
|
|
105
|
+
diffs = [d for d in diffs if d.status != "UNCHANGED"]
|
|
106
|
+
|
|
107
|
+
summary = summarize_diffs(diff_cedar(old_rules, new_rules))
|
|
108
|
+
|
|
109
|
+
return PolicyDiffResult(
|
|
110
|
+
agent=agent,
|
|
111
|
+
diffs=diffs,
|
|
112
|
+
summary=summary,
|
|
113
|
+
compare_label=compare_label,
|
|
114
|
+
old_source=old_source,
|
|
115
|
+
new_source=new_source,
|
|
116
|
+
draft_stale=draft_status.is_stale if draft_status else False,
|
|
117
|
+
draft_status=draft_status,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _compile_draft(gov_dir: Path, intent_text: str) -> str:
|
|
122
|
+
from iris_core.models.passport import AgentPassport
|
|
123
|
+
|
|
124
|
+
from iris_cli.compiler_config import compiler_info, create_policy_compiler
|
|
125
|
+
from iris_cli.policy_cache import save_policy_draft
|
|
126
|
+
|
|
127
|
+
passport = AgentPassport.from_yaml((gov_dir / "passport.yaml").read_text())
|
|
128
|
+
compiler = create_policy_compiler()
|
|
129
|
+
result = compiler.compile(intent_text, passport, dry_run=True)
|
|
130
|
+
if not result.cedar_policy:
|
|
131
|
+
raise RuntimeError(
|
|
132
|
+
"Policy compiler returned empty Cedar.\n"
|
|
133
|
+
"Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or configure ~/.iris/config.yaml"
|
|
134
|
+
)
|
|
135
|
+
backend, model = compiler_info(compiler)
|
|
136
|
+
save_policy_draft(gov_dir, intent_text, result.cedar_policy, backend, model)
|
|
137
|
+
return result.cedar_policy
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _load_cedar_from_git(gov_dir: Path, branch: str, agent: str) -> str:
|
|
141
|
+
rel = f"governance/agents/{agent}/policy.cedar"
|
|
142
|
+
cwd = gov_dir
|
|
143
|
+
while cwd != cwd.parent:
|
|
144
|
+
if (cwd / ".git").exists():
|
|
145
|
+
break
|
|
146
|
+
cwd = cwd.parent
|
|
147
|
+
else:
|
|
148
|
+
cwd = Path.cwd()
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
result = subprocess.run(
|
|
152
|
+
["git", "show", f"{branch}:{rel}"],
|
|
153
|
+
capture_output=True,
|
|
154
|
+
text=True,
|
|
155
|
+
check=True,
|
|
156
|
+
cwd=cwd,
|
|
157
|
+
)
|
|
158
|
+
return result.stdout
|
|
159
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
160
|
+
cedar_file = gov_dir / "policy.cedar"
|
|
161
|
+
if cedar_file.exists():
|
|
162
|
+
return cedar_file.read_text()
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _print_diff_entry(console, diff: CedarDiff) -> None:
|
|
167
|
+
refs = ", ".join(f"[{r}]" for r in diff.compliance_affected) or "[—]"
|
|
168
|
+
rule = diff.new_rule or diff.old_rule
|
|
169
|
+
english = rule.plain_english if rule else ""
|
|
170
|
+
|
|
171
|
+
if diff.status == "ADDED":
|
|
172
|
+
console.print(f"\nADDED {refs} {english}")
|
|
173
|
+
elif diff.status == "REMOVED":
|
|
174
|
+
console.print(f"\nREMOVED {refs} {english}")
|
|
175
|
+
elif diff.status == "MODIFIED":
|
|
176
|
+
console.print(f"\n~ MODIFIED {refs} {_modified_summary(diff)}")
|
|
177
|
+
if diff.old_rule and diff.new_rule:
|
|
178
|
+
console.print(
|
|
179
|
+
f"Was: {_condition_summary(diff.old_rule)} → "
|
|
180
|
+
f"Now: {_condition_summary(diff.new_rule)}"
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
console.print(f"\n[dim]UNCHANGED[/dim] {refs} {english}")
|
|
184
|
+
|
|
185
|
+
risk_color = {
|
|
186
|
+
"INCREASED": "red",
|
|
187
|
+
"DECREASED": "green",
|
|
188
|
+
"NEUTRAL": "yellow",
|
|
189
|
+
}.get(diff.risk_delta, "white")
|
|
190
|
+
console.print(
|
|
191
|
+
f"Risk: [{risk_color}]{diff.risk_delta}[/{risk_color}] — {diff.risk_reason}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _modified_summary(diff: CedarDiff) -> str:
|
|
196
|
+
if not diff.old_rule or not diff.new_rule:
|
|
197
|
+
return diff.new_rule.plain_english if diff.new_rule else ""
|
|
198
|
+
old_env = _environment_scope(diff.old_rule)
|
|
199
|
+
new_env = _environment_scope(diff.new_rule)
|
|
200
|
+
resource = _resource_label(diff.new_rule)
|
|
201
|
+
if old_env != new_env:
|
|
202
|
+
return f"{resource} now restricted to {new_env} only"
|
|
203
|
+
return diff.new_rule.plain_english
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _resource_label(rule: CedarRule) -> str:
|
|
207
|
+
quoted = __import__("re").search(r'"([^"]+)"', rule.resource)
|
|
208
|
+
if quoted:
|
|
209
|
+
name = quoted.group(1).replace("-", " ")
|
|
210
|
+
if "API" in rule.resource:
|
|
211
|
+
return f"{name.title()} API"
|
|
212
|
+
return name.title()
|
|
213
|
+
return "Resource"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _condition_summary(rule: CedarRule) -> str:
|
|
217
|
+
scope = _environment_scope(rule)
|
|
218
|
+
if scope != "unspecified scope":
|
|
219
|
+
return scope
|
|
220
|
+
if rule.conditions:
|
|
221
|
+
return f"{len(rule.conditions)} condition(s)"
|
|
222
|
+
return "no conditions"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _print_compliance_impact(console, result: PolicyDiffResult) -> None:
|
|
226
|
+
summary = result.summary
|
|
227
|
+
if not any(d.status != "UNCHANGED" for d in result.diffs):
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
console.print("\n[bold]Compliance impact:[/bold]")
|
|
231
|
+
if summary["violations_opened"] == 0:
|
|
232
|
+
console.print("[green]✓ No new violations opened[/green]")
|
|
233
|
+
else:
|
|
234
|
+
console.print(
|
|
235
|
+
f"[red]✗ {summary['violations_opened']} change(s) may open compliance gaps[/red]"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if summary["violations_closed"] > 0:
|
|
239
|
+
console.print(
|
|
240
|
+
f"[green]✓ {summary['violations_closed']} change(s) reduced compliance risk[/green]"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
for ref, count in sorted(result.summary["coverage_strengthened"].items()):
|
|
244
|
+
if count == 1:
|
|
245
|
+
console.print(f"[green]✓ {ref} coverage strengthened[/green]")
|
|
246
|
+
else:
|
|
247
|
+
console.print(
|
|
248
|
+
f"[green]✓ {ref} coverage strengthened in {count} rule(s)[/green]"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _print_draft_notice(console, result: PolicyDiffResult) -> None:
|
|
253
|
+
if result.draft_stale:
|
|
254
|
+
console.print(
|
|
255
|
+
"\n[yellow]⚠ policy-intent.md changed since the cached draft was compiled.[/yellow]"
|
|
256
|
+
)
|
|
257
|
+
console.print(
|
|
258
|
+
f'[yellow]Refresh the draft:[/yellow] '
|
|
259
|
+
f'[bold]iris policy compile --agent {result.agent} --dry-run[/bold]'
|
|
260
|
+
)
|
|
261
|
+
if result.draft_status and result.draft_status.meta:
|
|
262
|
+
meta = result.draft_status.meta
|
|
263
|
+
console.print(
|
|
264
|
+
f"[dim]Cached draft: {meta.compiled_at} "
|
|
265
|
+
f"via {meta.compiler_backend}/{meta.compiler_model}[/dim]"
|
|
266
|
+
)
|
|
267
|
+
elif result.draft_status and result.draft_status.meta:
|
|
268
|
+
meta = result.draft_status.meta
|
|
269
|
+
console.print(
|
|
270
|
+
f"\n[dim]Draft compiled {meta.compiled_at} "
|
|
271
|
+
f"via {meta.compiler_backend}/{meta.compiler_model}[/dim]"
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _print_footer(console, result: PolicyDiffResult) -> None:
|
|
276
|
+
_print_draft_notice(console, result)
|
|
277
|
+
if result.draft_stale:
|
|
278
|
+
console.print(
|
|
279
|
+
f'\nRun "[bold]iris policy compile --agent {result.agent} --dry-run[/bold]" '
|
|
280
|
+
f"to refresh the draft, then diff again."
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
console.print(
|
|
284
|
+
f'\nRun "[bold]iris policy compile --agent {result.agent}[/bold]" '
|
|
285
|
+
f"to apply these changes."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def format_diff_json(result: PolicyDiffResult) -> str:
|
|
290
|
+
"""Machine-readable diff for CI integration."""
|
|
291
|
+
payload = {
|
|
292
|
+
"agent": result.agent,
|
|
293
|
+
"compare": result.compare_label,
|
|
294
|
+
"draft_stale": result.draft_stale,
|
|
295
|
+
"summary": result.summary,
|
|
296
|
+
"diffs": [
|
|
297
|
+
{
|
|
298
|
+
"status": d.status,
|
|
299
|
+
"risk_delta": d.risk_delta,
|
|
300
|
+
"risk_reason": d.risk_reason,
|
|
301
|
+
"compliance_affected": d.compliance_affected,
|
|
302
|
+
"plain_english": (
|
|
303
|
+
(d.new_rule or d.old_rule).plain_english
|
|
304
|
+
if (d.new_rule or d.old_rule)
|
|
305
|
+
else ""
|
|
306
|
+
),
|
|
307
|
+
"old_rule": _rule_to_dict(d.old_rule),
|
|
308
|
+
"new_rule": _rule_to_dict(d.new_rule),
|
|
309
|
+
}
|
|
310
|
+
for d in result.diffs
|
|
311
|
+
],
|
|
312
|
+
}
|
|
313
|
+
if result.draft_status and result.draft_status.meta:
|
|
314
|
+
payload["draft_meta"] = {
|
|
315
|
+
"compiled_at": result.draft_status.meta.compiled_at,
|
|
316
|
+
"compiler_backend": result.draft_status.meta.compiler_backend,
|
|
317
|
+
"compiler_model": result.draft_status.meta.compiler_model,
|
|
318
|
+
}
|
|
319
|
+
return json.dumps(payload, indent=2)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _rule_to_dict(rule: Optional[CedarRule]) -> Optional[dict]:
|
|
323
|
+
if rule is None:
|
|
324
|
+
return None
|
|
325
|
+
return {
|
|
326
|
+
"type": rule.type,
|
|
327
|
+
"principal": rule.principal,
|
|
328
|
+
"action": rule.action,
|
|
329
|
+
"resource": rule.resource,
|
|
330
|
+
"conditions": rule.conditions,
|
|
331
|
+
"compliance_refs": rule.compliance_refs,
|
|
332
|
+
"plain_english": rule.plain_english,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def format_diff_markdown(result: PolicyDiffResult) -> str:
|
|
337
|
+
"""Render diff as markdown."""
|
|
338
|
+
counts = result.summary["counts"]
|
|
339
|
+
lines = [
|
|
340
|
+
f"# Policy diff: {result.agent}",
|
|
341
|
+
"",
|
|
342
|
+
f"**Comparing:** {result.compare_label}",
|
|
343
|
+
"",
|
|
344
|
+
(
|
|
345
|
+
f"**Rules:** {counts['ADDED']} added, {counts['REMOVED']} removed, "
|
|
346
|
+
f"{counts['MODIFIED']} modified, {counts['UNCHANGED']} unchanged"
|
|
347
|
+
),
|
|
348
|
+
"",
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
if result.draft_stale:
|
|
352
|
+
lines.append(
|
|
353
|
+
"> ⚠ policy-intent.md changed since the cached draft. "
|
|
354
|
+
f"Run `iris policy compile --agent {result.agent} --dry-run` to refresh."
|
|
355
|
+
)
|
|
356
|
+
lines.append("")
|
|
357
|
+
|
|
358
|
+
for diff in result.diffs:
|
|
359
|
+
refs = ", ".join(diff.compliance_affected) or "—"
|
|
360
|
+
rule = diff.new_rule or diff.old_rule
|
|
361
|
+
english = rule.plain_english if rule else ""
|
|
362
|
+
lines.append(f"## {diff.status} [{refs}]")
|
|
363
|
+
lines.append("")
|
|
364
|
+
lines.append(english)
|
|
365
|
+
lines.append("")
|
|
366
|
+
lines.append(f"**Risk:** {diff.risk_delta} — {diff.risk_reason}")
|
|
367
|
+
lines.append("")
|
|
368
|
+
|
|
369
|
+
lines.append("## Compliance impact")
|
|
370
|
+
lines.append("")
|
|
371
|
+
if result.summary["violations_opened"] == 0:
|
|
372
|
+
lines.append("- ✓ No new violations opened")
|
|
373
|
+
for ref, count in sorted(result.summary["coverage_strengthened"].items()):
|
|
374
|
+
lines.append(f"- ✓ {ref} coverage strengthened in {count} rule(s)")
|
|
375
|
+
|
|
376
|
+
lines.append("")
|
|
377
|
+
if result.draft_stale:
|
|
378
|
+
lines.append(
|
|
379
|
+
f"Run `iris policy compile --agent {result.agent} --dry-run` to refresh the draft."
|
|
380
|
+
)
|
|
381
|
+
else:
|
|
382
|
+
lines.append(f"Run `iris policy compile --agent {result.agent}` to apply these changes.")
|
|
383
|
+
return "\n".join(lines)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@click.command("diff")
|
|
387
|
+
@click.option("--agent", required=True, help="Agent name")
|
|
388
|
+
@click.option("--from", "from_branch", default=None, help="Git branch for baseline policy.cedar")
|
|
389
|
+
@click.option("--dir", "governance_dir", type=Path, default=None)
|
|
390
|
+
@click.option("--draft", "draft_path", type=Path, default=None, help="Compare against this Cedar file")
|
|
391
|
+
@click.option(
|
|
392
|
+
"--compile",
|
|
393
|
+
"compile_draft",
|
|
394
|
+
is_flag=True,
|
|
395
|
+
help="Recompile draft via your LLM before diffing (uses your API key)",
|
|
396
|
+
)
|
|
397
|
+
@click.option(
|
|
398
|
+
"--format",
|
|
399
|
+
"output_format",
|
|
400
|
+
default="table",
|
|
401
|
+
type=click.Choice(["table", "json", "markdown"]),
|
|
402
|
+
)
|
|
403
|
+
@click.option("--verbose", is_flag=True, help="Include unchanged rules in output")
|
|
404
|
+
def policy_diff(
|
|
405
|
+
agent,
|
|
406
|
+
from_branch,
|
|
407
|
+
governance_dir,
|
|
408
|
+
draft_path,
|
|
409
|
+
compile_draft,
|
|
410
|
+
output_format,
|
|
411
|
+
verbose,
|
|
412
|
+
):
|
|
413
|
+
"""
|
|
414
|
+
Preview Cedar policy changes before compiling.
|
|
415
|
+
|
|
416
|
+
Fully offline by default — compares policy.cedar against a cached
|
|
417
|
+
policy-draft.cedar from your last compile/dry-run. No API calls.
|
|
418
|
+
|
|
419
|
+
Workflow:
|
|
420
|
+
1. Edit policy-intent.md
|
|
421
|
+
2. iris policy compile --agent <name> --dry-run (your LLM key)
|
|
422
|
+
3. iris policy diff --agent <name> (offline, free)
|
|
423
|
+
|
|
424
|
+
Example:
|
|
425
|
+
iris policy diff --agent payment-agent
|
|
426
|
+
iris policy diff --agent payment-agent --format json
|
|
427
|
+
iris policy diff --agent payment-agent --compile
|
|
428
|
+
"""
|
|
429
|
+
from rich.console import Console
|
|
430
|
+
from rich.panel import Panel
|
|
431
|
+
|
|
432
|
+
console = Console()
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
result = run_policy_diff(
|
|
436
|
+
agent=agent,
|
|
437
|
+
governance_dir=governance_dir,
|
|
438
|
+
from_branch=from_branch,
|
|
439
|
+
verbose=verbose,
|
|
440
|
+
draft_path=draft_path,
|
|
441
|
+
compile=compile_draft,
|
|
442
|
+
)
|
|
443
|
+
except FileNotFoundError as exc:
|
|
444
|
+
console.print(f"[red]{exc}[/red]")
|
|
445
|
+
raise SystemExit(1)
|
|
446
|
+
except RuntimeError as exc:
|
|
447
|
+
console.print(f"[red]{exc}[/red]")
|
|
448
|
+
raise SystemExit(1)
|
|
449
|
+
|
|
450
|
+
if output_format == "json":
|
|
451
|
+
click.echo(format_diff_json(result))
|
|
452
|
+
elif output_format == "markdown":
|
|
453
|
+
click.echo(format_diff_markdown(result))
|
|
454
|
+
else:
|
|
455
|
+
counts = result.summary["counts"]
|
|
456
|
+
header = (
|
|
457
|
+
f"Current: {result.old_source} → Draft: compiled from intent\n"
|
|
458
|
+
f"Changes: {counts['ADDED']} added, {counts['REMOVED']} removed, "
|
|
459
|
+
f"{counts['MODIFIED']} modified, {counts['UNCHANGED']} unchanged"
|
|
460
|
+
)
|
|
461
|
+
console.print(Panel(header, title=f"Policy diff: {agent}", style="blue"))
|
|
462
|
+
|
|
463
|
+
for diff in result.diffs:
|
|
464
|
+
_print_diff_entry(console, diff)
|
|
465
|
+
|
|
466
|
+
_print_compliance_impact(console, result)
|
|
467
|
+
_print_footer(console, result)
|
iris_cli/scan_report.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Rich terminal rendering for enhanced iris scan results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from iris_core.compliance.registry import ComplianceRegistry
|
|
13
|
+
from iris_core.discovery.scanner import ScanResult, UngovernedFinding
|
|
14
|
+
from iris_core.models.passport import AgentPassport
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _compliance_label(passport: AgentPassport) -> str:
|
|
18
|
+
if passport.compliance_tags:
|
|
19
|
+
return passport.compliance_tags[0].value
|
|
20
|
+
return "colorado-ai-act"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _agent_status(
|
|
24
|
+
passport: AgentPassport,
|
|
25
|
+
registry: ComplianceRegistry,
|
|
26
|
+
framework: Optional[str],
|
|
27
|
+
) -> str:
|
|
28
|
+
violations = registry.check_passport(passport, framework)
|
|
29
|
+
return "PASS" if not violations else "FAIL"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_discover_scan(
|
|
33
|
+
console: Console,
|
|
34
|
+
result: ScanResult,
|
|
35
|
+
scan_dir: Path,
|
|
36
|
+
framework: Optional[str] = None,
|
|
37
|
+
auto_register: bool = False,
|
|
38
|
+
drafts_written: Optional[List[Path]] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Render governed agents, ungoverned findings, and scan summary."""
|
|
41
|
+
registry = ComplianceRegistry()
|
|
42
|
+
drafts_written = drafts_written or []
|
|
43
|
+
|
|
44
|
+
console.print(
|
|
45
|
+
Panel(
|
|
46
|
+
f"[bold]IRIS Governance Scan[/bold]\n"
|
|
47
|
+
f"Directory: {scan_dir}\n"
|
|
48
|
+
f"Framework: {framework or 'all active bundles'}\n"
|
|
49
|
+
f"Files scanned: {result.files_scanned:,} | "
|
|
50
|
+
f"Lines scanned: {result.lines_scanned:,}",
|
|
51
|
+
style="blue",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
governed = result.governed_agents
|
|
56
|
+
console.print(f"\n[bold]GOVERNED AGENTS ({len(governed)})[/bold]")
|
|
57
|
+
if governed:
|
|
58
|
+
for passport in governed:
|
|
59
|
+
compliance = _compliance_label(passport)
|
|
60
|
+
status = _agent_status(passport, registry, framework)
|
|
61
|
+
status_style = "green" if status == "PASS" else "red"
|
|
62
|
+
console.print(
|
|
63
|
+
f"[green]✓[/green] {passport.name:<20} "
|
|
64
|
+
f"{compliance:<18} [{status_style}]{status}[/{status_style}]"
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
console.print("[dim]No passport.yaml files found.[/dim]")
|
|
68
|
+
|
|
69
|
+
findings = result.ungoverned_findings
|
|
70
|
+
console.print(f"\n[bold]UNGOVERNED AGENTS DETECTED ({len(findings)})[/bold]")
|
|
71
|
+
if findings:
|
|
72
|
+
for finding in findings:
|
|
73
|
+
_print_finding(console, finding)
|
|
74
|
+
if not auto_register:
|
|
75
|
+
console.print(
|
|
76
|
+
'\n[dim]Run "iris scan --discover --auto-register" to generate '
|
|
77
|
+
"passport drafts for all findings.[/dim]"
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
console.print("[green]No ungoverned AI patterns detected in source files.[/green]")
|
|
81
|
+
|
|
82
|
+
if result.shadow_agents:
|
|
83
|
+
console.print(f"\n[bold]SHADOW AGENTS ({len(result.shadow_agents)})[/bold]")
|
|
84
|
+
for shadow in result.shadow_agents:
|
|
85
|
+
console.print(
|
|
86
|
+
f"[yellow]⚠[/yellow] {shadow.resource_path} "
|
|
87
|
+
f"({shadow.resource_name} @ {shadow.namespace})\n"
|
|
88
|
+
f" {shadow.reason}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if drafts_written:
|
|
92
|
+
console.print(f"\n[bold green]Passport drafts written ({len(drafts_written)})[/bold green]")
|
|
93
|
+
for draft in drafts_written:
|
|
94
|
+
console.print(f" [cyan]{draft}[/cyan]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _print_finding(console: Console, finding: UngovernedFinding) -> None:
|
|
98
|
+
risk_style = {
|
|
99
|
+
"HIGH": "red",
|
|
100
|
+
"MEDIUM": "yellow",
|
|
101
|
+
"LOW": "green",
|
|
102
|
+
}.get(finding.risk_level, "white")
|
|
103
|
+
console.print(
|
|
104
|
+
f"\n[yellow]⚠[/yellow] {finding.file_path}:{finding.line_number}\n"
|
|
105
|
+
f"Pattern: {finding.pattern_matched}\n"
|
|
106
|
+
f"Framework: {_display_framework(finding.framework_detected)}\n"
|
|
107
|
+
f"Risk: [{risk_style}]{finding.risk_level}[/{risk_style}] — {finding.risk_reason}\n"
|
|
108
|
+
f"Fix: [bold cyan]{finding.suggested_command}[/bold cyan]"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _display_framework(framework: str) -> str:
|
|
113
|
+
labels: Dict[str, str] = {
|
|
114
|
+
"langchain": "LangChain",
|
|
115
|
+
"crewai": "CrewAI",
|
|
116
|
+
"openai": "OpenAI",
|
|
117
|
+
"anthropic": "Anthropic SDK",
|
|
118
|
+
"generic": "Generic",
|
|
119
|
+
}
|
|
120
|
+
return labels.get(framework, framework)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_violations_table(console: Console, violations: list) -> None:
|
|
124
|
+
"""Render compliance violations table (existing scan behavior)."""
|
|
125
|
+
table = Table(title=f"Violations ({len(violations)} found)")
|
|
126
|
+
table.add_column("Rule", style="cyan", no_wrap=True)
|
|
127
|
+
table.add_column("Severity", style="red")
|
|
128
|
+
table.add_column("Message")
|
|
129
|
+
table.add_column("Remediation", style="yellow")
|
|
130
|
+
for v in violations:
|
|
131
|
+
severity_color = {
|
|
132
|
+
"CRITICAL": "red",
|
|
133
|
+
"HIGH": "orange3",
|
|
134
|
+
"MEDIUM": "yellow",
|
|
135
|
+
"LOW": "green",
|
|
136
|
+
}.get(v.severity.value, "white")
|
|
137
|
+
remediation = v.remediation
|
|
138
|
+
if len(remediation) > 80:
|
|
139
|
+
remediation = remediation[:80] + "..."
|
|
140
|
+
table.add_row(
|
|
141
|
+
v.rule_id,
|
|
142
|
+
f"[{severity_color}]{v.severity.value}[/{severity_color}]",
|
|
143
|
+
v.message,
|
|
144
|
+
remediation,
|
|
145
|
+
)
|
|
146
|
+
console.print(table)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iris-security-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: IRIS CLI — iris scan, iris register, iris policy, iris compliance
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/gimartinb/iris-sdk
|
|
7
|
+
Project-URL: Repository, https://github.com/gimartinb/iris-sdk
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: iris-security-core>=0.1.0
|
|
19
|
+
Requires-Dist: iris-security-sdk>=0.1.0
|
|
20
|
+
Requires-Dist: click>=8.1
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
|
|
24
|
+
# iris-security-cli
|
|
25
|
+
|
|
26
|
+
IRIS CLI — `iris scan`, `iris register`, `iris policy`, `iris compliance`.
|
|
27
|
+
|
|
28
|
+
Command-line tools for AI agent governance and Colorado AI Act compliance.
|
|
29
|
+
|
|
30
|
+
Part of the [IRIS SDK](https://github.com/gimartinb/iris-sdk).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install iris-security-cli
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
iris --version
|
|
42
|
+
iris register --name my-agent --owner you@company.com --team my-team --compliance colorado-ai-act
|
|
43
|
+
iris scan
|
|
44
|
+
iris compliance check --framework colorado-ai-act
|
|
45
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
iris_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
iris_cli/assess.py,sha256=-18cQZBlbqGnk2Q-3i1SNrfeV98LUt2Go8w3WMsXU54,16515
|
|
3
|
+
iris_cli/cedar_parser.py,sha256=3V1t-L3cSBCAMDmCkzcoBDA6S4ulg5LKpWDN2fU_jbM,15161
|
|
4
|
+
iris_cli/compiler_config.py,sha256=DbKxqe3D4hpkDMyPT2QOfUg7RJNTneMMC0hLgTkCh7k,1610
|
|
5
|
+
iris_cli/evidence.py,sha256=ZEHUUOK6TNTPgeUGycFy4f9CfAXJAHASyThi3gW3mOE,29631
|
|
6
|
+
iris_cli/main.py,sha256=xpLZbGLePysH_dF7ex9snsg5toPHIuHNogz2gmBV-dI,19834
|
|
7
|
+
iris_cli/mcp_server.py,sha256=FcIhBFxEfFy5RYsS9jchYuE2d57fmdZja58EFiwab0o,20782
|
|
8
|
+
iris_cli/policy_cache.py,sha256=cSqbbvSwFfQU3JnD8EWxQ0dW8sOcHYfTEXVpMkre87U,3456
|
|
9
|
+
iris_cli/policy_diff.py,sha256=CymsDBeWJ30WonIzhTtWcA03mcW6v9UA-6VFh1fpByc,15945
|
|
10
|
+
iris_cli/scan_report.py,sha256=myYuvF6rfUxJFDHWi-iErB17-QbHbfwzUleXt6MU0uo,5097
|
|
11
|
+
tests/test_evidence.py,sha256=uPN_XKWqktrQvepC55HXQ0SNOy3KoxKDOByzLavLWFk,9395
|
|
12
|
+
tests/test_policy_diff.py,sha256=338L3eb6BKiUAixan_TGSCSGFkUEw74F12RxH2rHxLo,8052
|
|
13
|
+
iris_security_cli-0.1.0.dist-info/METADATA,sha256=6Eul4xdCFyzy_Aqd8XhcqjiiijHCkfeOQ08yLxMxhNw,1403
|
|
14
|
+
iris_security_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
iris_security_cli-0.1.0.dist-info/entry_points.txt,sha256=MGi5_jQ_DETbkKiZLmgKYNRt6vIXtMQvmwCSU0T7e64,43
|
|
16
|
+
iris_security_cli-0.1.0.dist-info/top_level.txt,sha256=OanTp8Sdq_suSs9gfugtQibn3SJ71i-JCSsOfA9CSsg,15
|
|
17
|
+
iris_security_cli-0.1.0.dist-info/RECORD,,
|