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.
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/PKG-INFO +3 -3
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/certify.py +49 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/compliance_check_cmd.py +26 -13
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/framework_suggest.py +41 -0
- iris_security_cli-0.2.1/iris_cli/hitl.py +303 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/main.py +4 -1
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/PKG-INFO +3 -3
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/SOURCES.txt +2 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/requires.txt +2 -2
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/pyproject.toml +3 -3
- iris_security_cli-0.2.1/tests/test_hitl_cli.py +111 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/README.md +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/__init__.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/action_plan.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/assess.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/cedar_parser.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/compiler_config.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/compliance_fix.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/cost.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/declare.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/delegation.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/dlp_cmd.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/drift.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/enforce.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/entitlements_cmd.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/evidence.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/explain.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/framework_test.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/license_cmd.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/mcp_server.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/models_cmd.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/policy_cache.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/policy_diff.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/preview.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/quickstart.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/redteam.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/regulatory.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/scan_govern.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/scan_report.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/scm.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/sentinel.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/status_cmd.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/users.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/vault.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/watch.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_cli/witness.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/dependency_links.txt +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/entry_points.txt +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/top_level.txt +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/setup.cfg +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_entitlements_display.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_evidence.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_framework_suggest.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_framework_test.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_mcp_model_governance.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_policy_diff.py +0 -0
- {iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/tests/test_quickstart.py +0 -0
- {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
|
|
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.
|
|
19
|
-
Requires-Dist: iris-security-sdk>=0.1
|
|
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
|
|
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
|
|
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.
|
|
19
|
-
Requires-Dist: iris-security-sdk>=0.1
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "iris-security-cli"
|
|
7
|
-
version = "0.1
|
|
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.
|
|
24
|
-
"iris-security-sdk>=0.1
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{iris_security_cli-0.1.9 → iris_security_cli-0.2.1}/iris_security_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|