iris-security-cli 0.1.4__tar.gz → 0.1.7__tar.gz

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.
Files changed (43) hide show
  1. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/PKG-INFO +3 -3
  2. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/assess.py +2 -2
  3. iris_security_cli-0.1.7/iris_cli/compliance_check_cmd.py +127 -0
  4. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/drift.py +11 -12
  5. iris_security_cli-0.1.7/iris_cli/entitlements_cmd.py +16 -0
  6. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/framework_test.py +151 -25
  7. iris_security_cli-0.1.7/iris_cli/license_cmd.py +153 -0
  8. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/main.py +14 -150
  9. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/redteam.py +2 -2
  10. iris_security_cli-0.1.7/iris_cli/regulatory.py +365 -0
  11. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/status_cmd.py +3 -0
  12. iris_security_cli-0.1.7/iris_cli/vault.py +219 -0
  13. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_security_cli.egg-info/PKG-INFO +3 -3
  14. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_security_cli.egg-info/SOURCES.txt +6 -0
  15. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_security_cli.egg-info/requires.txt +2 -2
  16. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/pyproject.toml +3 -3
  17. iris_security_cli-0.1.7/tests/test_entitlements_display.py +135 -0
  18. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/tests/test_framework_test.py +1 -1
  19. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/README.md +0 -0
  20. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/__init__.py +0 -0
  21. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/action_plan.py +0 -0
  22. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/cedar_parser.py +0 -0
  23. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/compiler_config.py +0 -0
  24. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/cost.py +0 -0
  25. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/dlp_cmd.py +0 -0
  26. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/evidence.py +0 -0
  27. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/explain.py +0 -0
  28. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/framework_suggest.py +0 -0
  29. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/mcp_server.py +0 -0
  30. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/policy_cache.py +0 -0
  31. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/policy_diff.py +0 -0
  32. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/scan_govern.py +0 -0
  33. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/scan_report.py +0 -0
  34. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/scm.py +0 -0
  35. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/users.py +0 -0
  36. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_cli/watch.py +0 -0
  37. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_security_cli.egg-info/dependency_links.txt +0 -0
  38. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_security_cli.egg-info/entry_points.txt +0 -0
  39. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/iris_security_cli.egg-info/top_level.txt +0 -0
  40. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/setup.cfg +0 -0
  41. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/tests/test_evidence.py +0 -0
  42. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/tests/test_framework_suggest.py +0 -0
  43. {iris_security_cli-0.1.4 → iris_security_cli-0.1.7}/tests/test_policy_diff.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris-security-cli
3
- Version: 0.1.4
3
+ Version: 0.1.7
4
4
  Summary: IRIS CLI — iris scan, iris register, iris policy, iris compliance
5
5
  License: Apache-2.0
6
6
  Project-URL: Homepage, https://github.com/gimartinb/iris-sdk
@@ -15,8 +15,8 @@ Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Requires-Python: >=3.10
17
17
  Description-Content-Type: text/markdown
18
- Requires-Dist: iris-security-core>=0.1.3
19
- Requires-Dist: iris-security-sdk>=0.1.4
18
+ Requires-Dist: iris-security-core>=0.1.6
19
+ Requires-Dist: iris-security-sdk>=0.1.7
20
20
  Requires-Dist: click>=8.1
21
21
  Requires-Dist: rich>=13.0
22
22
  Requires-Dist: pyyaml>=6.0
@@ -21,7 +21,7 @@ from rich.console import Console
21
21
  from rich.panel import Panel
22
22
  from rich.prompt import Prompt, Confirm
23
23
  from rich.table import Table
24
- from rich import print as rprint
24
+ from iris_core import __version__ as IRIS_PLATFORM_VERSION
25
25
 
26
26
  console = Console()
27
27
 
@@ -342,7 +342,7 @@ def generate_assessment_markdown(
342
342
  > **Date**: {now}
343
343
  > **Framework**: Colorado AI Act (SB 26-189), effective January 1, 2027
344
344
  > **Note**: Replaces SB 24-205, signed May 14, 2026
345
- > **Generated by**: IRIS Compliance Platform v0.1.0
345
+ > **Generated by**: IRIS Compliance Platform v{IRIS_PLATFORM_VERSION}
346
346
 
347
347
  ---
348
348
 
@@ -0,0 +1,127 @@
1
+ """iris compliance check — framework compliance with Pro preview."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import click
10
+ from rich.console import Console
11
+
12
+ from iris import AgentPassport
13
+ from iris_core.compliance.framework_check import run_framework_check
14
+ from iris_core.compliance.results import ComplianceCheckStatus
15
+ from iris_core.entitlements.display import build_pro_preview_box
16
+
17
+ console = Console()
18
+
19
+
20
+ def _discover_passports(
21
+ gov_dir: Path,
22
+ agent: Optional[str],
23
+ ) -> list[AgentPassport]:
24
+ passports: list[AgentPassport] = []
25
+ if agent:
26
+ passport_file = gov_dir / agent / "passport.yaml"
27
+ if passport_file.exists():
28
+ passports.append(AgentPassport.from_yaml(passport_file.read_text()))
29
+ return passports
30
+
31
+ for passport_file in gov_dir.rglob("passport.yaml"):
32
+ try:
33
+ passports.append(AgentPassport.from_yaml(passport_file.read_text()))
34
+ except Exception:
35
+ continue
36
+ return passports
37
+
38
+
39
+ @click.command("check")
40
+ @click.option("--agent", default=None, help="Specific agent to check (or all)")
41
+ @click.option("--framework", "-f", default="colorado-ai-act")
42
+ @click.option("--dir", "governance_dir", type=click.Path(path_type=Path), default=None)
43
+ def compliance_check_cmd(
44
+ agent: Optional[str],
45
+ framework: str,
46
+ governance_dir: Optional[Path],
47
+ ) -> None:
48
+ """
49
+ Check an agent against a compliance framework.
50
+
51
+ Shows a detailed breakdown of which rules pass and fail,
52
+ with plain-English remediation guidance for each failure.
53
+
54
+ Example:
55
+ iris compliance check --framework colorado-ai-act
56
+ iris compliance check --agent payment-agent --framework hipaa
57
+ """
58
+ gov_dir = governance_dir or Path.cwd() / "governance" / "agents"
59
+ passports = _discover_passports(gov_dir, agent)
60
+ if not passports:
61
+ console.print("[yellow]No agent passports found.[/yellow]")
62
+ sys.exit(0)
63
+
64
+ all_pass = True
65
+ for passport in passports:
66
+ result = run_framework_check(passport, framework)
67
+
68
+ if result.preview_only:
69
+ preview_lines = [
70
+ f"{rule.rule_id} {rule.severity} {rule.name} [{rule.status}]"
71
+ for rule in result.rule_results
72
+ ]
73
+ console.print(
74
+ build_pro_preview_box(
75
+ framework,
76
+ result.framework_name,
77
+ result.total_controls,
78
+ preview_lines,
79
+ )
80
+ )
81
+ all_pass = False
82
+ continue
83
+
84
+ has_failures = any(rule.status == "FAIL" for rule in result.rule_results) or bool(
85
+ result.violations
86
+ )
87
+ status = "[bold green]PASS[/bold green]" if not has_failures else "[bold red]FAIL[/bold red]"
88
+ console.print(f"\nAgent: [cyan]{passport.name}[/cyan] — {status}")
89
+
90
+ if result.violations:
91
+ all_pass = False
92
+ for violation in result.violations:
93
+ console.print(f" [red]✗[/red] [{violation.rule_id}] {violation.message}")
94
+ if violation.remediation:
95
+ console.print(f" [yellow]→[/yellow] {violation.remediation}")
96
+
97
+ shown_ids = {v.rule_id for v in result.violations}
98
+ for rule in result.rule_results:
99
+ if rule.rule_id in shown_ids:
100
+ continue
101
+ if rule.status == "PASS":
102
+ console.print(f" [green]✓[/green] [{rule.rule_id}] {rule.name}")
103
+ elif rule.status == "FAIL":
104
+ all_pass = False
105
+ console.print(f" [red]✗[/red] [{rule.rule_id}] {rule.name}")
106
+ if rule.message:
107
+ console.print(f" {rule.message}")
108
+ if rule.remediation:
109
+ console.print(f" [yellow]→[/yellow] {rule.remediation}")
110
+
111
+ if not result.violations and not any(r.status == "FAIL" for r in result.rule_results):
112
+ console.print(f" [green]✓[/green] All {framework} rules satisfied")
113
+
114
+ for guidance in result.guidance:
115
+ if guidance.status == ComplianceCheckStatus.GUIDANCE:
116
+ console.print(f" [blue]ℹ[/blue] [{guidance.rule_id}] {guidance.message}")
117
+ if guidance.remediation:
118
+ console.print(f" [yellow]→[/yellow] {guidance.remediation}")
119
+ elif guidance.status == ComplianceCheckStatus.PRO_REQUIRED:
120
+ console.print(f" [yellow]⚠[/yellow] [{guidance.rule_id}] {guidance.message}")
121
+ if guidance.remediation:
122
+ console.print(f" [yellow]→[/yellow] {guidance.remediation}")
123
+
124
+ for note in result.check_notes:
125
+ console.print(f" [dim]ℹ {note}[/dim]")
126
+
127
+ sys.exit(0 if all_pass else 1)
@@ -13,7 +13,7 @@ import click
13
13
  from rich.console import Console
14
14
  from rich.table import Table
15
15
 
16
- from iris_core.compliance.license import IrisLicense
16
+ from iris_core.entitlements import Entitlements, Feature
17
17
  from iris_core.drift.detector import DriftDetector, DriftReport
18
18
  from iris_core.drift.notifier import DriftNotifier, load_alert_config, save_alert_config
19
19
 
@@ -24,15 +24,14 @@ def _gov_dir(governance_dir: Path | None) -> Path:
24
24
  return governance_dir or Path.cwd() / "governance" / "agents"
25
25
 
26
26
 
27
- def _require_pro_license(feature: str) -> None:
28
- result = IrisLicense().check("gdpr")
29
- if not result.valid:
30
- console.print(
31
- f"[red]{feature} requires an IRIS Pro license.[/red]\n"
32
- "Drift watch (terminal alerts) is available on the free tier.\n"
33
- "Run: iris license activate <your-key> | https://iris.ai/pricing"
34
- )
35
- sys.exit(1)
27
+ def _require_pro_license(feature_name: str, feature: Feature) -> None:
28
+ ents = Entitlements()
29
+ if not ents.has(feature):
30
+ try:
31
+ ents.require(feature, context=feature_name)
32
+ except Exception as exc:
33
+ console.print(str(exc))
34
+ sys.exit(1)
36
35
 
37
36
 
38
37
  def _render_check_table(report: DriftReport) -> None:
@@ -249,10 +248,10 @@ def drift_alert_config(slack_webhook: Optional[str], email: Optional[str], webho
249
248
  config = load_alert_config()
250
249
 
251
250
  if slack_webhook:
252
- _require_pro_license("Slack drift alerts")
251
+ _require_pro_license("Slack drift alerts", Feature.DRIFT_SLACK_ALERT)
253
252
  config["slack_webhook"] = slack_webhook
254
253
  if email:
255
- _require_pro_license("Email drift alerts")
254
+ _require_pro_license("Email drift alerts", Feature.DRIFT_EMAIL_ALERT)
256
255
  config["email"] = email
257
256
  if webhook:
258
257
  config["webhook"] = webhook
@@ -0,0 +1,16 @@
1
+ """iris entitlements — transparent feature map by tier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from iris_core.entitlements.display import build_entitlements_panel
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command("entitlements")
14
+ def entitlements_cmd() -> None:
15
+ """Show the complete IRIS feature map — what is available at each tier."""
16
+ console.print(build_entitlements_panel())
@@ -12,9 +12,9 @@ import click
12
12
  from rich.console import Console
13
13
  from rich.panel import Panel
14
14
 
15
- from iris_core.compliance.license import IrisLicense, is_free_bundle
15
+ from iris_core.entitlements import Entitlements, Feature
16
16
  from iris_core.evidence.vault import EvidenceVault
17
- from iris_core.models.passport import AgentPassport
17
+ from iris_core.models.passport import AgentPassport, DataClassification
18
18
 
19
19
  console = Console()
20
20
 
@@ -27,6 +27,17 @@ PRO_BUNDLES = {
27
27
  }
28
28
 
29
29
  SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
30
+ MS25_PARTIAL_MISSING = (
31
+ "Free tier provides 30-day local retention. "
32
+ "NIST AI RMF MS-2.5 recommends ongoing risk tracking. "
33
+ "30 days of history partially satisfies this control."
34
+ )
35
+ MS25_PARTIAL_FIX = (
36
+ "For full satisfaction:\n"
37
+ "iris license activate <key>\n"
38
+ "This enables unlimited Evidence Vault retention so your "
39
+ "complete governance history satisfies MS-2.5."
40
+ )
30
41
  EFFORT_BY_REASON = {
31
42
  "infrastructure": "1 week",
32
43
  "document": "1 hour",
@@ -190,10 +201,79 @@ def _framework_bundle(framework: str) -> Dict[str, Any]:
190
201
  ),
191
202
  ],
192
203
  },
204
+ "hipaa": {
205
+ "bundle_id": "hipaa",
206
+ "full_name": _framework_name("hipaa"),
207
+ "rules": [
208
+ _rule(
209
+ "HIPAA-001",
210
+ "PHI data classification",
211
+ "CRITICAL",
212
+ "passport.data_classification == DataClassification.PHI",
213
+ "PHI data classification missing",
214
+ "Set data_classification=PHI in passport",
215
+ "passport",
216
+ ),
217
+ _rule(
218
+ "HIPAA-002",
219
+ "Evidence Vault PHI audit trail",
220
+ "HIGH",
221
+ "vault.total_evaluations > 0",
222
+ "Evidence Vault not recording PHI",
223
+ "Ensure IRIS client is wrapping LLM calls",
224
+ "process",
225
+ ),
226
+ _rule(
227
+ "HIPAA-003",
228
+ "Region restriction for PHI",
229
+ "HIGH",
230
+ "len(passport.allowed_regions) > 0",
231
+ "No region restriction declared",
232
+ "Add allowed_regions to passport",
233
+ "passport",
234
+ ),
235
+ _rule(
236
+ "HIPAA-004",
237
+ "Minimum necessary tool permissions",
238
+ "HIGH",
239
+ "len(passport.tool_permissions) > 0",
240
+ "No minimum-necessary tool permissions declared",
241
+ "Declare tool_permissions in passport.yaml",
242
+ "passport",
243
+ ),
244
+ _rule(
245
+ "HIPAA-005",
246
+ "Business Associate Agreement evidence",
247
+ "CRITICAL",
248
+ "passport.evidence_vault_id is not None",
249
+ "BAA evidence not linked in Evidence Vault",
250
+ f"Run: iris evidence init --agent <agent>",
251
+ "document",
252
+ ),
253
+ _rule(
254
+ "HIPAA-006",
255
+ "Breach notification readiness",
256
+ "CRITICAL",
257
+ "files['policy_cedar_exists']",
258
+ "Runtime policy enforcement not configured",
259
+ "Run: iris policy compile --agent <agent>",
260
+ "document",
261
+ ),
262
+ _rule(
263
+ "HIPAA-007",
264
+ "De-identification policy",
265
+ "HIGH",
266
+ "passport.intent_ref is not None",
267
+ "De-identification policy not declared",
268
+ "Add policy-intent.md and run iris policy compile",
269
+ "document",
270
+ ),
271
+ ],
272
+ },
193
273
  }
194
274
 
195
275
  # Reuse NIST-style checks for paid frameworks until dedicated bundles are added.
196
- for paid_alias in ("fedramp-moderate", "hipaa", "soc2", "gdpr"):
276
+ for paid_alias in ("fedramp-moderate", "soc2", "gdpr"):
197
277
  alias_bundle = dict(bundles["nist-ai-rmf"])
198
278
  alias_bundle["bundle_id"] = paid_alias
199
279
  alias_bundle["full_name"] = _framework_name(paid_alias)
@@ -210,6 +290,7 @@ def _evaluate_check(check_expr: str, passport: AgentPassport, vault_summary: Any
210
290
  "passport": passport,
211
291
  "vault": vault_summary,
212
292
  "files": files,
293
+ "DataClassification": DataClassification,
213
294
  "bool": bool,
214
295
  "len": len,
215
296
  "all": all,
@@ -284,6 +365,13 @@ def _build_result(framework: str, agent: str, passport: AgentPassport) -> Framew
284
365
 
285
366
  for rule in bundle["rules"]:
286
367
  status = _evaluate_check(rule["check"], passport, summary, files)
368
+ if (
369
+ rule["rule_id"] == "MS-2.5"
370
+ and status == "FAIL"
371
+ and not Entitlements().has(Feature.VAULT_UNLIMITED_RETENTION)
372
+ ):
373
+ status = "PARTIAL"
374
+
287
375
  if status == "PASS":
288
376
  pass_count += 1
289
377
  continue
@@ -296,14 +384,20 @@ def _build_result(framework: str, agent: str, passport: AgentPassport) -> Framew
296
384
  fail_count += 1
297
385
  status = "FAIL"
298
386
 
387
+ what_is_missing = rule["what_is_missing"]
388
+ how_to_fix = rule["how_to_fix"]
389
+ if rule["rule_id"] == "MS-2.5" and status == "PARTIAL":
390
+ what_is_missing = MS25_PARTIAL_MISSING
391
+ how_to_fix = MS25_PARTIAL_FIX
392
+
299
393
  gaps.append(
300
394
  ControlGap(
301
395
  rule_id=rule["rule_id"],
302
396
  name=rule["name"],
303
397
  severity=rule["severity"],
304
398
  status=status,
305
- what_is_missing=rule["what_is_missing"],
306
- how_to_fix=rule["how_to_fix"].replace("<agent>", agent),
399
+ what_is_missing=what_is_missing,
400
+ how_to_fix=how_to_fix.replace("<agent>", agent),
307
401
  estimated_effort=rule["estimated_effort"],
308
402
  )
309
403
  )
@@ -329,12 +423,51 @@ def _build_result(framework: str, agent: str, passport: AgentPassport) -> Framew
329
423
  )
330
424
 
331
425
 
426
+ def _render_free_tier_panel(result: FrameworkTestResult) -> str:
427
+ framework_label = _framework_name(result.framework)
428
+ evaluated = result.passed_controls + result.failed_controls + result.partial_controls
429
+ filled = int((evaluated / max(1, result.total_controls)) * 10)
430
+ bar = f"{'█' * filled}{'░' * (10 - filled)}"
431
+ title = f"{framework_label} Test: {result.agent_name}"
432
+ pad = max(0, 60 - len(title) - 4)
433
+ lines = [
434
+ f"┌─ {title} {'─' * pad}┐",
435
+ "│ Certification Readiness │",
436
+ f"│ {bar} {evaluated} / {result.total_controls} controls evaluated{' ' * max(0, 22 - len(str(evaluated)) - len(str(result.total_controls)))}│",
437
+ "│ Score: requires Pro for full evaluation │",
438
+ "│ │",
439
+ "│ TOP 3 GAPS (free preview) │",
440
+ ]
441
+ for index, gap in enumerate(result.gaps[:3], start=1):
442
+ lines.append(
443
+ f"│ {index}. [{gap.severity}] {gap.rule_id} — {gap.name[:35]:<35} │"
444
+ )
445
+ fix = gap.how_to_fix.split("\n")[0][:48]
446
+ lines.append(f"│ Fix: {fix:<51}│")
447
+ lines.append(f"│ Time: {gap.estimated_effort:<49}│")
448
+ if index < min(3, len(result.gaps)):
449
+ lines.append("│ │")
450
+ total = result.total_controls
451
+ lines.extend(
452
+ [
453
+ "│ │",
454
+ f"│ See all {total} controls + evidence package:{' ' * max(0, 24 - len(str(total)))}│",
455
+ "│ iris license activate <key> │",
456
+ "└─────────────────────────────────────────────────────────────┘",
457
+ ]
458
+ )
459
+ return "\n".join(lines)
460
+
461
+
332
462
  def _render_table(result: FrameworkTestResult, has_pro: bool) -> None:
463
+ if not has_pro:
464
+ console.print(_render_free_tier_panel(result))
465
+ return
466
+
333
467
  framework_label = _framework_name(result.framework)
334
- subtitle = "IRIS Pro active" if has_pro else "IRIS Pro required for full report"
335
468
  console.print(
336
469
  Panel(
337
- f"Framework: {framework_label}\nGenerated: {datetime.utcnow().strftime('%Y-%m-%d')} | {subtitle}",
470
+ f"Framework: {framework_label}\nGenerated: {datetime.utcnow().strftime('%Y-%m-%d')} | IRIS Pro active",
338
471
  title=f"{framework_label} Test — {result.agent_name}",
339
472
  style="blue",
340
473
  )
@@ -351,29 +484,20 @@ def _render_table(result: FrameworkTestResult, has_pro: bool) -> None:
351
484
  f"PASSED ({result.passed_controls}) FAILED ({result.failed_controls}) "
352
485
  f"PARTIAL ({result.partial_controls}) N/A ({result.not_applicable})"
353
486
  )
354
- if has_pro and result.progress_delta_percent is not None:
487
+ if result.progress_delta_percent is not None:
355
488
  delta = result.progress_delta_percent
356
489
  sign = "+" if delta >= 0 else ""
357
490
  console.print(f"Progress since last run: [bold]{sign}{delta}%[/bold]")
358
491
 
359
- gaps = result.gaps if has_pro else result.gaps[:3]
360
- hidden = max(0, len(result.gaps) - len(gaps))
361
- title = "ALL GAPS" if has_pro else f"TOP 3 GAPS (upgrade to IRIS Pro to see all {len(result.gaps)})"
362
- console.print(f"\n[bold]{title}[/bold]")
492
+ console.print("\n[bold]ALL GAPS[/bold]")
363
493
  console.print("─" * 65)
364
- for gap in gaps:
494
+ for gap in result.gaps:
365
495
  console.print(f"[{gap.severity}] {gap.rule_id} — {gap.name}")
496
+ console.print(f"Status: {gap.status}")
366
497
  console.print(f"Missing: {gap.what_is_missing}")
367
498
  console.print(f"Fix: {gap.how_to_fix}")
368
499
  console.print(f"Effort: {gap.estimated_effort}\n")
369
500
 
370
- if hidden and not has_pro:
371
- console.print("─" * 65)
372
- console.print("See all gaps and generate your evidence package:")
373
- console.print("iris license activate <your-key>")
374
- console.print("https://iris.ai/pricing")
375
- console.print("─" * 65)
376
-
377
501
 
378
502
  def _render_markdown(result: FrameworkTestResult, has_pro: bool) -> str:
379
503
  lines = [
@@ -440,9 +564,8 @@ def test(framework: str, agent_name: str, output_format: str, export_format: Opt
440
564
  raise click.ClickException(f"Passport not found: {passport_path}")
441
565
  passport = AgentPassport.from_yaml(passport_path.read_text())
442
566
 
443
- has_pro = is_free_bundle(framework)
444
- if framework in PRO_BUNDLES:
445
- has_pro = IrisLicense().check("gdpr").valid
567
+ ents = Entitlements()
568
+ has_pro = ents.has(Feature.CLI_TEST_FULL_REPORT)
446
569
 
447
570
  result = _build_result(framework, agent_name, passport)
448
571
  previous = _load_previous_score(agent_name, framework)
@@ -452,8 +575,11 @@ def test(framework: str, agent_name: str, output_format: str, export_format: Opt
452
575
  saved_path = _save_result(result)
453
576
 
454
577
  if export_format == "pdf":
455
- if not has_pro:
456
- raise click.ClickException("PDF export requires IRIS Pro. Run: iris license activate <your-key>")
578
+ if not ents.has(Feature.CLI_TEST_PDF_EXPORT):
579
+ try:
580
+ ents.require(Feature.CLI_TEST_PDF_EXPORT, context="PDF export")
581
+ except Exception as exc:
582
+ raise click.ClickException(str(exc)) from exc
457
583
  pdf_path = saved_path.with_suffix(".pdf")
458
584
  pdf_path.write_text("PDF export placeholder. Use IRIS Pro renderer in hosted pipeline.")
459
585
 
@@ -0,0 +1,153 @@
1
+ """iris license — IRIS Pro license management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from iris_core.entitlements import Entitlements, Feature, LicenseKey, Tier
13
+ from iris_core.entitlements.features import FEATURE_TIERS, TIER_RANK
14
+
15
+ console = Console()
16
+
17
+ _STATUS_BOX = """┌─ IRIS License Status ──────────────────────────────────────┐
18
+ │ Tier: {tier:<52}│
19
+ │ Key: {key:<52}│
20
+ │ │
21
+ │ Available now (free forever): │
22
+ │ Colorado AI Act bundle, all LLM integrations, │
23
+ │ Cursor MCP, local Evidence Vault (30 days), │
24
+ │ iris scan, iris status, iris framework suggest │
25
+ │ │
26
+ │ Unlock with Pro: │
27
+ │ NIST AI RMF, FedRAMP, HIPAA, SOC 2, GDPR, EU AI Act │
28
+ │ Unlimited Evidence Vault + cloud sync + PDF export │
29
+ │ K8s sidecar, HITL gate, SCM org scanner │
30
+ │ Team RBAC, SSO, drift alerts, cost anomaly alerts │
31
+ │ │
32
+ │ Activate: iris license activate <your-key> │
33
+ │ Get a key: https://iris.ai/pricing │
34
+ └────────────────────────────────────────────────────────────┘"""
35
+
36
+
37
+ def _mask_key(key: str | None) -> str:
38
+ if not key:
39
+ return "Not activated"
40
+ if len(key) <= 16:
41
+ return key
42
+ return f"{key[:9]}…{key[-4:]}"
43
+
44
+
45
+ def _is_test_key(key: str) -> bool:
46
+ return key.startswith("IRIS-TEST-") or key.startswith("IRIS-DEMO-")
47
+
48
+
49
+ def _features_for_tier(tier: Tier) -> list[Feature]:
50
+ return [
51
+ feature
52
+ for feature, required in FEATURE_TIERS.items()
53
+ if TIER_RANK[tier] >= TIER_RANK[required]
54
+ ]
55
+
56
+
57
+ def _features_exactly_tier(tier: Tier) -> list[Feature]:
58
+ return [feature for feature, required in FEATURE_TIERS.items() if required == tier]
59
+
60
+
61
+ @click.group()
62
+ def license():
63
+ """IRIS Pro license management."""
64
+ pass
65
+
66
+
67
+ @license.command("status")
68
+ def license_status():
69
+ """Show current license status, tier, and what is unlocked."""
70
+ ents = Entitlements()
71
+ key = ents.license_key
72
+ tier_label = ents.tier_name()
73
+ key_label = _mask_key(key)
74
+ if key and not ents.license_valid:
75
+ tier_label = f"{tier_label} (invalid key: {ents.license_reason})"
76
+ key_label = "Invalid"
77
+
78
+ console.print(_STATUS_BOX.format(tier=tier_label, key=key_label))
79
+
80
+
81
+ @license.command("activate")
82
+ @click.argument("key")
83
+ def license_activate(key: str):
84
+ """Validate and save an IRIS license key."""
85
+ key = key.strip()
86
+ valid, tier, reason = LicenseKey.validate(key)
87
+ if not valid:
88
+ console.print(
89
+ "[red]Invalid license key format.[/red]\n"
90
+ "Expected: IRIS-XXXX-XXXX-XXXX-XXXX (uppercase letters and digits)\n"
91
+ "Example: IRIS-P123-ABCD-EFGH-5678\n"
92
+ "Get a key: https://iris.ai/pricing"
93
+ )
94
+ sys.exit(1)
95
+
96
+ LicenseKey.save(key)
97
+ ents = Entitlements()
98
+ test_note = " (test key)" if _is_test_key(key) else ""
99
+ unlocked = _features_for_tier(tier)
100
+ newly = [f.value for f in unlocked if FEATURE_TIERS[f] == tier]
101
+ feature_list = ", ".join(newly[:12])
102
+ if len(newly) > 12:
103
+ feature_list += f", … (+{len(newly) - 12} more)"
104
+
105
+ console.print(
106
+ Panel(
107
+ f"[bold green]✓ License activated{test_note}[/bold green]\n\n"
108
+ f"Tier unlocked: [cyan]{ents.tier_name()}[/cyan]\n"
109
+ f"Saved to: {LicenseKey.license_file_path()}\n\n"
110
+ f"Features unlocked at this tier:\n {feature_list}",
111
+ style="green",
112
+ )
113
+ )
114
+
115
+
116
+ @license.command("deactivate")
117
+ @click.confirmation_option(prompt="Remove your IRIS license key?")
118
+ def license_deactivate():
119
+ """Remove the license key and revert to the free tier."""
120
+ removed = LicenseKey.clear()
121
+ if removed:
122
+ console.print("[yellow]License deactivated.[/yellow] You are now on the Free tier.")
123
+ else:
124
+ console.print("[dim]No license key was stored.[/dim]")
125
+
126
+
127
+ @license.command("features")
128
+ @click.option("--tier", type=click.Choice(["free", "pro", "enterprise"]), default=None)
129
+ def license_features(tier: str | None):
130
+ """List features by tier (comparison table by default)."""
131
+ if tier:
132
+ selected = Tier(tier)
133
+ features = _features_exactly_tier(selected)
134
+ console.print(f"\n[bold]{selected.value.title()} tier features[/bold] ({len(features)})\n")
135
+ for feature in sorted(features, key=lambda f: f.value):
136
+ console.print(f" • {feature.value}")
137
+ return
138
+
139
+ table = Table(title="IRIS Feature Comparison")
140
+ table.add_column("Feature", style="bold")
141
+ table.add_column("Free")
142
+ table.add_column("Pro")
143
+ table.add_column("Enterprise")
144
+
145
+ for feature in sorted(FEATURE_TIERS, key=lambda f: f.value):
146
+ required = FEATURE_TIERS[feature]
147
+ table.add_row(
148
+ feature.value,
149
+ "✓" if TIER_RANK[Tier.FREE] >= TIER_RANK[required] else "—",
150
+ "✓" if TIER_RANK[Tier.PRO] >= TIER_RANK[required] else "—",
151
+ "✓" if TIER_RANK[Tier.ENTERPRISE] >= TIER_RANK[required] else "—",
152
+ )
153
+ console.print(table)