iris-security-cli 0.1.2__tar.gz → 0.1.4__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.2 → iris_security_cli-0.1.4}/PKG-INFO +3 -3
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/assess.py +64 -17
- iris_security_cli-0.1.4/iris_cli/cost.py +418 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/evidence.py +8 -6
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/framework_suggest.py +99 -5
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/main.py +19 -2
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/mcp_server.py +1 -1
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/status_cmd.py +16 -1
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/PKG-INFO +3 -3
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/SOURCES.txt +1 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/requires.txt +2 -2
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/pyproject.toml +3 -3
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/tests/test_framework_suggest.py +1 -1
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/README.md +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/__init__.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/action_plan.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/cedar_parser.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/compiler_config.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/dlp_cmd.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/drift.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/explain.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/framework_test.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/policy_cache.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/policy_diff.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/redteam.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/scan_govern.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/scan_report.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/scm.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/users.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_cli/watch.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/dependency_links.txt +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/entry_points.txt +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/top_level.txt +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/setup.cfg +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/tests/test_evidence.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/tests/test_framework_test.py +0 -0
- {iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/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.
|
|
3
|
+
Version: 0.1.4
|
|
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.
|
|
19
|
-
Requires-Dist: iris-security-sdk>=0.1.
|
|
18
|
+
Requires-Dist: iris-security-core>=0.1.3
|
|
19
|
+
Requires-Dist: iris-security-sdk>=0.1.4
|
|
20
20
|
Requires-Dist: click>=8.1
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
22
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -27,7 +27,7 @@ console = Console()
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
# ── Colorado AI Act impact assessment questionnaire ───────────────────────────
|
|
30
|
-
# Each question maps to a specific obligation under SB
|
|
30
|
+
# Each question maps to a specific obligation under SB 26-189.
|
|
31
31
|
# The answers drive the risk level and the generated assessment document.
|
|
32
32
|
|
|
33
33
|
QUESTIONNAIRE = [
|
|
@@ -44,7 +44,7 @@ QUESTIONNAIRE = [
|
|
|
44
44
|
"insurance",
|
|
45
45
|
"legal services",
|
|
46
46
|
"government",
|
|
47
|
-
"other (not
|
|
47
|
+
"other (not covered ADMT)",
|
|
48
48
|
],
|
|
49
49
|
"type": "choice",
|
|
50
50
|
"high_risk_values": [
|
|
@@ -58,6 +58,27 @@ QUESTIONNAIRE = [
|
|
|
58
58
|
"government",
|
|
59
59
|
],
|
|
60
60
|
},
|
|
61
|
+
{
|
|
62
|
+
"id": "impact_assessment",
|
|
63
|
+
"rule": "CO-002",
|
|
64
|
+
"severity": "MEDIUM",
|
|
65
|
+
"question": (
|
|
66
|
+
"Has an impact assessment been completed for this agent? "
|
|
67
|
+
"(Not legally required under SB 26-189 but recommended as "
|
|
68
|
+
"best practice for NIST AI RMF alignment)"
|
|
69
|
+
),
|
|
70
|
+
"type": "confirm",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "record_retention",
|
|
74
|
+
"rule": "CO-RR-001",
|
|
75
|
+
"severity": "HIGH",
|
|
76
|
+
"question": (
|
|
77
|
+
"Do you have a plan for retaining AI usage records for 3 years? "
|
|
78
|
+
"(Required under SB 26-189 for covered ADMT systems)"
|
|
79
|
+
),
|
|
80
|
+
"type": "confirm",
|
|
81
|
+
},
|
|
61
82
|
{
|
|
62
83
|
"id": "consequential",
|
|
63
84
|
"rule": "CO-004",
|
|
@@ -137,7 +158,8 @@ def run_questionnaire() -> dict:
|
|
|
137
158
|
console.print("\n[bold]Colorado AI Act Impact Assessment[/bold]")
|
|
138
159
|
console.print(
|
|
139
160
|
"[dim]Answer each question honestly. IRIS uses your answers to generate "
|
|
140
|
-
"
|
|
161
|
+
"an impact assessment under SB 26-189 (replaces SB 24-205, effective "
|
|
162
|
+
"Jan. 1, 2027).[/dim]\n"
|
|
141
163
|
)
|
|
142
164
|
|
|
143
165
|
for i, q in enumerate(QUESTIONNAIRE, 1):
|
|
@@ -204,7 +226,28 @@ def calculate_risk_level(answers: dict) -> tuple:
|
|
|
204
226
|
if domain in high_risk_domains:
|
|
205
227
|
risk_score += 3
|
|
206
228
|
findings.append(
|
|
207
|
-
f"Agent operates in '{domain}' — a
|
|
229
|
+
f"Agent operates in '{domain}' — a covered ADMT domain under SB 26-189."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if not answers.get("impact_assessment"):
|
|
233
|
+
risk_score += 1
|
|
234
|
+
findings.append(
|
|
235
|
+
"No impact assessment on file (best practice under SB 26-189, "
|
|
236
|
+
"not legally required)."
|
|
237
|
+
)
|
|
238
|
+
recommendations.append(
|
|
239
|
+
"Complete an impact assessment via iris compliance assess to align "
|
|
240
|
+
"with NIST AI RMF MAP-1.5."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if not answers.get("record_retention"):
|
|
244
|
+
risk_score += 2
|
|
245
|
+
findings.append(
|
|
246
|
+
"No 3-year record retention plan documented for ADMT usage records."
|
|
247
|
+
)
|
|
248
|
+
recommendations.append(
|
|
249
|
+
"Configure Evidence Vault Pro for 3-year retention (CO-RR-001). "
|
|
250
|
+
"Run: iris license activate <your-key>"
|
|
208
251
|
)
|
|
209
252
|
|
|
210
253
|
if answers.get("consequential"):
|
|
@@ -297,7 +340,8 @@ def generate_assessment_markdown(
|
|
|
297
340
|
> **Assessment ID**: `{assessment_id}`
|
|
298
341
|
> **Assessed by**: {assessed_by}
|
|
299
342
|
> **Date**: {now}
|
|
300
|
-
> **Framework**: Colorado AI Act (SB
|
|
343
|
+
> **Framework**: Colorado AI Act (SB 26-189), effective January 1, 2027
|
|
344
|
+
> **Note**: Replaces SB 24-205, signed May 14, 2026
|
|
301
345
|
> **Generated by**: IRIS Compliance Platform v0.1.0
|
|
302
346
|
|
|
303
347
|
---
|
|
@@ -316,6 +360,8 @@ def generate_assessment_markdown(
|
|
|
316
360
|
| Owner | {owner} |
|
|
317
361
|
| Operating domain | {answers.get('domain', 'not specified')} |
|
|
318
362
|
| Makes consequential decisions | {'Yes' if answers.get('consequential') else 'No'} |
|
|
363
|
+
| Impact assessment completed | {'Yes' if answers.get('impact_assessment') else 'No (best practice)'} |
|
|
364
|
+
| 3-year record retention plan | {'Yes' if answers.get('record_retention') else 'No'} |
|
|
319
365
|
| Human review available | {'Yes' if answers.get('human_review') else 'No'} |
|
|
320
366
|
| Consumer opt-out available | {'Yes' if answers.get('opt_out') else 'No'} |
|
|
321
367
|
| Discrimination review completed | {'Yes' if answers.get('discrimination_review') else 'No'} |
|
|
@@ -356,12 +402,12 @@ def generate_assessment_markdown(
|
|
|
356
402
|
|
|
357
403
|
| Rule | Obligation | Status |
|
|
358
404
|
|---|---|---|
|
|
359
|
-
| CO-001 |
|
|
360
|
-
| CO-002 | Impact assessment | ✅ Satisfied
|
|
361
|
-
| CO-003 |
|
|
362
|
-
| CO-004 |
|
|
363
|
-
| CO-
|
|
364
|
-
| CO-
|
|
405
|
+
| CO-001 | ADMT inventory | ✅ Satisfied — agent registered in IRIS |
|
|
406
|
+
| CO-002 | Impact assessment (best practice) | {'✅ Satisfied' if answers.get('impact_assessment') else '⚠️ Recommended'} |
|
|
407
|
+
| CO-003 | Consumer notice / transparency | ✅ Satisfied — policy-intent.md |
|
|
408
|
+
| CO-004 | Post-adverse-action notice | {'✅ Satisfied' if answers.get('opt_out') else '⚠️ Requires action'} |
|
|
409
|
+
| CO-RR-001 | 3-year record retention | {'✅ Satisfied' if answers.get('record_retention') else '⚠️ Requires action'} |
|
|
410
|
+
| CO-DEV-001 | Developer documentation | ✅ Satisfied — policy-intent.md + passport.yaml |
|
|
365
411
|
|
|
366
412
|
---
|
|
367
413
|
|
|
@@ -374,9 +420,10 @@ def generate_assessment_markdown(
|
|
|
374
420
|
|
|
375
421
|
---
|
|
376
422
|
|
|
377
|
-
*This document was generated by IRIS and constitutes
|
|
378
|
-
under Colorado AI Act SB
|
|
379
|
-
the agent's capabilities, data access, or
|
|
423
|
+
*This document was generated by IRIS and constitutes an impact assessment
|
|
424
|
+
under Colorado AI Act SB 26-189 (replaces SB 24-205, effective Jan. 1, 2027).
|
|
425
|
+
It should be reviewed whenever the agent's capabilities, data access, or
|
|
426
|
+
decision scope changes.*
|
|
380
427
|
"""
|
|
381
428
|
|
|
382
429
|
|
|
@@ -428,8 +475,8 @@ def compliance_assess(agent, assessor, governance_dir, yes, answers):
|
|
|
428
475
|
console.print(Panel(
|
|
429
476
|
f"[bold]IRIS Impact Assessment[/bold]\n"
|
|
430
477
|
f"Agent: [cyan]{agent}[/cyan]\n"
|
|
431
|
-
f"Framework: Colorado AI Act (SB
|
|
432
|
-
f"Effective:
|
|
478
|
+
f"Framework: Colorado AI Act (SB 26-189)\n"
|
|
479
|
+
f"Effective: January 1, 2027 (replaces SB 24-205)",
|
|
433
480
|
style="blue"
|
|
434
481
|
))
|
|
435
482
|
|
|
@@ -500,7 +547,7 @@ def compliance_assess(agent, assessor, governance_dir, yes, answers):
|
|
|
500
547
|
f"Files updated:\n"
|
|
501
548
|
f" [dim]{assessment_file}[/dim]\n"
|
|
502
549
|
f" [dim]{passport_file}[/dim]\n\n"
|
|
503
|
-
f"CO-002
|
|
550
|
+
f"CO-002 best practice: [bold green]{'SATISFIED' if questionnaire_answers.get('impact_assessment') else 'RECOMMENDED'}[/bold green]\n\n"
|
|
504
551
|
f"Next step: [bold]iris compliance check --framework colorado-ai-act[/bold]",
|
|
505
552
|
style="green"
|
|
506
553
|
))
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""iris cost — token cost tracking, reporting, and optimization suggestions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import yaml
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from iris_core.cost.pricing import DEFAULT_PRICING, overrides_path, PricingRegistry
|
|
20
|
+
from iris_core.cost.tracker import CostSummary, CostTracker, discover_agent_trackers
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
ALERTS_PATH = Path.home() / ".iris" / "cost-alerts.yaml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _since_from_days(days: int) -> str:
|
|
28
|
+
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _since_from_date(since: Optional[str], days: int) -> str:
|
|
32
|
+
if since:
|
|
33
|
+
return datetime.fromisoformat(since).replace(tzinfo=timezone.utc).isoformat()
|
|
34
|
+
return _since_from_days(days)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _format_usd(amount: float) -> str:
|
|
38
|
+
if amount >= 1:
|
|
39
|
+
return f"${amount:,.2f}"
|
|
40
|
+
return f"${amount:.3f}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _find_tracker(agent_name: str) -> Optional[CostTracker]:
|
|
44
|
+
for tracker in discover_agent_trackers():
|
|
45
|
+
if tracker.agent_name == agent_name or tracker.agent_id == agent_name:
|
|
46
|
+
return tracker
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_trackers(agent: Optional[str]) -> List[CostTracker]:
|
|
51
|
+
trackers = discover_agent_trackers()
|
|
52
|
+
if not agent:
|
|
53
|
+
return trackers
|
|
54
|
+
matched = [t for t in trackers if t.agent_name == agent or t.agent_id == agent]
|
|
55
|
+
if not matched:
|
|
56
|
+
console.print(f"[yellow]No cost data found for agent '{agent}'.[/yellow]")
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
return matched
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _model_call_counts(tracker: CostTracker, since: str) -> Dict[str, int]:
|
|
62
|
+
counts: Dict[str, int] = {}
|
|
63
|
+
for entry in tracker.get_entries(since=since):
|
|
64
|
+
counts[entry.model] = counts.get(entry.model, 0) + 1
|
|
65
|
+
return counts
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_report_table(summary: CostSummary, days: int) -> None:
|
|
69
|
+
generated = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
70
|
+
header = (
|
|
71
|
+
f"┌─ Cost Report: {summary.agent_name} "
|
|
72
|
+
f"{'─' * max(1, 40 - len(summary.agent_name))}┐\n"
|
|
73
|
+
f"│ Period: last {days} days │ Generated: {generated}"
|
|
74
|
+
f"{' ' * max(1, 18 - len(str(days)))}│\n"
|
|
75
|
+
f"└{'─' * 58}┘"
|
|
76
|
+
)
|
|
77
|
+
console.print(header)
|
|
78
|
+
console.print("\n[bold]SUMMARY[/bold]")
|
|
79
|
+
console.print(f"Total spend: {_format_usd(summary.total_cost_usd)}")
|
|
80
|
+
console.print(f"Total calls: {summary.total_calls:,}")
|
|
81
|
+
console.print(f"Avg cost per call: {_format_usd(summary.avg_cost_per_call)}")
|
|
82
|
+
console.print(f"Estimated monthly: {_format_usd(summary.estimated_monthly_cost)}")
|
|
83
|
+
|
|
84
|
+
if summary.cost_by_model:
|
|
85
|
+
console.print("\n[bold]BY MODEL[/bold]")
|
|
86
|
+
tracker = _find_tracker(summary.agent_name)
|
|
87
|
+
since = summary.period_start
|
|
88
|
+
call_counts = _model_call_counts(tracker, since) if tracker else {}
|
|
89
|
+
total = summary.total_cost_usd or 1.0
|
|
90
|
+
for model, cost in sorted(summary.cost_by_model.items(), key=lambda x: -x[1]):
|
|
91
|
+
pct = int(cost / total * 100)
|
|
92
|
+
calls = call_counts.get(model, 0)
|
|
93
|
+
console.print(
|
|
94
|
+
f"{model:<20} {_format_usd(cost):>8} {pct:>3}% ({calls:,} calls)"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if summary.cost_by_tool:
|
|
98
|
+
console.print("\n[bold]BY TOOL[/bold]")
|
|
99
|
+
for tool, cost in sorted(summary.cost_by_tool.items(), key=lambda x: -x[1]):
|
|
100
|
+
entries = [
|
|
101
|
+
e for e in (_find_tracker(summary.agent_name) or CostTracker("", "")).get_entries(since=summary.period_start)
|
|
102
|
+
if e.tool_name == tool
|
|
103
|
+
]
|
|
104
|
+
calls = len(entries) if entries else 0
|
|
105
|
+
per_call = cost / calls if calls else 0.0
|
|
106
|
+
console.print(
|
|
107
|
+
f"{tool:<20} {_format_usd(cost):>8} ({calls:,} calls) "
|
|
108
|
+
f"{_format_usd(per_call)}/call"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if summary.anomalies:
|
|
112
|
+
console.print("\n[bold]ANOMALIES[/bold]")
|
|
113
|
+
for anomaly in summary.anomalies[:10]:
|
|
114
|
+
ts = anomaly.call.timestamp[:16].replace("T", " ")
|
|
115
|
+
console.print(
|
|
116
|
+
f"⚠ {ts} {_format_usd(anomaly.call.cost_usd)} call — "
|
|
117
|
+
f"{anomaly.call.tool_name}()\n"
|
|
118
|
+
f" {anomaly.description}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _render_summary_table(summaries: List[CostSummary], days: int) -> None:
|
|
123
|
+
total_org = sum(s.total_cost_usd for s in summaries)
|
|
124
|
+
header = (
|
|
125
|
+
f"┌─ IRIS Cost Summary — All Agents {'─' * 24}┐\n"
|
|
126
|
+
f"│ Period: last {days} days │ Total org spend: {_format_usd(total_org):<12}│\n"
|
|
127
|
+
f"└{'─' * 58}┘"
|
|
128
|
+
)
|
|
129
|
+
console.print(header)
|
|
130
|
+
console.print("")
|
|
131
|
+
|
|
132
|
+
table = Table(show_header=True, header_style="bold")
|
|
133
|
+
table.add_column("Agent")
|
|
134
|
+
table.add_column("Spend", justify="right")
|
|
135
|
+
table.add_column("Calls", justify="right")
|
|
136
|
+
table.add_column("Avg/call", justify="right")
|
|
137
|
+
table.add_column("Trend")
|
|
138
|
+
|
|
139
|
+
for summary in sorted(summaries, key=lambda s: -s.total_cost_usd):
|
|
140
|
+
trend = summary.cost_trend
|
|
141
|
+
if trend == "INCREASING":
|
|
142
|
+
trend_display = "↑ increasing"
|
|
143
|
+
elif trend == "DECREASING":
|
|
144
|
+
trend_display = "↓ decreasing"
|
|
145
|
+
else:
|
|
146
|
+
trend_display = "→ stable"
|
|
147
|
+
table.add_row(
|
|
148
|
+
summary.agent_name,
|
|
149
|
+
_format_usd(summary.total_cost_usd),
|
|
150
|
+
f"{summary.total_calls:,}",
|
|
151
|
+
_format_usd(summary.avg_cost_per_call),
|
|
152
|
+
trend_display,
|
|
153
|
+
)
|
|
154
|
+
console.print(table)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _suggest_optimizations(summary: CostSummary, since: str) -> List[str]:
|
|
158
|
+
suggestions: List[str] = []
|
|
159
|
+
tracker = _find_tracker(summary.agent_name)
|
|
160
|
+
if not tracker:
|
|
161
|
+
return suggestions
|
|
162
|
+
|
|
163
|
+
downgrade_map = {
|
|
164
|
+
"gpt-4o": "gpt-4o-mini",
|
|
165
|
+
"claude-sonnet-4-6": "claude-haiku-4-5",
|
|
166
|
+
"claude-opus-4-6": "claude-sonnet-4-6",
|
|
167
|
+
"gemini-2.0-pro": "gemini-2.0-flash",
|
|
168
|
+
"gemini-1.5-pro": "gemini-1.5-flash",
|
|
169
|
+
}
|
|
170
|
+
registry = PricingRegistry()
|
|
171
|
+
idx = 1
|
|
172
|
+
total_saving = 0.0
|
|
173
|
+
|
|
174
|
+
for tool, tool_cost in sorted(summary.cost_by_tool.items(), key=lambda x: -x[1]):
|
|
175
|
+
tool_entries = [e for e in tracker.get_entries(since=since) if e.tool_name == tool]
|
|
176
|
+
if not tool_entries:
|
|
177
|
+
continue
|
|
178
|
+
current_model = max(
|
|
179
|
+
{(e.model, e.cost_usd) for e in tool_entries},
|
|
180
|
+
key=lambda x: x[1],
|
|
181
|
+
)[0]
|
|
182
|
+
suggested_model = downgrade_map.get(current_model)
|
|
183
|
+
if not suggested_model:
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
calls = len(tool_entries)
|
|
187
|
+
current_per_call = tool_cost / calls
|
|
188
|
+
provider = tool_entries[0].provider
|
|
189
|
+
avg_input = sum(e.input_tokens for e in tool_entries) // calls
|
|
190
|
+
avg_output = sum(e.output_tokens for e in tool_entries) // calls
|
|
191
|
+
suggested_cost = registry.calculate_cost(provider, suggested_model, avg_input, avg_output)
|
|
192
|
+
suggested_total = suggested_cost * calls
|
|
193
|
+
saving = tool_cost - suggested_total
|
|
194
|
+
if saving <= 0:
|
|
195
|
+
continue
|
|
196
|
+
total_saving += saving
|
|
197
|
+
suggestions.append(
|
|
198
|
+
f"{idx}. Switch {tool} to {suggested_model}\n"
|
|
199
|
+
f" Current: {current_model} at {_format_usd(current_per_call)}/call "
|
|
200
|
+
f"({calls:,} calls = {_format_usd(tool_cost)})\n"
|
|
201
|
+
f" Suggested: {suggested_model} at {_format_usd(suggested_cost)}/call "
|
|
202
|
+
f"({calls:,} calls = {_format_usd(suggested_total)})\n"
|
|
203
|
+
f" Estimated saving: {_format_usd(saving)}/month "
|
|
204
|
+
f"({int(saving / tool_cost * 100)}% reduction)\n"
|
|
205
|
+
f" Risk: Lower capability model. Test accuracy before switching."
|
|
206
|
+
)
|
|
207
|
+
idx += 1
|
|
208
|
+
|
|
209
|
+
large_prompt_entries = [
|
|
210
|
+
e for e in tracker.get_entries(since=since) if e.input_tokens > 10_000
|
|
211
|
+
]
|
|
212
|
+
if large_prompt_entries:
|
|
213
|
+
avg_tokens = int(
|
|
214
|
+
sum(e.input_tokens for e in large_prompt_entries) / len(large_prompt_entries)
|
|
215
|
+
)
|
|
216
|
+
excess_cost = sum(e.cost_usd for e in large_prompt_entries) * 0.3
|
|
217
|
+
total_saving += excess_cost
|
|
218
|
+
suggestions.append(
|
|
219
|
+
f"{idx}. The {min(3, len(large_prompt_entries))} most expensive calls include "
|
|
220
|
+
f"large prompts (avg {avg_tokens:,} tokens). Consider summarizing first.\n"
|
|
221
|
+
f" Estimated saving: {_format_usd(excess_cost)}/month"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if suggestions:
|
|
225
|
+
pct = int(total_saving / max(summary.estimated_monthly_cost, 0.01) * 100)
|
|
226
|
+
suggestions.append(
|
|
227
|
+
f"\nTotal estimated saving: {_format_usd(total_saving)}/month ({pct}% reduction)"
|
|
228
|
+
)
|
|
229
|
+
return suggestions
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_alert_config() -> dict:
|
|
233
|
+
if not ALERTS_PATH.exists():
|
|
234
|
+
return {}
|
|
235
|
+
return yaml.safe_load(ALERTS_PATH.read_text()) or {}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def save_alert_config(config: dict) -> None:
|
|
239
|
+
ALERTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
ALERTS_PATH.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@click.group()
|
|
244
|
+
def cost():
|
|
245
|
+
"""Token cost tracking and reporting across all IRIS integrations."""
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@cost.command("report")
|
|
250
|
+
@click.option("--agent", default=None, help="Agent name (default: all agents)")
|
|
251
|
+
@click.option("--days", default=30, type=int, help="Report period in days")
|
|
252
|
+
@click.option("--format", "output_format", default="table", type=click.Choice(["table", "json", "csv"]))
|
|
253
|
+
@click.option("--since", default=None, help="ISO date string for period start")
|
|
254
|
+
def cost_report(agent: Optional[str], days: int, output_format: str, since: Optional[str]) -> None:
|
|
255
|
+
"""Show a detailed cost report for one or all agents."""
|
|
256
|
+
since_iso = _since_from_date(since, days)
|
|
257
|
+
trackers = _resolve_trackers(agent)
|
|
258
|
+
summaries = [t.get_summary(since=since_iso) for t in trackers]
|
|
259
|
+
|
|
260
|
+
if output_format == "json":
|
|
261
|
+
payload = [
|
|
262
|
+
{
|
|
263
|
+
"agent_name": s.agent_name,
|
|
264
|
+
"total_cost_usd": s.total_cost_usd,
|
|
265
|
+
"total_calls": s.total_calls,
|
|
266
|
+
"avg_cost_per_call": s.avg_cost_per_call,
|
|
267
|
+
"estimated_monthly_cost": s.estimated_monthly_cost,
|
|
268
|
+
"cost_by_model": s.cost_by_model,
|
|
269
|
+
"cost_by_tool": s.cost_by_tool,
|
|
270
|
+
"cost_trend": s.cost_trend,
|
|
271
|
+
}
|
|
272
|
+
for s in summaries
|
|
273
|
+
]
|
|
274
|
+
click.echo(json.dumps(payload, indent=2))
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
if output_format == "csv":
|
|
278
|
+
buffer = io.StringIO()
|
|
279
|
+
writer = csv.writer(buffer)
|
|
280
|
+
writer.writerow(
|
|
281
|
+
["agent", "total_cost_usd", "total_calls", "avg_cost_per_call", "estimated_monthly"]
|
|
282
|
+
)
|
|
283
|
+
for s in summaries:
|
|
284
|
+
writer.writerow(
|
|
285
|
+
[s.agent_name, s.total_cost_usd, s.total_calls, s.avg_cost_per_call, s.estimated_monthly_cost]
|
|
286
|
+
)
|
|
287
|
+
click.echo(buffer.getvalue())
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
for summary in summaries:
|
|
291
|
+
_render_report_table(summary, days)
|
|
292
|
+
if len(summaries) > 1:
|
|
293
|
+
console.print("")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@cost.command("summary")
|
|
297
|
+
@click.option("--days", default=30, type=int, help="Report period in days")
|
|
298
|
+
@click.option("--since", default=None, help="ISO date string for period start")
|
|
299
|
+
def cost_summary(days: int, since: Optional[str]) -> None:
|
|
300
|
+
"""CFO report — cost across all agents sorted by spend."""
|
|
301
|
+
since_iso = _since_from_date(since, days)
|
|
302
|
+
trackers = discover_agent_trackers()
|
|
303
|
+
if not trackers:
|
|
304
|
+
console.print("[yellow]No cost data recorded yet.[/yellow]")
|
|
305
|
+
console.print("Cost tracking starts automatically when governed LLM calls are made.")
|
|
306
|
+
return
|
|
307
|
+
summaries = [t.get_summary(since=since_iso) for t in trackers]
|
|
308
|
+
_render_summary_table(summaries, days)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@cost.command("alert")
|
|
312
|
+
@click.option("--agent", default=None, help="Agent name to monitor")
|
|
313
|
+
@click.option("--threshold", type=float, default=None, help="Alert if single call exceeds USD")
|
|
314
|
+
@click.option("--monthly-budget", type=float, default=None, help="Alert if monthly spend exceeds USD")
|
|
315
|
+
def cost_alert(agent: Optional[str], threshold: Optional[float], monthly_budget: Optional[float]) -> None:
|
|
316
|
+
"""Configure cost alerts (terminal delivery on free tier)."""
|
|
317
|
+
config = load_alert_config()
|
|
318
|
+
if agent:
|
|
319
|
+
agents = config.setdefault("agents", {})
|
|
320
|
+
agent_cfg = agents.setdefault(agent, {})
|
|
321
|
+
if threshold is not None:
|
|
322
|
+
agent_cfg["single_call_threshold_usd"] = threshold
|
|
323
|
+
if monthly_budget is not None:
|
|
324
|
+
agent_cfg["monthly_budget_usd"] = monthly_budget
|
|
325
|
+
save_alert_config(config)
|
|
326
|
+
console.print(f"[green]✓ Alert config saved for {agent}[/green]")
|
|
327
|
+
console.print(f"Config: {ALERTS_PATH}")
|
|
328
|
+
|
|
329
|
+
config = load_alert_config()
|
|
330
|
+
if not config.get("agents"):
|
|
331
|
+
console.print("[yellow]No alert rules configured.[/yellow]")
|
|
332
|
+
console.print("Example: iris cost alert --agent my-agent --threshold 1.00 --monthly-budget 50")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
since_iso = _since_from_days(30)
|
|
336
|
+
for agent_name, rules in config.get("agents", {}).items():
|
|
337
|
+
tracker = _find_tracker(agent_name)
|
|
338
|
+
if not tracker:
|
|
339
|
+
continue
|
|
340
|
+
summary = tracker.get_summary(since=since_iso)
|
|
341
|
+
if rules.get("monthly_budget_usd") and summary.estimated_monthly_cost > rules["monthly_budget_usd"]:
|
|
342
|
+
console.print(
|
|
343
|
+
f"[red]⚠ BUDGET ALERT[/red] {agent_name}: "
|
|
344
|
+
f"estimated monthly {_format_usd(summary.estimated_monthly_cost)} "
|
|
345
|
+
f"exceeds budget {_format_usd(rules['monthly_budget_usd'])}"
|
|
346
|
+
)
|
|
347
|
+
call_threshold = rules.get("single_call_threshold_usd")
|
|
348
|
+
if call_threshold:
|
|
349
|
+
for entry in tracker.get_entries(since=since_iso):
|
|
350
|
+
if entry.cost_usd > call_threshold:
|
|
351
|
+
console.print(
|
|
352
|
+
f"[red]⚠ CALL ALERT[/red] {agent_name}: "
|
|
353
|
+
f"{entry.tool_name}() cost {_format_usd(entry.cost_usd)} "
|
|
354
|
+
f"at {entry.timestamp[:16]}"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@cost.command("optimize")
|
|
359
|
+
@click.option("--agent", required=True, help="Agent name to analyze")
|
|
360
|
+
@click.option("--days", default=30, type=int, help="Analysis period in days")
|
|
361
|
+
def cost_optimize(agent: str, days: int) -> None:
|
|
362
|
+
"""Suggest cost optimizations without modifying any code or config."""
|
|
363
|
+
since_iso = _since_from_days(days)
|
|
364
|
+
tracker = _find_tracker(agent)
|
|
365
|
+
if not tracker:
|
|
366
|
+
console.print(f"[yellow]No cost data found for agent '{agent}'.[/yellow]")
|
|
367
|
+
sys.exit(1)
|
|
368
|
+
|
|
369
|
+
summary = tracker.get_summary(since=since_iso)
|
|
370
|
+
console.print(f"\n[bold]IRIS Cost Optimization — {summary.agent_name}[/bold]\n")
|
|
371
|
+
suggestions = _suggest_optimizations(summary, since_iso)
|
|
372
|
+
if not suggestions:
|
|
373
|
+
console.print("[green]No optimization opportunities identified.[/green]")
|
|
374
|
+
return
|
|
375
|
+
for block in suggestions:
|
|
376
|
+
console.print(block)
|
|
377
|
+
console.print("")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@cost.command("pricing")
|
|
381
|
+
@click.option("--provider", default=None, help="Filter pricing by provider")
|
|
382
|
+
@click.option("--update", is_flag=True, help="Show pricing override instructions")
|
|
383
|
+
def cost_pricing(provider: Optional[str], update: bool) -> None:
|
|
384
|
+
"""Show current LLM pricing table and custom override options."""
|
|
385
|
+
registry = PricingRegistry()
|
|
386
|
+
pricing = registry.all_pricing()
|
|
387
|
+
|
|
388
|
+
if update:
|
|
389
|
+
console.print(
|
|
390
|
+
Panel(
|
|
391
|
+
"Custom pricing overrides are stored in:\n"
|
|
392
|
+
f" {overrides_path()}\n\n"
|
|
393
|
+
"Format:\n"
|
|
394
|
+
" pricing:\n"
|
|
395
|
+
' "openai/gpt-4o":\n'
|
|
396
|
+
" input_per_1m: 2.50\n"
|
|
397
|
+
" output_per_1m: 10.00\n\n"
|
|
398
|
+
"Overrides always take precedence over the built-in table.",
|
|
399
|
+
title="Pricing Overrides",
|
|
400
|
+
style="blue",
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
table = Table(title="IRIS LLM Pricing (per 1M tokens)", show_header=True, header_style="bold")
|
|
406
|
+
table.add_column("Model")
|
|
407
|
+
table.add_column("Input", justify="right")
|
|
408
|
+
table.add_column("Output", justify="right")
|
|
409
|
+
|
|
410
|
+
combined = {**DEFAULT_PRICING, **pricing}
|
|
411
|
+
for model_key in sorted(combined):
|
|
412
|
+
if provider and not model_key.startswith(f"{provider.lower()}/"):
|
|
413
|
+
continue
|
|
414
|
+
input_price, output_price = combined[model_key]
|
|
415
|
+
table.add_row(model_key, f"${input_price:.3f}", f"${output_price:.3f}")
|
|
416
|
+
|
|
417
|
+
console.print(table)
|
|
418
|
+
console.print(f"\n[dim]Overrides: {overrides_path()}[/dim]")
|
|
@@ -57,23 +57,25 @@ def _compliance_status(passport: AgentPassport) -> List[dict]:
|
|
|
57
57
|
statuses: List[dict] = []
|
|
58
58
|
|
|
59
59
|
checks = {
|
|
60
|
-
"CO-001": passport.
|
|
60
|
+
"CO-001": bool(passport.agent_id) and bool(passport.owner),
|
|
61
61
|
"CO-002": bool(passport.evidence_vault_id),
|
|
62
62
|
"CO-003": bool(passport.intent_ref),
|
|
63
63
|
"CO-004": bool(passport.intent_ref),
|
|
64
|
-
"CO-
|
|
65
|
-
"CO-
|
|
64
|
+
"CO-RR-001": None,
|
|
65
|
+
"CO-DEV-001": bool(passport.intent_ref) and bool(passport.description),
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
for rule in rules:
|
|
69
69
|
rule_id = rule["rule_id"]
|
|
70
|
-
if rule.get("
|
|
70
|
+
if rule.get("status") == "BEST_PRACTICE":
|
|
71
|
+
passed = checks.get(rule_id)
|
|
72
|
+
status = "PASS" if passed else "RECOMMENDED"
|
|
71
73
|
statuses.append(
|
|
72
74
|
{
|
|
73
75
|
"rule_id": rule_id,
|
|
74
76
|
"name": rule["name"],
|
|
75
|
-
"status":
|
|
76
|
-
"detail": "
|
|
77
|
+
"status": status,
|
|
78
|
+
"detail": "Best practice (not legally required under SB 26-189)",
|
|
77
79
|
}
|
|
78
80
|
)
|
|
79
81
|
continue
|
|
@@ -17,7 +17,32 @@ from rich.table import Table
|
|
|
17
17
|
console = Console()
|
|
18
18
|
|
|
19
19
|
MAJOR_CLOUDS = {"Google Cloud (GCP)", "AWS", "Microsoft Azure", "Multiple clouds"}
|
|
20
|
-
FREE_FRAMEWORKS = {
|
|
20
|
+
FREE_FRAMEWORKS = {
|
|
21
|
+
"colorado-ai-act",
|
|
22
|
+
"colorado-chatbot",
|
|
23
|
+
"colorado-health-ai",
|
|
24
|
+
"colorado-mental-health-ai",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
COLORADO_AI_ACT_NOTE = (
|
|
28
|
+
"Note: Colorado SB 26-189 replaced the original Colorado AI Act on May 14, 2026. "
|
|
29
|
+
"Key changes: impact assessments are no longer legally required (retained as best "
|
|
30
|
+
"practice). Record retention for 3 years is now required. Effective January 1, 2027."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
MENTAL_HEALTH_KEYWORDS = (
|
|
34
|
+
"therapy",
|
|
35
|
+
"counseling",
|
|
36
|
+
"mental health",
|
|
37
|
+
"psychotherapy",
|
|
38
|
+
"clinician",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
CONVERSATIONAL_KEYWORDS = (
|
|
42
|
+
"conversational ai",
|
|
43
|
+
"chatbot",
|
|
44
|
+
"chat bot",
|
|
45
|
+
)
|
|
21
46
|
|
|
22
47
|
Q1_CHOICES = [
|
|
23
48
|
"Makes decisions that affect individual people (hiring, loans, medical, etc.)",
|
|
@@ -119,6 +144,10 @@ def _prefill_answers(passport: dict[str, Any] | None) -> dict[str, Any]:
|
|
|
119
144
|
prefill["q6"] = True
|
|
120
145
|
prefill["q2"] = Q2_CHOICES[0]
|
|
121
146
|
|
|
147
|
+
description = str(spec.get("description", "")).strip()
|
|
148
|
+
if description:
|
|
149
|
+
prefill["agent_description"] = description
|
|
150
|
+
|
|
122
151
|
return prefill
|
|
123
152
|
|
|
124
153
|
|
|
@@ -205,11 +234,30 @@ def build_recommendations(answers: dict[str, Any]) -> list[Recommendation]:
|
|
|
205
234
|
q6 = bool(answers.get("q6"))
|
|
206
235
|
q7 = set(answers.get("q7", []))
|
|
207
236
|
q8 = set(answers.get("q8", []))
|
|
237
|
+
agent_description = str(answers.get("agent_description", "")).lower()
|
|
208
238
|
|
|
209
239
|
colorado = (
|
|
210
240
|
(q5 and "Colorado" in q8)
|
|
211
241
|
or (q1 == Q1_CHOICES[0] and "Colorado" in q8)
|
|
212
242
|
)
|
|
243
|
+
conversational = (
|
|
244
|
+
any(kw in q1.lower() for kw in CONVERSATIONAL_KEYWORDS)
|
|
245
|
+
or any(kw in agent_description for kw in CONVERSATIONAL_KEYWORDS)
|
|
246
|
+
or q1 == Q1_CHOICES[3]
|
|
247
|
+
)
|
|
248
|
+
consumer_facing = q2 in {Q2_CHOICES[4], Q2_CHOICES[2]} or "minor" in agent_description
|
|
249
|
+
chatbot = conversational and consumer_facing and "Colorado" in q8
|
|
250
|
+
|
|
251
|
+
health_data = q4 == Q4_CHOICES[0] or "health" in str(q4).lower() or "medical" in str(q4).lower()
|
|
252
|
+
health_insurance = (
|
|
253
|
+
"HIPAA (healthcare)" in q7
|
|
254
|
+
or q2 == Q2_CHOICES[3]
|
|
255
|
+
or "health insurance" in agent_description
|
|
256
|
+
)
|
|
257
|
+
health_ai = (health_data or health_insurance) and "Colorado" in q8
|
|
258
|
+
|
|
259
|
+
mental_health = any(kw in agent_description for kw in MENTAL_HEALTH_KEYWORDS)
|
|
260
|
+
|
|
213
261
|
nist = q6 or "FedRAMP (federal government contracts)" in q7 or q2 == Q2_CHOICES[0]
|
|
214
262
|
fedramp = (
|
|
215
263
|
(q6 or "FedRAMP (federal government contracts)" in q7 or q2 in {Q2_CHOICES[0], Q2_CHOICES[1]})
|
|
@@ -219,17 +267,61 @@ def build_recommendations(answers: dict[str, Any]) -> list[Recommendation]:
|
|
|
219
267
|
soc2 = q2 == Q2_CHOICES[5] or "SOC 2 (SaaS / enterprise)" in q7
|
|
220
268
|
gdpr = "Outside the US" in q8 or "Other / multiple states" in q8
|
|
221
269
|
|
|
270
|
+
colorado_reason = (
|
|
271
|
+
"Your agent makes consequential decisions for Colorado users. "
|
|
272
|
+
"SB 26-189 applies (replaces SB 24-205, effective Jan. 1, 2027). "
|
|
273
|
+
f"{COLORADO_AI_ACT_NOTE}"
|
|
274
|
+
if colorado
|
|
275
|
+
else "No Colorado consequential decision scope detected."
|
|
276
|
+
)
|
|
277
|
+
|
|
222
278
|
recommendations = [
|
|
223
279
|
Recommendation(
|
|
224
280
|
framework="colorado-ai-act",
|
|
225
281
|
tier="FREE",
|
|
226
282
|
status="REQUIRED" if colorado else "NOT APPLICABLE",
|
|
283
|
+
reason=colorado_reason,
|
|
284
|
+
command="iris compliance check --framework colorado-ai-act" if colorado else None,
|
|
285
|
+
),
|
|
286
|
+
Recommendation(
|
|
287
|
+
framework="colorado-chatbot",
|
|
288
|
+
tier="FREE",
|
|
289
|
+
status="REQUIRED" if chatbot else "NOT APPLICABLE",
|
|
227
290
|
reason=(
|
|
228
|
-
"
|
|
229
|
-
|
|
230
|
-
|
|
291
|
+
"Conversational AI serving Colorado consumers requires HB 26-1263 "
|
|
292
|
+
"chatbot safety controls (effective Jan. 1, 2027)."
|
|
293
|
+
if chatbot
|
|
294
|
+
else "No conversational AI + consumer audience trigger detected."
|
|
295
|
+
),
|
|
296
|
+
command="iris compliance check --framework colorado-chatbot" if chatbot else None,
|
|
297
|
+
),
|
|
298
|
+
Recommendation(
|
|
299
|
+
framework="colorado-health-ai",
|
|
300
|
+
tier="FREE",
|
|
301
|
+
status="REQUIRED" if health_ai else "NOT APPLICABLE",
|
|
302
|
+
reason=(
|
|
303
|
+
"Health or insurance data handling in Colorado requires HB 26-1139 "
|
|
304
|
+
"AI in health insurance controls (effective Jan. 1, 2027)."
|
|
305
|
+
if health_ai
|
|
306
|
+
else "No Colorado health insurance AI trigger detected."
|
|
307
|
+
),
|
|
308
|
+
command="iris compliance check --framework colorado-health-ai" if health_ai else None,
|
|
309
|
+
),
|
|
310
|
+
Recommendation(
|
|
311
|
+
framework="colorado-mental-health-ai",
|
|
312
|
+
tier="FREE",
|
|
313
|
+
status="REQUIRED" if mental_health else "NOT APPLICABLE",
|
|
314
|
+
reason=(
|
|
315
|
+
"Mental health services agent detected. HB 26-1195 applies "
|
|
316
|
+
"(effective Aug. 12, 2026 — two months from now)."
|
|
317
|
+
if mental_health
|
|
318
|
+
else "No mental health services scope detected."
|
|
319
|
+
),
|
|
320
|
+
command=(
|
|
321
|
+
"iris compliance check --framework colorado-mental-health-ai"
|
|
322
|
+
if mental_health
|
|
323
|
+
else None
|
|
231
324
|
),
|
|
232
|
-
command="iris compliance check --framework colorado-ai-act" if colorado else None,
|
|
233
325
|
),
|
|
234
326
|
Recommendation(
|
|
235
327
|
framework="nist-ai-rmf",
|
|
@@ -396,6 +488,8 @@ def framework_suggest(agent: str | None, output_format: str) -> None:
|
|
|
396
488
|
|
|
397
489
|
prefill = _prefill_answers(passport)
|
|
398
490
|
answers = run_questionnaire(prefill)
|
|
491
|
+
if prefill.get("agent_description"):
|
|
492
|
+
answers["agent_description"] = prefill["agent_description"]
|
|
399
493
|
recommendations = build_recommendations(answers)
|
|
400
494
|
save_path = _save_recommendations(agent, answers, recommendations)
|
|
401
495
|
|
|
@@ -25,7 +25,7 @@ console = Console()
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
@click.group()
|
|
28
|
-
@click.version_option(version="0.1.
|
|
28
|
+
@click.version_option(version="0.1.4", prog_name="iris")
|
|
29
29
|
def cli():
|
|
30
30
|
"""
|
|
31
31
|
IRIS — AI Agent Governance Platform
|
|
@@ -35,7 +35,9 @@ def cli():
|
|
|
35
35
|
|
|
36
36
|
Docs: https://docs.iris.ai
|
|
37
37
|
"""
|
|
38
|
-
|
|
38
|
+
from iris._telemetry import maybe_fire_first_run
|
|
39
|
+
|
|
40
|
+
maybe_fire_first_run()
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
# ── iris scan ─────────────────────────────────────────────────────────────────
|
|
@@ -555,6 +557,21 @@ cli.add_command(red_team)
|
|
|
555
557
|
from iris_cli.users import users
|
|
556
558
|
cli.add_command(users)
|
|
557
559
|
|
|
560
|
+
from iris_cli.cost import cost
|
|
561
|
+
cli.add_command(cost)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@cli.command("ping", hidden=True)
|
|
565
|
+
def ping():
|
|
566
|
+
"""Internal telemetry verification (hidden)."""
|
|
567
|
+
from iris._telemetry import send_ping, telemetry_enabled
|
|
568
|
+
|
|
569
|
+
if not telemetry_enabled():
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
send_ping()
|
|
573
|
+
click.echo("Telemetry ping sent. Opt-out status: enabled")
|
|
574
|
+
|
|
558
575
|
|
|
559
576
|
@compliance.command("check")
|
|
560
577
|
@click.option("--agent", default=None, help="Specific agent to check (or all)")
|
|
@@ -335,7 +335,7 @@ def handle_suggest_policy(params: dict) -> dict:
|
|
|
335
335
|
- Call any API not listed above
|
|
336
336
|
|
|
337
337
|
## Compliance notes
|
|
338
|
-
This agent operates under the Colorado AI Act (SB
|
|
338
|
+
This agent operates under the Colorado AI Act (SB 26-189, effective Jan. 1, 2027).
|
|
339
339
|
User consent must be logged before any consequential decision.
|
|
340
340
|
"""
|
|
341
341
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import List, Optional
|
|
7
8
|
|
|
@@ -12,6 +13,7 @@ from rich.panel import Panel
|
|
|
12
13
|
from iris import AgentPassport
|
|
13
14
|
from iris_cli.action_plan import compliance_score, progress_bar
|
|
14
15
|
from iris_core.compliance.registry import ComplianceRegistry
|
|
16
|
+
from iris_core.cost.tracker import discover_agent_trackers
|
|
15
17
|
|
|
16
18
|
console = Console()
|
|
17
19
|
|
|
@@ -49,6 +51,16 @@ def _status_label(score: float, violations: bool) -> str:
|
|
|
49
51
|
return "NOT REGISTERED"
|
|
50
52
|
|
|
51
53
|
|
|
54
|
+
def _monthly_cost_by_agent(days: int = 30) -> dict[str, float]:
|
|
55
|
+
since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
56
|
+
costs: dict[str, float] = {}
|
|
57
|
+
for tracker in discover_agent_trackers():
|
|
58
|
+
summary = tracker.get_summary(since=since)
|
|
59
|
+
costs[tracker.agent_name] = summary.estimated_monthly_cost
|
|
60
|
+
costs[tracker.agent_id] = summary.estimated_monthly_cost
|
|
61
|
+
return costs
|
|
62
|
+
|
|
63
|
+
|
|
52
64
|
@click.command("status")
|
|
53
65
|
@click.option("--agent", default=None, help="Show status for a specific agent")
|
|
54
66
|
@click.option("--dir", "governance_dir", type=click.Path(path_type=Path), default=None)
|
|
@@ -81,6 +93,7 @@ def status_cmd(agent: Optional[str], governance_dir: Optional[Path], include_dem
|
|
|
81
93
|
registry = ComplianceRegistry()
|
|
82
94
|
next_global: Optional[str] = None
|
|
83
95
|
lowest_score = 2.0
|
|
96
|
+
monthly_costs = _monthly_cost_by_agent()
|
|
84
97
|
|
|
85
98
|
for name in sorted(all_agents):
|
|
86
99
|
agent_dir = all_agents[name]
|
|
@@ -95,8 +108,10 @@ def status_cmd(agent: Optional[str], governance_dir: Optional[Path], include_dem
|
|
|
95
108
|
next_global = _next_action(passport, agent_dir)
|
|
96
109
|
|
|
97
110
|
color = "green" if score >= 1.0 else "yellow" if score >= 0.4 else "red"
|
|
111
|
+
cost = monthly_costs.get(name) or monthly_costs.get(passport.agent_id)
|
|
112
|
+
cost_str = f" ${cost:.2f}/mo" if cost else ""
|
|
98
113
|
lines.append(
|
|
99
|
-
f" [{color}]{name:<24}[/{color}] {bar} {pct:>3}% {label}"
|
|
114
|
+
f" [{color}]{name:<24}[/{color}] {bar} {pct:>3}% {label}{cost_str}"
|
|
100
115
|
)
|
|
101
116
|
|
|
102
117
|
lines.append("")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: iris-security-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
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.
|
|
19
|
-
Requires-Dist: iris-security-sdk>=0.1.
|
|
18
|
+
Requires-Dist: iris-security-core>=0.1.3
|
|
19
|
+
Requires-Dist: iris-security-sdk>=0.1.4
|
|
20
20
|
Requires-Dist: click>=8.1
|
|
21
21
|
Requires-Dist: rich>=13.0
|
|
22
22
|
Requires-Dist: 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.
|
|
7
|
+
version = "0.1.4"
|
|
8
8
|
description = "IRIS CLI — iris scan, iris register, iris policy, iris compliance"
|
|
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.3",
|
|
24
|
+
"iris-security-sdk>=0.1.4",
|
|
25
25
|
"click>=8.1",
|
|
26
26
|
"rich>=13.0",
|
|
27
27
|
"pyyaml>=6.0",
|
|
@@ -41,7 +41,7 @@ def test_colorado_consequential_gets_colorado_act(tmp_path):
|
|
|
41
41
|
)
|
|
42
42
|
assert result.exit_code == 0, result.output
|
|
43
43
|
assert "colorado-ai-act" in result.output
|
|
44
|
-
assert "SB
|
|
44
|
+
assert "SB 26-189" in result.output
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def test_hipaa_trigger_on_health_data(tmp_path):
|
|
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.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{iris_security_cli-0.1.2 → iris_security_cli-0.1.4}/iris_security_cli.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|