iris-security-cli 0.1.9__tar.gz → 0.2.1__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 (58) hide show
  1. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/PKG-INFO +3 -3
  2. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/certify.py +49 -0
  3. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/compliance_check_cmd.py +26 -13
  4. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/framework_suggest.py +41 -0
  5. iris_security_cli-0.2.1/iris_cli/hitl.py +303 -0
  6. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/main.py +4 -1
  7. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/PKG-INFO +3 -3
  8. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/SOURCES.txt +2 -0
  9. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/requires.txt +2 -2
  10. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/pyproject.toml +3 -3
  11. iris_security_cli-0.2.1/tests/test_hitl_cli.py +111 -0
  12. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/README.md +0 -0
  13. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/__init__.py +0 -0
  14. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/action_plan.py +0 -0
  15. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/assess.py +0 -0
  16. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/cedar_parser.py +0 -0
  17. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/compiler_config.py +0 -0
  18. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/compliance_fix.py +0 -0
  19. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/cost.py +0 -0
  20. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/declare.py +0 -0
  21. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/delegation.py +0 -0
  22. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/dlp_cmd.py +0 -0
  23. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/drift.py +0 -0
  24. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/enforce.py +0 -0
  25. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/entitlements_cmd.py +0 -0
  26. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/evidence.py +0 -0
  27. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/explain.py +0 -0
  28. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/framework_test.py +0 -0
  29. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/license_cmd.py +0 -0
  30. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/mcp_server.py +0 -0
  31. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/models_cmd.py +0 -0
  32. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/policy_cache.py +0 -0
  33. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/policy_diff.py +0 -0
  34. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/preview.py +0 -0
  35. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/quickstart.py +0 -0
  36. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/redteam.py +0 -0
  37. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/regulatory.py +0 -0
  38. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/scan_govern.py +0 -0
  39. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/scan_report.py +0 -0
  40. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/scm.py +0 -0
  41. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/sentinel.py +0 -0
  42. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/status_cmd.py +0 -0
  43. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/users.py +0 -0
  44. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/vault.py +0 -0
  45. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/watch.py +0 -0
  46. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/witness.py +0 -0
  47. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/dependency_links.txt +0 -0
  48. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/entry_points.txt +0 -0
  49. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/top_level.txt +0 -0
  50. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/setup.cfg +0 -0
  51. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_entitlements_display.py +0 -0
  52. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_evidence.py +0 -0
  53. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_framework_suggest.py +0 -0
  54. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_framework_test.py +0 -0
  55. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_mcp_model_governance.py +0 -0
  56. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_policy_diff.py +0 -0
  57. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_quickstart.py +0 -0
  58. {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_vocabulary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris-security-cli
3
- Version: 0.1.9
3
+ Version: 0.2.1
4
4
  Summary: IRIS CLI — declare, compile, preview, enforce, witness, certify, sentinel
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.7
19
- Requires-Dist: iris-security-sdk>=0.1.9
18
+ Requires-Dist: iris-security-core>=0.1.9
19
+ Requires-Dist: iris-security-sdk>=0.2.1
20
20
  Requires-Dist: click>=8.1
21
21
  Requires-Dist: rich>=13.0
22
22
  Requires-Dist: pyyaml>=6.0
@@ -99,6 +99,55 @@ def certify_cmd(framework: str, agent_name: str, output_format: str) -> None:
99
99
  else:
100
100
  _render_certification_header(framework, agent_name, result)
101
101
  _render_table(result, has_pro)
102
+ _render_response_configuration(framework, passport)
103
+
104
+
105
+ def _render_response_configuration(framework: str, passport: AgentPassport) -> None:
106
+ from iris_core.compliance.framework_check import load_bundle_data
107
+ from iris_core.compliance.violation_response import (
108
+ get_effective_response,
109
+ rule_response_label,
110
+ ViolationResponse,
111
+ )
112
+
113
+ try:
114
+ bundle = load_bundle_data(framework)
115
+ except ValueError:
116
+ return
117
+ rules = bundle.get("rules", [])
118
+ if not rules:
119
+ return
120
+
121
+ console.print("\n[bold]RESPONSE CONFIGURATION[/bold]")
122
+ console.print("─" * 65)
123
+ overrides = passport.compliance_response_overrides or {}
124
+ for rule in rules:
125
+ rule_id = rule.get("rule_id", "")
126
+ default = rule.get("violation_response", "inform").upper()
127
+ effective = rule_response_label(rule, overrides)
128
+ line = f"{rule_id:<12} {effective:<8}"
129
+ if rule.get("violation_response"):
130
+ base = ViolationResponse(rule["violation_response"])
131
+ if base == ViolationResponse.BLOCK:
132
+ line += " (unconditional — cannot be overridden)"
133
+ elif effective != default:
134
+ line += f" (override active)"
135
+ else:
136
+ line += " (default)"
137
+ console.print(line)
138
+
139
+ if overrides:
140
+ console.print("\n[dim]Active overrides in passport.yaml:[/dim]")
141
+ for rule_id, cfg in overrides.items():
142
+ console.print(f" {rule_id}: {cfg.get('response', 'inform')} — {cfg.get('reason', '')}")
143
+ else:
144
+ console.print(
145
+ "\n[dim]Override example (add to passport.yaml if needed):[/dim]\n"
146
+ " compliance_response_overrides:\n"
147
+ " CO-003:\n"
148
+ " response: inform\n"
149
+ " reason: Pre-production — not serving CO consumers yet"
150
+ )
102
151
 
103
152
 
104
153
  # Legacy alias: iris test → iris certify
@@ -8,6 +8,7 @@ from typing import Optional
8
8
 
9
9
  import click
10
10
  from rich.console import Console
11
+ from rich.table import Table
11
12
 
12
13
  from iris import AgentPassport
13
14
  from iris_core.compliance.framework_check import run_framework_check
@@ -66,25 +67,37 @@ def _render_result(
66
67
  status = "[bold green]PASS[/bold green]" if not has_failures else "[bold red]FAIL[/bold red]"
67
68
  console.print(f"\nAgent: [cyan]{passport.name}[/cyan] — {status}")
68
69
 
70
+ if result.rule_results:
71
+ table = Table(title=f"{result.framework_name}", show_header=True, header_style="bold")
72
+ table.add_column("Rule", style="cyan")
73
+ table.add_column("Severity")
74
+ table.add_column("Status")
75
+ table.add_column("Response")
76
+ table.add_column("Notes")
77
+ for rule in result.rule_results:
78
+ status_icon = "[green]✓ PASS[/green]" if rule.status == "PASS" else "[red]✗ FAIL[/red]"
79
+ notes = rule.message if rule.status == "FAIL" else ""
80
+ table.add_row(
81
+ rule.rule_id,
82
+ rule.severity,
83
+ status_icon,
84
+ rule.response,
85
+ notes[:80] + ("..." if len(notes) > 80 else ""),
86
+ )
87
+ console.print(table)
88
+ console.print(
89
+ "\n[dim]RESPONSE KEY: INFORM — log and proceed | "
90
+ "HITL — human review required | BLOCK — unconditional block[/dim]"
91
+ )
92
+
69
93
  if result.violations:
70
94
  for violation in result.violations:
95
+ if any(r.rule_id == violation.rule_id for r in result.rule_results):
96
+ continue
71
97
  console.print(f" [red]✗[/red] [{violation.rule_id}] {violation.message}")
72
98
  if violation.remediation:
73
99
  console.print(f" [yellow]→[/yellow] {violation.remediation}")
74
100
 
75
- shown_ids = {v.rule_id for v in result.violations}
76
- for rule in result.rule_results:
77
- if rule.rule_id in shown_ids:
78
- continue
79
- if rule.status == "PASS":
80
- console.print(f" [green]✓[/green] [{rule.rule_id}] {rule.name}")
81
- elif rule.status == "FAIL":
82
- console.print(f" [red]✗[/red] [{rule.rule_id}] {rule.name}")
83
- if rule.message:
84
- console.print(f" {rule.message}")
85
- if rule.remediation:
86
- console.print(f" [yellow]→[/yellow] {rule.remediation}")
87
-
88
101
  if not result.violations and not any(r.status == "FAIL" for r in result.rule_results):
89
102
  console.print(f" [green]✓[/green] All {framework} rules satisfied")
90
103
 
@@ -267,6 +267,21 @@ def build_recommendations(answers: dict[str, Any]) -> list[Recommendation]:
267
267
  soc2 = q2 == Q2_CHOICES[5] or "SOC 2 (SaaS / enterprise)" in q7
268
268
  gdpr = "Outside the US" in q8 or "Other / multiple states" in q8
269
269
 
270
+ ccpa_admt = (
271
+ "California" in q8
272
+ and q5
273
+ and (
274
+ q1 == Q1_CHOICES[0]
275
+ or q2 in {Q2_CHOICES[2], Q2_CHOICES[3]}
276
+ )
277
+ )
278
+ china_pipl = (
279
+ "Outside the US" in q8
280
+ or "china" in agent_description
281
+ or "asia" in agent_description
282
+ or (q2 == Q2_CHOICES[4] and "Outside the US" in q8)
283
+ )
284
+
270
285
  colorado_reason = (
271
286
  "Your agent makes consequential decisions for Colorado users. "
272
287
  "SB 26-189 applies (replaces SB 24-205, effective Jan. 1, 2027). "
@@ -378,6 +393,32 @@ def build_recommendations(answers: dict[str, Any]) -> list[Recommendation]:
378
393
  ),
379
394
  command="iris test --framework gdpr" if gdpr else None,
380
395
  ),
396
+ Recommendation(
397
+ framework="ccpa-admt",
398
+ tier="PRO",
399
+ status="REQUIRED" if ccpa_admt else "NOT APPLICABLE",
400
+ reason=(
401
+ "California ADMT regulations (effective Jan. 1, 2026) apply to "
402
+ "automated decisions affecting California consumers in employment, "
403
+ "credit, healthcare, housing, or education."
404
+ if ccpa_admt
405
+ else "No California ADMT scope detected."
406
+ ),
407
+ command="iris compliance check --framework ccpa-admt" if ccpa_admt else None,
408
+ ),
409
+ Recommendation(
410
+ framework="china-pipl",
411
+ tier="PRO",
412
+ status="REQUIRED" if china_pipl else "NOT APPLICABLE",
413
+ reason=(
414
+ "China PIPL applies when processing personal information of "
415
+ "individuals located in China, including automated decision-making "
416
+ "and cross-border transfer restrictions."
417
+ if china_pipl
418
+ else "No China or Asia-Pacific user footprint detected."
419
+ ),
420
+ command="iris compliance check --framework china-pipl" if china_pipl else None,
421
+ ),
381
422
  ]
382
423
  return recommendations
383
424
 
@@ -0,0 +1,303 @@
1
+ """iris hitl — human-in-the-loop review commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import click
12
+ import yaml
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from iris import AgentPassport
17
+ from iris_core.hitl.models import HITLConfig, HITLConditionRule, HITLStatus
18
+ from iris_core.hitl.notifier import HITLNotifier
19
+ from iris_core.hitl.queue import HITLQueue
20
+
21
+ console = Console()
22
+
23
+
24
+ def _reviewer() -> str:
25
+ return os.environ.get("IRIS_USER_EMAIL", "local-reviewer")
26
+
27
+
28
+ def _gov_dir() -> Path:
29
+ return Path.cwd() / "governance" / "agents"
30
+
31
+
32
+ def _load_passport(agent: str) -> AgentPassport:
33
+ path = _gov_dir() / agent / "passport.yaml"
34
+ if not path.exists():
35
+ raise click.ClickException(f"No passport found for agent '{agent}' at {path}")
36
+ return AgentPassport.from_yaml(path.read_text())
37
+
38
+
39
+ def _save_hitl_config(agent: str, config: HITLConfig) -> Path:
40
+ path = _gov_dir() / agent / "passport.yaml"
41
+ data = yaml.safe_load(path.read_text())
42
+ data.setdefault("spec", {})["hitl"] = config.to_dict()
43
+ path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
44
+ return path
45
+
46
+
47
+ def _time_remaining(expires_at: str) -> str:
48
+ try:
49
+ expiry = datetime.fromisoformat(expires_at.replace("Z", ""))
50
+ delta = expiry - datetime.utcnow()
51
+ seconds = int(delta.total_seconds())
52
+ if seconds <= 0:
53
+ return "expired"
54
+ minutes, secs = divmod(seconds, 60)
55
+ return f"{minutes}m {secs}s"
56
+ except ValueError:
57
+ return "unknown"
58
+
59
+
60
+ @click.group()
61
+ def hitl():
62
+ """Human-in-the-loop review commands."""
63
+ pass
64
+
65
+
66
+ @hitl.command("list")
67
+ @click.option("--agent", default=None, help="Filter by agent name")
68
+ @click.option("--status", default="pending", type=click.Choice(["pending", "all"]))
69
+ def hitl_list(agent: Optional[str], status: str):
70
+ """List pending HITL reviews."""
71
+ queue = HITLQueue()
72
+ if status == "pending":
73
+ reviews = queue.list_pending(agent_name=agent)
74
+ else:
75
+ reviews = queue.list_all(agent_name=agent)
76
+
77
+ console.print(
78
+ "┌─ IRIS Pending Reviews ────────────────────────────────────────┐"
79
+ )
80
+ console.print(
81
+ f"│ {len(reviews)} review(s) awaiting human decision"
82
+ f"{' ' * max(0, 44 - len(str(len(reviews))))}│"
83
+ )
84
+ console.print(
85
+ "└───────────────────────────────────────────────────────────────┘"
86
+ )
87
+
88
+ if not reviews:
89
+ return
90
+
91
+ table = Table(show_header=True, header_style="bold")
92
+ table.add_column("ID")
93
+ table.add_column("Agent")
94
+ table.add_column("Risk")
95
+ table.add_column("Action")
96
+ table.add_column("Expires")
97
+ for review in reviews:
98
+ table.add_row(
99
+ review.review_id[:14],
100
+ review.agent_name[:20],
101
+ review.risk_level,
102
+ f"{review.tool_name[:12]}-{review.action[:8]}",
103
+ _time_remaining(review.expires_at),
104
+ )
105
+ console.print(table)
106
+
107
+
108
+ @hitl.command("approve")
109
+ @click.argument("review_id")
110
+ @click.option("--note", default=None, help="Approval reason")
111
+ def hitl_approve(review_id: str, note: Optional[str]):
112
+ """Approve a pending review."""
113
+ queue = HITLQueue()
114
+ reviewer = _reviewer()
115
+ review = queue.resolve(review_id, HITLStatus.APPROVED, reviewer, reviewer_note=note)
116
+ console.print(f"[green]✓[/green] Review {review_id} approved by {reviewer}")
117
+ if note:
118
+ console.print(f" Note: \"{note}\"")
119
+ if review.approval_token:
120
+ console.print(f" Approval token: {review.approval_token[:32]}...")
121
+ console.print(" Logged to Evidence Vault.")
122
+ console.print(" The waiting agent call will now proceed.")
123
+
124
+
125
+ @hitl.command("reject")
126
+ @click.argument("review_id")
127
+ @click.option("--reason", default=None, help="Rejection reason")
128
+ def hitl_reject(review_id: str, reason: Optional[str]):
129
+ """Reject a pending review."""
130
+ queue = HITLQueue()
131
+ reviewer = _reviewer()
132
+ queue.resolve(review_id, HITLStatus.REJECTED, reviewer, reviewer_note=reason)
133
+ console.print(f"[red]✗[/red] Review {review_id} rejected by {reviewer}")
134
+ if reason:
135
+ console.print(f" Reason: \"{reason}\"")
136
+ console.print(" The waiting agent call will raise IrisViolationError.")
137
+ console.print(" Logged to Evidence Vault.")
138
+
139
+
140
+ @hitl.command("escalate")
141
+ @click.argument("review_id")
142
+ @click.option("--to", "escalate_to", default=None, help="Escalate to email")
143
+ @click.option("--note", default=None, help="Escalation reason")
144
+ def hitl_escalate(review_id: str, escalate_to: Optional[str], note: Optional[str]):
145
+ """Escalate a review to a senior reviewer."""
146
+ queue = HITLQueue()
147
+ reviewer = _reviewer()
148
+ review = queue.resolve(
149
+ review_id,
150
+ HITLStatus.ESCALATED,
151
+ reviewer,
152
+ reviewer_note=note or f"Escalated to {escalate_to or 'senior reviewer'}",
153
+ )
154
+ console.print(f"[yellow]⬆[/yellow] Review {review_id} escalated by {reviewer}")
155
+ if note:
156
+ console.print(f" Note: \"{note}\"")
157
+
158
+
159
+ @hitl.command("show")
160
+ @click.argument("review_id")
161
+ def hitl_show(review_id: str):
162
+ """Show full detail for a review."""
163
+ queue = HITLQueue()
164
+ review = queue.get(review_id)
165
+ if not review:
166
+ raise click.ClickException(f"Review not found: {review_id}")
167
+ for key, value in review.to_dict().items():
168
+ console.print(f"[bold]{key}:[/bold] {value}")
169
+
170
+
171
+ @hitl.command("config")
172
+ @click.option("--agent", required=True)
173
+ def hitl_config(agent: str):
174
+ """Show HITL configuration for an agent."""
175
+ passport = _load_passport(agent)
176
+ config = passport.hitl_config or HITLConfig()
177
+ console.print(f"HITL for [cyan]{agent}[/cyan]: enabled={config.enabled}")
178
+ console.print(f" Timeout: {config.timeout_seconds}s · Policy: {config.timeout_policy}")
179
+ console.print(f" Condition rules: {len(config.condition_rules)}")
180
+
181
+
182
+ @hitl.command("setup")
183
+ @click.option("--agent", required=True)
184
+ def hitl_setup(agent: str):
185
+ """Interactive wizard to configure HITL for an agent."""
186
+ passport = _load_passport(agent)
187
+ config = passport.hitl_config or HITLConfig()
188
+
189
+ enabled = click.confirm(f"Enable HITL for {agent}?", default=True)
190
+ config.enabled = enabled
191
+ config.timeout_seconds = click.prompt("Timeout if no response (seconds)", default=300, type=int)
192
+ config.timeout_policy = click.prompt(
193
+ "On timeout — deny or approve?",
194
+ default="deny",
195
+ type=click.Choice(["deny", "approve", "escalate"]),
196
+ )
197
+ config.escalation_seconds = click.prompt(
198
+ "Escalate after how many seconds?", default=180, type=int
199
+ )
200
+
201
+ console.print(
202
+ "\nDeclare which specific actions require human review.\n"
203
+ "HITL only fires when one of these rules matches.\n"
204
+ )
205
+ rules: list[HITLConditionRule] = []
206
+ while click.confirm("Add a condition rule?", default=len(rules) == 0):
207
+ condition = click.prompt("Condition (e.g. loan_amount > 50000)")
208
+ reason = click.prompt("Reason shown to reviewer")
209
+ channel = click.prompt(
210
+ "Notification channel",
211
+ default="all",
212
+ type=click.Choice(["slack", "email", "pagerduty", "all"]),
213
+ )
214
+ rules.append(HITLConditionRule(condition=condition, reason=reason, notify_channel=channel))
215
+ config.condition_rules = rules
216
+
217
+ if click.confirm("Configure Slack webhook URL (Pro)?", default=False):
218
+ config.slack_webhook_url = click.prompt("Slack webhook URL", default="")
219
+ if click.confirm("Configure email recipients (Pro)?", default=False):
220
+ emails = click.prompt("Email recipients (comma-separated)", default="")
221
+ config.email_recipients = [e.strip() for e in emails.split(",") if e.strip()]
222
+
223
+ path = _save_hitl_config(agent, config)
224
+ console.print(f"[green]✓[/green] HITL configured for {agent}")
225
+ console.print(f"[green]✓[/green] {len(rules)} condition rule(s) declared")
226
+ console.print("[green]✓[/green] HITL fires ONLY when a declared rule matches")
227
+ console.print(f"[green]✓[/green] Saved to {path}")
228
+
229
+
230
+ @hitl.command("rules")
231
+ @click.option("--agent", required=True)
232
+ def hitl_rules(agent: str):
233
+ """Show what will and will not trigger HITL."""
234
+ passport = _load_passport(agent)
235
+ config = passport.hitl_config or HITLConfig()
236
+ console.print(f"┌─ HITL Rules — {agent} ──────────────────────────┐")
237
+ console.print(
238
+ f"│ Status: {'enabled' if config.enabled else 'disabled'} · "
239
+ f"Timeout: {config.timeout_seconds}s · On timeout: {config.timeout_policy} │"
240
+ )
241
+ console.print("└───────────────────────────────────────────────────────────────┘")
242
+
243
+ if config.condition_rules:
244
+ console.print(f"\nWILL trigger HITL — declared condition rules ({len(config.condition_rules)}):")
245
+ for rule in config.condition_rules:
246
+ console.print(f"\n● {rule.condition}")
247
+ console.print(f" Reason: {rule.reason}")
248
+ console.print(f" Channel: {rule.notify_channel}")
249
+
250
+ cedar_rules = _parse_cedar_hitl_annotations(passport)
251
+ if cedar_rules:
252
+ console.print(f"\nWILL trigger HITL — Cedar annotations ({len(cedar_rules)}):")
253
+ for entry in cedar_rules:
254
+ console.print(f"\n● {entry['summary']}")
255
+ console.print(f" Reason: {entry['reason']}")
256
+ console.print(" Channel: All channels")
257
+
258
+ console.print("\nWILL NOT trigger HITL — everything else:")
259
+ console.print("✗ Calls with no matching condition rule → automatic")
260
+ console.print("✗ Risk level alone → never triggers HITL")
261
+ console.print("✗ Data classification alone → never triggers HITL")
262
+ console.print("\nRun: iris hitl test --agent {agent} to verify.")
263
+
264
+
265
+ def _parse_cedar_hitl_annotations(passport: AgentPassport) -> list[dict]:
266
+ if not passport.policy_ref:
267
+ return []
268
+ path = Path(passport.policy_ref)
269
+ if not path.is_absolute():
270
+ path = Path.cwd() / path
271
+ if not path.exists():
272
+ return []
273
+ text = path.read_text()
274
+ entries = []
275
+ pattern = re.compile(
276
+ r"permit\s*\((.*?)\).*?annotations\s*\{([^}]*)\}",
277
+ re.DOTALL | re.IGNORECASE,
278
+ )
279
+ for idx, match in enumerate(pattern.finditer(text), start=1):
280
+ header, ann = match.group(1), match.group(2)
281
+ if 'hitl_required' not in ann:
282
+ continue
283
+ reason_match = re.search(r'hitl_reason\s*=\s*"([^"]+)"', ann)
284
+ resource_match = re.search(r'resource\s*==\s*iris::API::"([^"]+)"', header)
285
+ resource = resource_match.group(1) if resource_match else "unknown"
286
+ entries.append(
287
+ {
288
+ "summary": f"policy.cedar line ~{idx}: {resource} › call",
289
+ "reason": reason_match.group(1) if reason_match else "Cedar annotation",
290
+ }
291
+ )
292
+ return entries
293
+
294
+
295
+ @hitl.command("test")
296
+ @click.option("--agent", required=True)
297
+ def hitl_test(agent: str):
298
+ """Send a test HITL notification (no real pending review)."""
299
+ passport = _load_passport(agent)
300
+ config = passport.hitl_config or HITLConfig(enabled=True)
301
+ notifier = HITLNotifier()
302
+ notifier.send_test(agent, config)
303
+ console.print(f"[green]✓[/green] Test notification sent for {agent}")
@@ -24,7 +24,7 @@ console = Console()
24
24
 
25
25
 
26
26
  @click.group()
27
- @click.version_option(version="0.1.9", prog_name="iris")
27
+ @click.version_option(version="0.2.1", prog_name="iris")
28
28
  def cli():
29
29
  """
30
30
  IRIS — Policy as Code for AI Agents
@@ -497,6 +497,9 @@ cli.add_command(regulatory)
497
497
  from iris_cli.delegation import delegation
498
498
  cli.add_command(delegation)
499
499
 
500
+ from iris_cli.hitl import hitl
501
+ cli.add_command(hitl)
502
+
500
503
 
501
504
  @cli.command("ping", hidden=True)
502
505
  def ping():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iris-security-cli
3
- Version: 0.1.9
3
+ Version: 0.2.1
4
4
  Summary: IRIS CLI — declare, compile, preview, enforce, witness, certify, sentinel
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.7
19
- Requires-Dist: iris-security-sdk>=0.1.9
18
+ Requires-Dist: iris-security-core>=0.1.9
19
+ Requires-Dist: iris-security-sdk>=0.2.1
20
20
  Requires-Dist: click>=8.1
21
21
  Requires-Dist: rich>=13.0
22
22
  Requires-Dist: pyyaml>=6.0
@@ -19,6 +19,7 @@ iris_cli/evidence.py
19
19
  iris_cli/explain.py
20
20
  iris_cli/framework_suggest.py
21
21
  iris_cli/framework_test.py
22
+ iris_cli/hitl.py
22
23
  iris_cli/license_cmd.py
23
24
  iris_cli/main.py
24
25
  iris_cli/mcp_server.py
@@ -48,6 +49,7 @@ tests/test_entitlements_display.py
48
49
  tests/test_evidence.py
49
50
  tests/test_framework_suggest.py
50
51
  tests/test_framework_test.py
52
+ tests/test_hitl_cli.py
51
53
  tests/test_mcp_model_governance.py
52
54
  tests/test_policy_diff.py
53
55
  tests/test_quickstart.py
@@ -1,5 +1,5 @@
1
- iris-security-core>=0.1.7
2
- iris-security-sdk>=0.1.9
1
+ iris-security-core>=0.1.9
2
+ iris-security-sdk>=0.2.1
3
3
  click>=8.1
4
4
  rich>=13.0
5
5
  pyyaml>=6.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "iris-security-cli"
7
- version = "0.1.9"
7
+ version = "0.2.1"
8
8
  description = "IRIS CLI — declare, compile, preview, enforce, witness, certify, sentinel"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -20,8 +20,8 @@ classifiers = [
20
20
  "Programming Language :: Python :: 3.12",
21
21
  ]
22
22
  dependencies = [
23
- "iris-security-core>=0.1.7",
24
- "iris-security-sdk>=0.1.9",
23
+ "iris-security-core>=0.1.9",
24
+ "iris-security-sdk>=0.2.1",
25
25
  "click>=8.1",
26
26
  "rich>=13.0",
27
27
  "pyyaml>=6.0",
@@ -0,0 +1,111 @@
1
+ """CLI tests for iris hitl commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from click.testing import CliRunner
6
+ from unittest.mock import patch
7
+
8
+ from iris_cli.hitl import hitl
9
+ from iris_core.hitl.models import HITLReview, HITLStatus
10
+ from iris_core.hitl.queue import HITLQueue
11
+
12
+
13
+ def test_iris_hitl_list_shows_pending(tmp_path, monkeypatch):
14
+ queue_path = tmp_path / "queue.jsonl"
15
+ monkeypatch.setattr(HITLQueue, "QUEUE_PATH", queue_path)
16
+ queue = HITLQueue(queue_path=queue_path)
17
+ queue.submit(
18
+ HITLReview(
19
+ review_id="rev_cli_test01",
20
+ agent_name="demo-agent",
21
+ agent_id="a1",
22
+ tool_name="tool",
23
+ action="read",
24
+ triggered_by_rule="test",
25
+ risk_level="HIGH",
26
+ environment="dev",
27
+ created_at="2026-06-15T12:00:00Z",
28
+ expires_at="2026-06-15T12:05:00Z",
29
+ )
30
+ )
31
+ runner = CliRunner()
32
+ result = runner.invoke(hitl, ["list"])
33
+ assert result.exit_code == 0
34
+ assert "rev_cli_test" in result.output or "demo-agent" in result.output
35
+
36
+
37
+ def test_iris_hitl_approve_resolves_review(tmp_path, monkeypatch):
38
+ queue_path = tmp_path / "queue.jsonl"
39
+ monkeypatch.setattr(HITLQueue, "QUEUE_PATH", queue_path)
40
+ queue = HITLQueue(queue_path=queue_path)
41
+ queue.submit(
42
+ HITLReview(
43
+ review_id="rev_approve01",
44
+ agent_name="demo-agent",
45
+ agent_id="a1",
46
+ tool_name="tool",
47
+ action="read",
48
+ triggered_by_rule="test",
49
+ risk_level="HIGH",
50
+ environment="dev",
51
+ created_at="2026-06-15T12:00:00Z",
52
+ expires_at="2026-06-15T12:05:00Z",
53
+ )
54
+ )
55
+ runner = CliRunner()
56
+ with patch("iris_cli.hitl.HITLQueue", return_value=queue):
57
+ result = runner.invoke(hitl, ["approve", "rev_approve01", "--note", "ok"])
58
+ assert result.exit_code == 0
59
+ assert "approved" in result.output.lower()
60
+ assert queue.get("rev_approve01").status == HITLStatus.APPROVED
61
+
62
+
63
+ def test_iris_hitl_reject_resolves_review(tmp_path, monkeypatch):
64
+ queue_path = tmp_path / "queue.jsonl"
65
+ monkeypatch.setattr(HITLQueue, "QUEUE_PATH", queue_path)
66
+ queue = HITLQueue(queue_path=queue_path)
67
+ queue.submit(
68
+ HITLReview(
69
+ review_id="rev_reject01",
70
+ agent_name="demo-agent",
71
+ agent_id="a1",
72
+ tool_name="tool",
73
+ action="read",
74
+ triggered_by_rule="test",
75
+ risk_level="HIGH",
76
+ environment="dev",
77
+ created_at="2026-06-15T12:00:00Z",
78
+ expires_at="2026-06-15T12:05:00Z",
79
+ )
80
+ )
81
+ runner = CliRunner()
82
+ with patch("iris_cli.hitl.HITLQueue", return_value=queue):
83
+ result = runner.invoke(hitl, ["reject", "rev_reject01", "--reason", "no"])
84
+ assert result.exit_code == 0
85
+ assert "rejected" in result.output.lower()
86
+ assert queue.get("rev_reject01").status == HITLStatus.REJECTED
87
+
88
+
89
+ def test_iris_hitl_test_sends_test_notification(monkeypatch, tmp_path):
90
+ gov = tmp_path / "governance" / "agents" / "demo-agent"
91
+ gov.mkdir(parents=True)
92
+ gov.joinpath("passport.yaml").write_text(
93
+ """
94
+ apiVersion: iris.io/v1alpha1
95
+ kind: AgentPassport
96
+ metadata:
97
+ name: demo-agent
98
+ agent_id: agent-1
99
+ spec:
100
+ owner: test@test.com
101
+ hitl:
102
+ enabled: true
103
+ timeout_seconds: 300
104
+ timeout_policy: deny
105
+ """
106
+ )
107
+ monkeypatch.chdir(tmp_path)
108
+ runner = CliRunner()
109
+ result = runner.invoke(hitl, ["test", "--agent", "demo-agent"])
110
+ assert result.exit_code == 0
111
+ assert "Test notification" in result.output