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/evidence.py ADDED
@@ -0,0 +1,822 @@
1
+ """
2
+ iris evidence — Evidence Vault read path and audit reporting.
3
+
4
+ Fully offline: no LLM calls, no network. Generates audit reports for
5
+ CISO review, annual compliance checks, and external auditor handoff.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import json
12
+ from collections import Counter
13
+ from datetime import datetime, timedelta
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+
22
+ from iris_core.compliance.bundles.colorado_ai_act import get_colorado_rules
23
+ from iris_core.evidence.vault import EvidenceVault, VaultSummary
24
+ from iris_core.models.passport import AgentPassport
25
+
26
+ console = Console()
27
+
28
+ ANNUAL_REVIEW_WARNING_DAYS = 30
29
+
30
+
31
+ def _governance_dir(governance_dir: Optional[Path]) -> Path:
32
+ return governance_dir or Path.cwd() / "governance" / "agents"
33
+
34
+
35
+ def _load_passport(agent: str, governance_dir: Optional[Path]) -> AgentPassport:
36
+ passport_file = _governance_dir(governance_dir) / agent / "passport.yaml"
37
+ if not passport_file.exists():
38
+ raise FileNotFoundError(
39
+ f"Passport not found: {passport_file}\nRun: iris register --name {agent}"
40
+ )
41
+ return AgentPassport.from_yaml(passport_file.read_text())
42
+
43
+
44
+ def _last_reviewed_iso(passport: AgentPassport) -> Optional[str]:
45
+ if passport.last_reviewed_at:
46
+ return passport.last_reviewed_at.isoformat()
47
+ return None
48
+
49
+
50
+ def _open_vault(agent: str, vault_dir: Optional[Path]) -> EvidenceVault:
51
+ return EvidenceVault(agent_id=agent, vault_dir=vault_dir)
52
+
53
+
54
+ def _compliance_status(passport: AgentPassport) -> List[dict]:
55
+ """Evaluate Colorado AI Act rules against passport fields."""
56
+ rules = get_colorado_rules()["rules"]
57
+ statuses: List[dict] = []
58
+
59
+ checks = {
60
+ "CO-001": passport.is_high_risk_ai and bool(passport.agent_id),
61
+ "CO-002": bool(passport.evidence_vault_id),
62
+ "CO-003": bool(passport.intent_ref),
63
+ "CO-004": bool(passport.intent_ref),
64
+ "CO-005": None,
65
+ "CO-006": bool(passport.last_reviewed_at),
66
+ }
67
+
68
+ for rule in rules:
69
+ rule_id = rule["rule_id"]
70
+ if rule.get("phase") == 2:
71
+ statuses.append(
72
+ {
73
+ "rule_id": rule_id,
74
+ "name": rule["name"],
75
+ "status": "PENDING",
76
+ "detail": "Phase 2",
77
+ }
78
+ )
79
+ continue
80
+
81
+ passed = checks.get(rule_id)
82
+ if passed is True:
83
+ status = "PASS"
84
+ elif passed is False:
85
+ status = "FAIL"
86
+ else:
87
+ status = "PENDING"
88
+
89
+ statuses.append(
90
+ {"rule_id": rule_id, "name": rule["name"], "status": status, "detail": ""}
91
+ )
92
+
93
+ return statuses
94
+
95
+
96
+ def _annual_review_block(passport: AgentPassport, summary: VaultSummary) -> dict:
97
+ last_reviewed = _last_reviewed_iso(passport)
98
+ if not last_reviewed:
99
+ return {
100
+ "last_reviewed": None,
101
+ "next_review_due": None,
102
+ "days_until": None,
103
+ "status": "OVERDUE",
104
+ "status_label": "OVERDUE — no review on record",
105
+ }
106
+
107
+ reviewed_dt = passport.last_reviewed_at
108
+ next_due = reviewed_dt + timedelta(days=365)
109
+ days_until = summary.days_until_annual_review
110
+
111
+ if days_until is not None and days_until < 0:
112
+ status = "OVERDUE"
113
+ status_label = f"OVERDUE by {abs(days_until)} days"
114
+ elif days_until is not None and days_until <= ANNUAL_REVIEW_WARNING_DAYS:
115
+ status = "DUE SOON"
116
+ status_label = f"Due in {days_until} days"
117
+ else:
118
+ status = "CURRENT"
119
+ status_label = "Current"
120
+
121
+ return {
122
+ "last_reviewed": reviewed_dt.strftime("%Y-%m-%d"),
123
+ "next_review_due": next_due.strftime("%Y-%m-%d"),
124
+ "days_until": days_until,
125
+ "status": status,
126
+ "status_label": status_label,
127
+ }
128
+
129
+
130
+ def _violation_trend(events: List[dict]) -> List[dict]:
131
+ """Group violation counts by month for trend reporting."""
132
+ by_month: Counter[str] = Counter()
133
+ for event in events:
134
+ ts = event.get("timestamp", "")[:7]
135
+ if not ts:
136
+ continue
137
+ count = len(event.get("violations", []))
138
+ if count:
139
+ by_month[ts] += count
140
+ return [{"month": month, "violations": count} for month, count in sorted(by_month.items())]
141
+
142
+
143
+ def _top_violations(violations_by_rule: Dict[str, int], limit: int = 5) -> List[dict]:
144
+ rule_names = {r["rule_id"]: r["name"] for r in get_colorado_rules()["rules"]}
145
+ rule_names.update(
146
+ {
147
+ "IRIS-TOOL-001": "Tool not in declared permissions",
148
+ "IRIS-XR-001": "Cross-region transfer attempted",
149
+ "IRIS-ENV-001": "Environment not authorized",
150
+ }
151
+ )
152
+ ranked = sorted(violations_by_rule.items(), key=lambda x: x[1], reverse=True)
153
+ return [
154
+ {
155
+ "rule_id": rule_id,
156
+ "name": rule_names.get(rule_id, rule_id),
157
+ "count": count,
158
+ }
159
+ for rule_id, count in ranked[:limit]
160
+ ]
161
+
162
+
163
+ def build_report_data(
164
+ agent: str,
165
+ passport: AgentPassport,
166
+ vault: EvidenceVault,
167
+ since: Optional[str] = None,
168
+ ) -> dict:
169
+ last_reviewed = _last_reviewed_iso(passport)
170
+ summary = vault.get_summary(last_reviewed_at=last_reviewed)
171
+ events = vault.get_events(limit=10_000, since=since)
172
+ assessments = vault.get_assessments()
173
+ integrity = vault.check_integrity(passport.evidence_vault_id)
174
+
175
+ xr_events = [
176
+ e
177
+ for e in events
178
+ if any(v.get("rule_id") == "IRIS-XR-001" for v in e.get("violations", []))
179
+ ]
180
+ hitl_events = [e for e in events if e.get("decision") == "HITL"]
181
+
182
+ retention_warning = vault.get_retention_warning()
183
+ oldest_age_days = None
184
+ if summary.retention_days_remaining < EvidenceVault.FREE_RETENTION_DAYS:
185
+ oldest_age_days = (
186
+ EvidenceVault.FREE_RETENTION_DAYS - summary.retention_days_remaining
187
+ )
188
+
189
+ return {
190
+ "agent": agent,
191
+ "generated_at": datetime.utcnow().strftime("%Y-%m-%d"),
192
+ "period": since or f"last {EvidenceVault.FREE_RETENTION_DAYS} days",
193
+ "identity": {
194
+ "owner": passport.owner,
195
+ "team": passport.team,
196
+ "is_high_risk_ai": passport.is_high_risk_ai,
197
+ "frameworks": [t.value for t in passport.compliance_tags],
198
+ "assessment_id": passport.evidence_vault_id,
199
+ },
200
+ "compliance": _compliance_status(passport),
201
+ "statistics": {
202
+ "total_evaluations": summary.total_evaluations,
203
+ "pass_rate": summary.compliance_pass_rate,
204
+ "total_violations": summary.total_violations,
205
+ "violations_by_severity": summary.violations_by_severity,
206
+ "environments_active": summary.environments_active,
207
+ },
208
+ "top_violations": _top_violations(summary.violations_by_rule),
209
+ "violation_trend": _violation_trend(events),
210
+ "assessments": assessments,
211
+ "cross_region_events": xr_events,
212
+ "hitl_events": hitl_events,
213
+ "annual_review": _annual_review_block(passport, summary),
214
+ "integrity": integrity,
215
+ "summary": summary,
216
+ "retention": {
217
+ "free_tier_days": EvidenceVault.FREE_RETENTION_DAYS,
218
+ "days_remaining": summary.retention_days_remaining,
219
+ "oldest_event_age_days": oldest_age_days,
220
+ "upgrade_available": summary.upgrade_available,
221
+ "warning": retention_warning,
222
+ },
223
+ }
224
+
225
+
226
+ def format_report_markdown(data: dict) -> str:
227
+ identity = data["identity"]
228
+ stats = data["statistics"]
229
+ review = data["annual_review"]
230
+ lines = [
231
+ f"# Evidence Vault Report: {data['agent']}",
232
+ "",
233
+ f"**Period:** {data['period']} | **Generated:** {data['generated_at']}",
234
+ "",
235
+ "## Agent Identity",
236
+ "",
237
+ f"- **Owner:** {identity['owner']} ({identity['team']})",
238
+ f"- **High-risk AI:** {'Yes' if identity['is_high_risk_ai'] else 'No'}",
239
+ f"- **Framework:** {', '.join(identity['frameworks']) or 'none'}",
240
+ f"- **Assessment ID:** {identity['assessment_id'] or 'none'}",
241
+ "",
242
+ "## Compliance Status",
243
+ "",
244
+ ]
245
+
246
+ for item in data["compliance"]:
247
+ icon = {"PASS": "✓", "FAIL": "✗", "PENDING": "⚠"}.get(item["status"], "?")
248
+ detail = f" ({item['detail']})" if item.get("detail") else ""
249
+ lines.append(
250
+ f"- {icon} **{item['rule_id']}** {item['name']} "
251
+ f"**{item['status']}**{detail}"
252
+ )
253
+
254
+ pass_pct = stats["pass_rate"] * 100
255
+ sev_parts = [
256
+ f"{count} {sev}"
257
+ for sev, count in sorted(stats["violations_by_severity"].items(), reverse=True)
258
+ ]
259
+ sev_text = f" ({', '.join(sev_parts)})" if sev_parts else ""
260
+
261
+ lines.extend(
262
+ [
263
+ "",
264
+ "## Evaluation Statistics",
265
+ "",
266
+ f"- **Total evaluations:** {stats['total_evaluations']}",
267
+ f"- **Pass rate:** {pass_pct:.1f}%",
268
+ f"- **Violations:** {stats['total_violations']}{sev_text}",
269
+ f"- **Environments active:** {', '.join(stats['environments_active']) or 'none'}",
270
+ "",
271
+ "## Top Violations",
272
+ "",
273
+ ]
274
+ )
275
+
276
+ if data["top_violations"]:
277
+ for v in data["top_violations"]:
278
+ lines.append(f"- **{v['rule_id']}** {v['name']} — {v['count']} times")
279
+ else:
280
+ lines.append("- No violations recorded")
281
+
282
+ if data["violation_trend"]:
283
+ lines.extend(["", "## Violation Trend", ""])
284
+ for point in data["violation_trend"]:
285
+ lines.append(f"- {point['month']}: {point['violations']} violations")
286
+
287
+ if data["assessments"]:
288
+ lines.extend(["", "## Impact Assessment History", ""])
289
+ for assessment in data["assessments"]:
290
+ lines.append(
291
+ f"- {assessment.get('timestamp', 'unknown')[:10]} "
292
+ f"**{assessment.get('assessment_id', 'unknown')}** "
293
+ f"risk={assessment.get('risk_level', 'unknown')}"
294
+ )
295
+
296
+ lines.extend(
297
+ [
298
+ "",
299
+ "## Cross-Region Detection",
300
+ "",
301
+ f"- **Blocks recorded:** {len(data['cross_region_events'])}",
302
+ ]
303
+ )
304
+ for event in data["cross_region_events"][:5]:
305
+ lines.append(
306
+ f" - {event.get('timestamp', '')[:19]} "
307
+ f"{event.get('action')} → {event.get('resource')}"
308
+ )
309
+
310
+ lines.extend(
311
+ [
312
+ "",
313
+ "## HITL Gate Events",
314
+ "",
315
+ f"- **Gates triggered:** {len(data['hitl_events'])}",
316
+ ]
317
+ )
318
+ for event in data["hitl_events"][:5]:
319
+ lines.append(
320
+ f" - {event.get('timestamp', '')[:19]} "
321
+ f"{event.get('action')} → {event.get('resource')}"
322
+ )
323
+
324
+ review_status = review["status_label"]
325
+ if review["status"] == "CURRENT":
326
+ review_status = f"✓ {review_status}"
327
+
328
+ retention = data.get("retention", {})
329
+ lines.extend(["", "## Retention", ""])
330
+ if retention.get("oldest_event_age_days") is not None:
331
+ lines.append(
332
+ f"- **Oldest event:** {retention['oldest_event_age_days']} days ago"
333
+ )
334
+ lines.append(
335
+ f"- **Free tier limit:** {retention['free_tier_days']} days │ "
336
+ f"{retention['days_remaining']} days remaining"
337
+ )
338
+ else:
339
+ lines.append(f"- **Free tier limit:** {retention.get('free_tier_days', 30)} days")
340
+ lines.append("- **Oldest event:** none recorded")
341
+ if retention.get("upgrade_available") and retention.get("warning"):
342
+ lines.extend(
343
+ [
344
+ "",
345
+ retention["warning"],
346
+ ]
347
+ )
348
+ elif retention.get("upgrade_available"):
349
+ lines.extend(
350
+ [
351
+ "",
352
+ "Upgrade to IRIS Pro for unlimited retention + PDF export",
353
+ "iris license activate <your-key>",
354
+ ]
355
+ )
356
+
357
+ lines.extend(
358
+ [
359
+ "",
360
+ "## Annual Review",
361
+ "",
362
+ f"- **Last reviewed:** {review['last_reviewed'] or 'never'}",
363
+ f"- **Next review due:** {review['next_review_due'] or 'unknown'}"
364
+ + (
365
+ f" ({review['days_until']} days)"
366
+ if review["days_until"] is not None and review["days_until"] >= 0
367
+ else ""
368
+ ),
369
+ f"- **Status:** {review_status}",
370
+ "",
371
+ "## Evidence Vault Integrity",
372
+ "",
373
+ ]
374
+ )
375
+
376
+ if data["integrity"]["valid"]:
377
+ lines.append("- ✓ All entries consistent")
378
+ else:
379
+ lines.append("- ✗ **Vault corruption detected**")
380
+ for issue in data["integrity"]["issues"]:
381
+ lines.append(f" - {issue}")
382
+
383
+ return "\n".join(lines)
384
+
385
+
386
+ def format_report_json(data: dict) -> str:
387
+ summary: VaultSummary = data["summary"]
388
+ payload = {
389
+ "schema_version": "1.0",
390
+ "agent": data["agent"],
391
+ "generated_at": data["generated_at"],
392
+ "period": data["period"],
393
+ "identity": data["identity"],
394
+ "compliance": data["compliance"],
395
+ "statistics": data["statistics"],
396
+ "top_violations": data["top_violations"],
397
+ "violation_trend": data["violation_trend"],
398
+ "assessments": data["assessments"],
399
+ "cross_region_event_count": len(data["cross_region_events"]),
400
+ "hitl_event_count": len(data["hitl_events"]),
401
+ "annual_review": data["annual_review"],
402
+ "integrity": data["integrity"],
403
+ "summary": {
404
+ "agent_id": summary.agent_id,
405
+ "total_evaluations": summary.total_evaluations,
406
+ "total_violations": summary.total_violations,
407
+ "violations_by_severity": summary.violations_by_severity,
408
+ "violations_by_rule": summary.violations_by_rule,
409
+ "most_violated_rule": summary.most_violated_rule,
410
+ "compliance_pass_rate": summary.compliance_pass_rate,
411
+ "last_assessment_date": summary.last_assessment_date,
412
+ "last_reviewed_at": summary.last_reviewed_at,
413
+ "days_until_annual_review": summary.days_until_annual_review,
414
+ "environments_active": summary.environments_active,
415
+ "cross_region_blocks": summary.cross_region_blocks,
416
+ "hitl_gates_triggered": summary.hitl_gates_triggered,
417
+ "retention_days_remaining": summary.retention_days_remaining,
418
+ "upgrade_available": summary.upgrade_available,
419
+ },
420
+ "retention": data.get("retention"),
421
+ }
422
+ return json.dumps(payload, indent=2)
423
+
424
+
425
+ def _print_report_table(data: dict) -> None:
426
+ agent = data["agent"]
427
+ console.print(
428
+ Panel(
429
+ f"Period: {data['period']} │ Generated: {data['generated_at']}",
430
+ title=f"Evidence Vault Report: {agent}",
431
+ style="blue",
432
+ )
433
+ )
434
+
435
+ identity = data["identity"]
436
+ console.print("\n[bold]AGENT IDENTITY[/bold]")
437
+ console.print(
438
+ f"Owner: {identity['owner']} ({identity['team']})\n"
439
+ f"High-risk AI: {'Yes' if identity['is_high_risk_ai'] else 'No'} │ "
440
+ f"Framework: {', '.join(identity['frameworks']) or 'none'}\n"
441
+ f"Assessment ID: {identity['assessment_id'] or 'none'}"
442
+ )
443
+
444
+ console.print("\n[bold]COMPLIANCE STATUS[/bold]")
445
+ for item in data["compliance"]:
446
+ icon = {"PASS": "[green]✓[/green]", "FAIL": "[red]✗[/red]", "PENDING": "[yellow]⚠[/yellow]"}.get(
447
+ item["status"], "?"
448
+ )
449
+ detail = f" ({item['detail']})" if item.get("detail") else ""
450
+ console.print(
451
+ f"{icon} {item['rule_id']} {item['name']:<30} {item['status']}{detail}"
452
+ )
453
+
454
+ stats = data["statistics"]
455
+ pass_pct = stats["pass_rate"] * 100
456
+ sev_parts = [
457
+ f"{count} {sev}"
458
+ for sev, count in sorted(stats["violations_by_severity"].items(), reverse=True)
459
+ ]
460
+ sev_text = f" ({', '.join(sev_parts)})" if sev_parts else ""
461
+
462
+ console.print("\n[bold]EVALUATION STATISTICS[/bold]")
463
+ console.print(
464
+ f"Total evaluations: {stats['total_evaluations']}\n"
465
+ f"Pass rate: {pass_pct:.1f}%\n"
466
+ f"Violations: {stats['total_violations']}{sev_text}\n"
467
+ f"Environments active: {', '.join(stats['environments_active']) or 'none'}"
468
+ )
469
+
470
+ console.print("\n[bold]TOP VIOLATIONS[/bold]")
471
+ if data["top_violations"]:
472
+ for v in data["top_violations"]:
473
+ console.print(f"{v['rule_id']:<16} {v['name']:<40} {v['count']} times")
474
+ else:
475
+ console.print("[dim]No violations recorded[/dim]")
476
+
477
+ retention = data.get("retention", {})
478
+ console.print("\n[bold]RETENTION[/bold]")
479
+ if retention.get("oldest_event_age_days") is not None:
480
+ console.print(
481
+ f"Oldest event: {retention['oldest_event_age_days']} days ago\n"
482
+ f"Free tier limit: {retention['free_tier_days']} days │ "
483
+ f"{retention['days_remaining']} days remaining"
484
+ )
485
+ else:
486
+ console.print(
487
+ f"Free tier limit: {retention.get('free_tier_days', 30)} days │ "
488
+ "no events recorded"
489
+ )
490
+ if retention.get("upgrade_available"):
491
+ console.print("[dim]─────────────────────────────────────────────────────────[/dim]")
492
+ if retention.get("warning"):
493
+ console.print(f"[yellow]{retention['warning']}[/yellow]")
494
+ else:
495
+ console.print(
496
+ "Upgrade to IRIS Pro for unlimited retention + PDF export\n"
497
+ "iris license activate <your-key>"
498
+ )
499
+
500
+ review = data["annual_review"]
501
+ console.print("\n[bold]ANNUAL REVIEW[/bold]")
502
+ status_style = {
503
+ "CURRENT": "[green]✓ Current[/green]",
504
+ "OVERDUE": "[red]OVERDUE[/red]",
505
+ "DUE SOON": "[yellow]Due soon[/yellow]",
506
+ }.get(review["status"], review["status_label"])
507
+ days_suffix = ""
508
+ if review["days_until"] is not None and review["days_until"] >= 0:
509
+ days_suffix = f" ({review['days_until']} days)"
510
+ console.print(
511
+ f"Last reviewed: {review['last_reviewed'] or 'never'}\n"
512
+ f"Next review due: {review['next_review_due'] or 'unknown'}{days_suffix}\n"
513
+ f"Status: {status_style}"
514
+ )
515
+
516
+ console.print("\n[bold]INTEGRITY CHECK[/bold]")
517
+ if data["integrity"]["valid"]:
518
+ console.print("[green]✓ All vault entries consistent[/green]")
519
+ else:
520
+ console.print("[red]✗ Vault corruption detected[/red]")
521
+ for issue in data["integrity"]["issues"]:
522
+ console.print(f" [red]•[/red] {issue}")
523
+
524
+
525
+ def _events_table(events: List[dict]) -> Table:
526
+ table = Table(title="Evidence Vault Events")
527
+ table.add_column("Timestamp", style="dim", no_wrap=True)
528
+ table.add_column("Action")
529
+ table.add_column("Resource")
530
+ table.add_column("Decision")
531
+ table.add_column("Violation Rule")
532
+ for event in events:
533
+ rules = ", ".join(v.get("rule_id", "") for v in event.get("violations", []))
534
+ table.add_row(
535
+ event.get("timestamp", "")[:19],
536
+ event.get("action", ""),
537
+ event.get("resource", ""),
538
+ event.get("decision", ""),
539
+ rules or "—",
540
+ )
541
+ return table
542
+
543
+
544
+ def _export_csv(vault_data: dict, output_path: Path) -> None:
545
+ fieldnames = [
546
+ "event_id",
547
+ "timestamp",
548
+ "agent_id",
549
+ "action",
550
+ "resource",
551
+ "environment",
552
+ "decision",
553
+ "violation_rules",
554
+ "violation_severities",
555
+ ]
556
+ with open(output_path, "w", newline="") as f:
557
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
558
+ writer.writeheader()
559
+ for event in vault_data["events"]:
560
+ violations = event.get("violations", [])
561
+ writer.writerow(
562
+ {
563
+ "event_id": event.get("event_id"),
564
+ "timestamp": event.get("timestamp"),
565
+ "agent_id": event.get("agent_id"),
566
+ "action": event.get("action"),
567
+ "resource": event.get("resource"),
568
+ "environment": event.get("environment"),
569
+ "decision": event.get("decision"),
570
+ "violation_rules": ";".join(v.get("rule_id", "") for v in violations),
571
+ "violation_severities": ";".join(
572
+ v.get("severity", "") for v in violations
573
+ ),
574
+ }
575
+ )
576
+
577
+
578
+ def aggregate_stats(governance_dir: Path, vault_root: Optional[Path] = None) -> dict:
579
+ """Aggregate evidence stats across all governed agents."""
580
+ agents: List[str] = []
581
+ total_evaluations_week = 0
582
+ rule_counter: Counter[str] = Counter()
583
+ approaching_review: List[dict] = []
584
+ critical_agents: List[dict] = []
585
+ retention_warnings: List[dict] = []
586
+
587
+ week_ago = (datetime.utcnow() - timedelta(days=7)).isoformat()
588
+
589
+ for passport_file in governance_dir.rglob("passport.yaml"):
590
+ try:
591
+ passport = AgentPassport.from_yaml(passport_file.read_text())
592
+ except Exception:
593
+ continue
594
+ agent_name = passport.name or passport_file.parent.name
595
+ agents.append(agent_name)
596
+
597
+ vault = EvidenceVault(agent_id=agent_name, vault_dir=vault_root)
598
+ last_reviewed = _last_reviewed_iso(passport)
599
+ summary = vault.get_summary(last_reviewed_at=last_reviewed)
600
+
601
+ week_events = vault.get_events(limit=10_000, since=week_ago[:10])
602
+ total_evaluations_week += len(week_events)
603
+
604
+ for rule_id, count in summary.violations_by_rule.items():
605
+ rule_counter[rule_id] += count
606
+
607
+ days = summary.days_until_annual_review
608
+ if days is None or days <= ANNUAL_REVIEW_WARNING_DAYS:
609
+ approaching_review.append(
610
+ {
611
+ "agent": agent_name,
612
+ "days_until": days,
613
+ "status": "OVERDUE" if days is None or days < 0 else "DUE SOON",
614
+ }
615
+ )
616
+
617
+ critical = summary.violations_by_severity.get("CRITICAL", 0)
618
+ if critical:
619
+ critical_agents.append({"agent": agent_name, "critical_violations": critical})
620
+
621
+ warning = vault.get_retention_warning()
622
+ if warning:
623
+ retention_warnings.append(
624
+ {
625
+ "agent": agent_name,
626
+ "days_remaining": summary.retention_days_remaining,
627
+ "message": warning,
628
+ }
629
+ )
630
+
631
+ return {
632
+ "total_agents": len(agents),
633
+ "total_evaluations_this_week": total_evaluations_week,
634
+ "top_violated_rules": [
635
+ {"rule_id": rule_id, "count": count}
636
+ for rule_id, count in rule_counter.most_common(3)
637
+ ],
638
+ "retention_warnings": retention_warnings,
639
+ "agents_approaching_review": sorted(
640
+ approaching_review,
641
+ key=lambda x: x["days_until"] if x["days_until"] is not None else -999,
642
+ ),
643
+ "agents_with_critical_violations": critical_agents,
644
+ }
645
+
646
+
647
+ @click.group()
648
+ def evidence():
649
+ """Evidence Vault audit trail commands."""
650
+ pass
651
+
652
+
653
+ @evidence.command("report")
654
+ @click.option("--agent", required=True, help="Agent name")
655
+ @click.option(
656
+ "--format",
657
+ "output_format",
658
+ default="table",
659
+ type=click.Choice(["markdown", "json", "table"]),
660
+ )
661
+ @click.option("--since", default=None, help="Start date (YYYY-MM-DD)")
662
+ @click.option("--dir", "governance_dir", type=Path, default=None)
663
+ @click.option("--vault-dir", type=Path, default=None, help="Override evidence vault root")
664
+ def evidence_report(agent, output_format, since, governance_dir, vault_dir):
665
+ """
666
+ Generate a complete audit report for an agent.
667
+
668
+ Example:
669
+ iris evidence report --agent payment-agent
670
+ iris evidence report --agent payment-agent --format markdown
671
+ """
672
+ try:
673
+ passport = _load_passport(agent, governance_dir)
674
+ vault = _open_vault(agent, vault_dir)
675
+ data = build_report_data(agent, passport, vault, since=since)
676
+ except FileNotFoundError as exc:
677
+ console.print(f"[red]{exc}[/red]")
678
+ raise SystemExit(1)
679
+
680
+ if output_format == "json":
681
+ click.echo(format_report_json(data))
682
+ elif output_format == "markdown":
683
+ click.echo(format_report_markdown(data))
684
+ else:
685
+ _print_report_table(data)
686
+
687
+
688
+ @evidence.command("list")
689
+ @click.option("--agent", required=True, help="Agent name")
690
+ @click.option("--violations-only", is_flag=True, help="Show only events with violations")
691
+ @click.option("--limit", default=50, show_default=True)
692
+ @click.option("--dir", "governance_dir", type=Path, default=None)
693
+ @click.option("--vault-dir", type=Path, default=None)
694
+ def evidence_list(agent, violations_only, limit, governance_dir, vault_dir):
695
+ """
696
+ List recent Evidence Vault events.
697
+
698
+ Example:
699
+ iris evidence list --agent payment-agent
700
+ iris evidence list --agent payment-agent --violations-only
701
+ """
702
+ try:
703
+ _load_passport(agent, governance_dir)
704
+ except FileNotFoundError as exc:
705
+ console.print(f"[red]{exc}[/red]")
706
+ raise SystemExit(1)
707
+
708
+ vault = _open_vault(agent, vault_dir)
709
+ events = vault.get_events(limit=limit)
710
+ if violations_only:
711
+ events = [e for e in events if e.get("violations")]
712
+
713
+ if not events:
714
+ console.print("[yellow]No events found in Evidence Vault.[/yellow]")
715
+ raise SystemExit(0)
716
+
717
+ console.print(_events_table(events))
718
+
719
+
720
+ @evidence.command("export")
721
+ @click.option("--agent", required=True, help="Agent name")
722
+ @click.option("--output", "output_path", required=True, type=Path)
723
+ @click.option("--format", "output_format", default="json", type=click.Choice(["json", "csv"]))
724
+ @click.option("--dir", "governance_dir", type=Path, default=None)
725
+ @click.option("--vault-dir", type=Path, default=None)
726
+ def evidence_export(agent, output_path, output_format, governance_dir, vault_dir):
727
+ """
728
+ Export the full Evidence Vault for external audit tools.
729
+
730
+ Example:
731
+ iris evidence export --agent payment-agent --output audit.json
732
+ iris evidence export --agent payment-agent --output audit.csv --format csv
733
+ """
734
+ try:
735
+ passport = _load_passport(agent, governance_dir)
736
+ except FileNotFoundError as exc:
737
+ console.print(f"[red]{exc}[/red]")
738
+ raise SystemExit(1)
739
+
740
+ vault = _open_vault(agent, vault_dir)
741
+ vault_data = vault.export_vault()
742
+ vault_data["passport"] = {
743
+ "owner": passport.owner,
744
+ "team": passport.team,
745
+ "evidence_vault_id": passport.evidence_vault_id,
746
+ "last_reviewed_at": _last_reviewed_iso(passport),
747
+ }
748
+ vault_data["integrity"] = vault.check_integrity(passport.evidence_vault_id)
749
+
750
+ output_path.parent.mkdir(parents=True, exist_ok=True)
751
+ if output_format == "json":
752
+ output_path.write_text(json.dumps(vault_data, indent=2))
753
+ else:
754
+ _export_csv(vault_data, output_path)
755
+
756
+ console.print(f"[green]✓ Exported to {output_path}[/green]")
757
+
758
+
759
+ @evidence.command("stats")
760
+ @click.option("--dir", "governance_dir", type=Path, default=None)
761
+ @click.option("--vault-dir", type=Path, default=None)
762
+ @click.option(
763
+ "--format",
764
+ "output_format",
765
+ default="table",
766
+ type=click.Choice(["table", "json"]),
767
+ )
768
+ def evidence_stats(governance_dir, vault_dir, output_format):
769
+ """
770
+ Show aggregate Evidence Vault stats across all governed agents.
771
+
772
+ Example:
773
+ iris evidence stats
774
+ """
775
+ gov_dir = _governance_dir(governance_dir)
776
+ if not gov_dir.exists():
777
+ console.print(f"[yellow]Governance directory not found: {gov_dir}[/yellow]")
778
+ raise SystemExit(0)
779
+
780
+ stats = aggregate_stats(gov_dir, vault_root=vault_dir)
781
+
782
+ if output_format == "json":
783
+ click.echo(json.dumps(stats, indent=2))
784
+ return
785
+
786
+ console.print(Panel("[bold]Evidence Vault — Aggregate Stats[/bold]", style="blue"))
787
+ console.print(f"\nTotal agents governed: {stats['total_agents']}")
788
+ console.print(f"Total evaluations this week: {stats['total_evaluations_this_week']}")
789
+
790
+ console.print("\n[bold]Top violated rules[/bold]")
791
+ if stats["top_violated_rules"]:
792
+ for item in stats["top_violated_rules"][:3]:
793
+ console.print(f" {item['rule_id']:<16} {item['count']} times")
794
+ else:
795
+ console.print(" [dim]None[/dim]")
796
+
797
+ console.print("\n[bold]Retention warnings[/bold]")
798
+ if stats.get("retention_warnings"):
799
+ for item in stats["retention_warnings"]:
800
+ console.print(
801
+ f" {item['agent']:<24} {item['days_remaining']} days remaining"
802
+ )
803
+ else:
804
+ console.print(" [dim]None[/dim]")
805
+
806
+ console.print("\n[bold]Agents approaching annual review[/bold]")
807
+ if stats["agents_approaching_review"]:
808
+ for item in stats["agents_approaching_review"]:
809
+ days = item["days_until"]
810
+ days_label = "OVERDUE" if days is None else f"{days} days"
811
+ console.print(f" {item['agent']:<24} {item['status']:<10} {days_label}")
812
+ else:
813
+ console.print(" [dim]None[/dim]")
814
+
815
+ console.print("\n[bold]Agents with open critical violations[/bold]")
816
+ if stats["agents_with_critical_violations"]:
817
+ for item in stats["agents_with_critical_violations"]:
818
+ console.print(
819
+ f" {item['agent']:<24} {item['critical_violations']} critical"
820
+ )
821
+ else:
822
+ console.print(" [dim]None[/dim]")