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/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]")
|