iris-security-cli 0.2.5__tar.gz → 0.2.12__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 (66) hide show
  1. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/PKG-INFO +3 -3
  2. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/certify.py +165 -5
  3. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/compliance_check_cmd.py +33 -12
  4. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/declare.py +3 -0
  5. iris_security_cli-0.2.12/iris_cli/discover.py +223 -0
  6. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/evidence.py +363 -1
  7. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/framework_suggest.py +36 -0
  8. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/license_cmd.py +12 -6
  9. iris_security_cli-0.2.12/iris_cli/list_cmd.py +283 -0
  10. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/main.py +38 -2
  11. iris_security_cli-0.2.12/iris_cli/onboarding_report.py +46 -0
  12. iris_security_cli-0.2.12/iris_cli/org_policy.py +399 -0
  13. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/policy_cache.py +9 -0
  14. iris_security_cli-0.2.12/iris_cli/policy_commit.py +129 -0
  15. iris_security_cli-0.2.12/iris_cli/policy_status.py +147 -0
  16. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/quickstart.py +2 -0
  17. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_security_cli.egg-info/PKG-INFO +3 -3
  18. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_security_cli.egg-info/SOURCES.txt +8 -0
  19. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_security_cli.egg-info/requires.txt +2 -2
  20. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/pyproject.toml +3 -3
  21. iris_security_cli-0.2.12/tests/test_evidence_query.py +210 -0
  22. iris_security_cli-0.2.12/tests/test_list_cmd.py +94 -0
  23. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/README.md +0 -0
  24. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/__init__.py +0 -0
  25. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/action_plan.py +0 -0
  26. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/assess.py +0 -0
  27. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/cedar_parser.py +0 -0
  28. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/compiler_config.py +0 -0
  29. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/compliance_fix.py +0 -0
  30. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/cost.py +0 -0
  31. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/delegation.py +0 -0
  32. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/dlp_cmd.py +0 -0
  33. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/drift.py +0 -0
  34. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/enforce.py +0 -0
  35. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/entitlements_cmd.py +0 -0
  36. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/explain.py +0 -0
  37. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/framework_test.py +0 -0
  38. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/hitl.py +0 -0
  39. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/mcp_server.py +0 -0
  40. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/models_cmd.py +0 -0
  41. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/policy_diff.py +0 -0
  42. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/preview.py +0 -0
  43. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/redteam.py +0 -0
  44. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/regulatory.py +0 -0
  45. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/scan_govern.py +0 -0
  46. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/scan_report.py +0 -0
  47. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/scm.py +0 -0
  48. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/sentinel.py +0 -0
  49. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/status_cmd.py +0 -0
  50. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/users.py +0 -0
  51. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/vault.py +0 -0
  52. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/watch.py +0 -0
  53. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_cli/witness.py +0 -0
  54. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_security_cli.egg-info/dependency_links.txt +0 -0
  55. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_security_cli.egg-info/entry_points.txt +0 -0
  56. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/iris_security_cli.egg-info/top_level.txt +0 -0
  57. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/setup.cfg +0 -0
  58. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_entitlements_display.py +0 -0
  59. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_evidence.py +0 -0
  60. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_framework_suggest.py +0 -0
  61. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_framework_test.py +0 -0
  62. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_hitl_cli.py +0 -0
  63. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_mcp_model_governance.py +0 -0
  64. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_policy_diff.py +0 -0
  65. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/tests/test_quickstart.py +0 -0
  66. {iris_security_cli-0.2.5 → iris_security_cli-0.2.12}/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.2.5
3
+ Version: 0.2.12
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.13
19
- Requires-Dist: iris-security-sdk>=0.2.5
18
+ Requires-Dist: iris-security-core>=0.1.16
19
+ Requires-Dist: iris-security-sdk>=0.2.12
20
20
  Requires-Dist: click>=8.1
21
21
  Requires-Dist: rich>=13.0
22
22
  Requires-Dist: pyyaml>=6.0
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  from datetime import datetime
6
7
  from pathlib import Path
7
8
  from typing import Optional
@@ -9,8 +10,17 @@ from typing import Optional
9
10
  import click
10
11
  from rich.console import Console
11
12
  from rich.panel import Panel
13
+ from rich.table import Table
12
14
 
13
15
  from iris_core.compliance.dynamic_loader import ComplianceRule, DynamicBundleLoader
16
+ from iris_core.compliance.exporters.aiuc1_export import AIUC1EvidenceExporter
17
+ from iris_core.compliance.framework_check import load_bundle_data, run_framework_check
18
+ from iris_core.compliance.bundles.iso42001 import (
19
+ ISO42001_BUNDLE,
20
+ ISO42001_CLAUSES,
21
+ coverage_breakdown,
22
+ stale_crosswalk_warning,
23
+ )
14
24
  from iris_core.entitlements import Entitlements, Feature
15
25
  from iris_core.models.passport import AgentPassport
16
26
 
@@ -26,6 +36,12 @@ from iris_cli.framework_test import (
26
36
 
27
37
  console = Console()
28
38
 
39
+ _CERTIFY_FRAMEWORKS = sorted([
40
+ "colorado-ai-act", "nist-ai-rmf", "fedramp-moderate", "hipaa", "soc2", "gdpr",
41
+ "nyc-ll144", "illinois-ai-video", "eu-ai-act", "ccpa-admt", "china-pipl",
42
+ "aiuc-1", "iso-42001",
43
+ ])
44
+
29
45
 
30
46
  def _evaluate_custom_rule(rule: ComplianceRule, passport: AgentPassport) -> str:
31
47
  safe_globals = {"__builtins__": {}}
@@ -120,17 +136,116 @@ def _render_custom_rules_section(
120
136
  console.print(f" Fix: {rule.remediation_command}")
121
137
 
122
138
 
139
+ def _render_iso42001_certification(agent_name: str) -> None:
140
+ crosswalk_date = ISO42001_BUNDLE["source_crosswalk_date"]
141
+ console.print(Panel(
142
+ f"Derived from AIUC-1 crosswalk ({crosswalk_date}) + IRIS evidence",
143
+ title=f"ISO 42001 Coverage — {agent_name}",
144
+ style="blue",
145
+ ))
146
+
147
+ warning = stale_crosswalk_warning()
148
+ if warning:
149
+ console.print(f"\n[yellow]⚠ {warning}[/yellow]")
150
+
151
+ counts = coverage_breakdown()
152
+ console.print(
153
+ f"\n[green]FULL[/green] coverage (IRIS evidence satisfies the clause):"
154
+ f" {counts['FULL']} clauses"
155
+ )
156
+ console.print(
157
+ f"[yellow]PARTIAL[/yellow] coverage (IRIS evidence + org action needed):"
158
+ f" {counts['PARTIAL']} clauses"
159
+ )
160
+ console.print(
161
+ f"[dim]NOT APPLICABLE[/dim] (organizational/human activity only):"
162
+ f" {counts['NOT_APPLICABLE']} clauses"
163
+ )
164
+ if counts.get("NONE", 0):
165
+ console.print(
166
+ f"[red]NONE[/red] (IRIS inherits AIUC-1 gap — no technical evidence):"
167
+ f" {counts['NONE']} clauses"
168
+ )
169
+
170
+ for tier, style in (
171
+ ("FULL", "green"),
172
+ ("PARTIAL", "yellow"),
173
+ ("NOT_APPLICABLE", "dim"),
174
+ ("NONE", "red"),
175
+ ):
176
+ section = [c for c in ISO42001_CLAUSES if c.get("iris_coverage") == tier]
177
+ if not section:
178
+ continue
179
+ console.print(f"\n[bold {style}]{tier}[/bold {style}] ({len(section)} clauses)")
180
+ table = Table(show_header=True, header_style="bold")
181
+ table.add_column("Clause")
182
+ table.add_column("Title")
183
+ table.add_column("AIUC-1 Gap")
184
+ table.add_column("Evidence / Gap note", overflow="fold")
185
+ for clause in section:
186
+ note = clause.get("iris_evidence") or clause.get("gap_note") or "—"
187
+ table.add_row(
188
+ clause["clause"],
189
+ clause["title"],
190
+ clause["aiuc1_gap"],
191
+ note,
192
+ )
193
+ console.print(table)
194
+
195
+
196
+ def _render_aiuc1_certification(agent_name: str, passport: AgentPassport) -> None:
197
+ result = run_framework_check(passport, "aiuc-1")
198
+ bundle = load_bundle_data("aiuc-1")
199
+ console.print(Panel(
200
+ f"Agent: {agent_name}\n"
201
+ f"Framework: {bundle.get('full_name', 'AIUC-1')}\n"
202
+ f"Source crosswalk: {bundle.get('source_crosswalk_date', '2025-09-18')}\n"
203
+ f"Generated: {datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')}",
204
+ title="IRIS Certification — aiuc-1",
205
+ style="blue",
206
+ ))
207
+ table = Table(show_header=True, header_style="bold")
208
+ table.add_column("Control")
209
+ table.add_column("Coverage")
210
+ table.add_column("Status")
211
+ table.add_column("Notes", overflow="fold")
212
+ for rule_result in result.rule_results:
213
+ bundle_rule = next(
214
+ (r for r in bundle.get("rules", []) if r["rule_id"] == rule_result.rule_id),
215
+ {},
216
+ )
217
+ coverage = bundle_rule.get("coverage", "")
218
+ if rule_result.status == "OUT_OF_SCOPE":
219
+ status = "[dim]OUT OF SCOPE[/dim]"
220
+ note = bundle_rule.get("gap_note", "")
221
+ elif rule_result.status == "PASS":
222
+ status = "[green]PASS[/green]"
223
+ note = bundle_rule.get("gap_note", "") if coverage == "PARTIAL" else ""
224
+ else:
225
+ status = "[red]FAIL[/red]"
226
+ note = rule_result.message
227
+ table.add_row(rule_result.rule_id, coverage, status, note)
228
+ console.print(table)
229
+ console.print(
230
+ "\n[dim]Export auditor-ready evidence: "
231
+ f"iris certify --framework aiuc-1 --agent {agent_name} "
232
+ "--format aiuc1-export[/dim]"
233
+ )
234
+
235
+
123
236
  @click.command("certify")
124
237
  @click.option(
125
238
  "--framework",
126
239
  required=True,
127
- type=click.Choice(sorted([
128
- "colorado-ai-act", "nist-ai-rmf", "fedramp-moderate", "hipaa", "soc2", "gdpr",
129
- "nyc-ll144", "illinois-ai-video", "eu-ai-act", "ccpa-admt", "china-pipl",
130
- ])),
240
+ type=click.Choice(_CERTIFY_FRAMEWORKS),
131
241
  )
132
242
  @click.option("--agent", "agent_name", required=True, help="Agent name under governance/agents")
133
- @click.option("--format", "output_format", default="table", type=click.Choice(["table", "json", "markdown"]))
243
+ @click.option(
244
+ "--format",
245
+ "output_format",
246
+ default="table",
247
+ type=click.Choice(["table", "json", "markdown", "aiuc1-export"]),
248
+ )
134
249
  @click.option(
135
250
  "--registry-version",
136
251
  default=None,
@@ -157,6 +272,51 @@ def certify_cmd(
157
272
  raise click.ClickException(f"Passport not found: {passport_path}")
158
273
  passport = AgentPassport.from_yaml(passport_path.read_text())
159
274
 
275
+ if framework == "iso-42001":
276
+ Entitlements().require(Feature.BUNDLE_ISO42001, context="iso-42001 certification")
277
+ if output_format == "json":
278
+ click.echo(
279
+ json.dumps(
280
+ {
281
+ "framework": "iso-42001",
282
+ "agent": agent_name,
283
+ "source_crosswalk_date": ISO42001_BUNDLE["source_crosswalk_date"],
284
+ "coverage_breakdown": coverage_breakdown(),
285
+ "clauses": ISO42001_CLAUSES,
286
+ "stale_warning": stale_crosswalk_warning(),
287
+ },
288
+ indent=2,
289
+ )
290
+ )
291
+ else:
292
+ _render_iso42001_certification(agent_name)
293
+ return
294
+
295
+ if framework == "aiuc-1":
296
+ Entitlements().require(Feature.BUNDLE_AIUC1, context="aiuc-1 certification")
297
+ if output_format == "aiuc1-export":
298
+ exporter = AIUC1EvidenceExporter(passport, gov_dir)
299
+ click.echo(json.dumps(exporter.export_full_package(agent_name), indent=2))
300
+ return
301
+ if output_format in ("json", "markdown"):
302
+ result = run_framework_check(passport, "aiuc-1")
303
+ payload = {
304
+ "framework": "aiuc-1",
305
+ "agent": agent_name,
306
+ "rules": [
307
+ {
308
+ "rule_id": r.rule_id,
309
+ "status": r.status,
310
+ "message": r.message,
311
+ }
312
+ for r in result.rule_results
313
+ ],
314
+ }
315
+ click.echo(json.dumps(payload, indent=2) if output_format == "json" else str(payload))
316
+ return
317
+ _render_aiuc1_certification(agent_name, passport)
318
+ return
319
+
160
320
  loader = DynamicBundleLoader(pinned_version=registry_version)
161
321
 
162
322
  if not offline and not registry_version:
@@ -11,7 +11,7 @@ from rich.console import Console
11
11
  from rich.table import Table
12
12
 
13
13
  from iris import AgentPassport
14
- from iris_core.compliance.framework_check import run_framework_check
14
+ from iris_core.compliance.framework_check import load_bundle_data, run_framework_check
15
15
  from iris_core.compliance.results import ComplianceCheckStatus
16
16
  from iris_core.entitlements.display import build_pro_preview_box
17
17
 
@@ -61,29 +61,50 @@ def _render_result(
61
61
  )
62
62
  return False
63
63
 
64
- has_failures = any(rule.status == "FAIL" for rule in result.rule_results) or bool(
65
- result.violations
66
- )
64
+ has_failures = any(
65
+ rule.status == "FAIL" for rule in result.rule_results
66
+ ) or bool(result.violations)
67
67
  status = "[bold green]PASS[/bold green]" if not has_failures else "[bold red]FAIL[/bold red]"
68
68
  console.print(f"\nAgent: [cyan]{passport.name}[/cyan] — {status}")
69
69
 
70
70
  if result.rule_results:
71
+ bundle_rules = []
72
+ if framework == "aiuc-1":
73
+ try:
74
+ bundle_rules = load_bundle_data(framework).get("rules", [])
75
+ except ValueError:
76
+ bundle_rules = []
71
77
  table = Table(title=f"{result.framework_name}", show_header=True, header_style="bold")
72
78
  table.add_column("Rule", style="cyan")
73
79
  table.add_column("Severity")
74
80
  table.add_column("Status")
81
+ if framework == "aiuc-1":
82
+ table.add_column("Coverage")
75
83
  table.add_column("Response")
76
84
  table.add_column("Notes")
77
85
  for rule in result.rule_results:
78
- status_icon = "[green]✓ PASS[/green]" if rule.status == "PASS" else "[red]✗ FAIL[/red]"
86
+ if rule.status == "PASS":
87
+ status_icon = "[green]✓ PASS[/green]"
88
+ elif rule.status == "OUT_OF_SCOPE":
89
+ status_icon = "[dim]— OUT OF SCOPE[/dim]"
90
+ else:
91
+ status_icon = "[red]✗ FAIL[/red]"
79
92
  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
- )
93
+ if rule.status == "OUT_OF_SCOPE":
94
+ notes = rule.message
95
+ row = [rule.rule_id, rule.severity, status_icon]
96
+ if framework == "aiuc-1":
97
+ bundle_rule = next(
98
+ (
99
+ r
100
+ for r in bundle_rules
101
+ if r.get("rule_id") == rule.rule_id
102
+ ),
103
+ {},
104
+ )
105
+ row.append(bundle_rule.get("coverage", ""))
106
+ row.extend([rule.response, notes[:80] + ("..." if len(notes) > 80 else "")])
107
+ table.add_row(*row)
87
108
  console.print(table)
88
109
  console.print(
89
110
  "\n[dim]RESPONSE KEY: INFORM — log and proceed | "
@@ -9,6 +9,8 @@ import click
9
9
  from rich.console import Console
10
10
  from rich.panel import Panel
11
11
 
12
+ from iris_core.cli_timing.instrument import timed_cli_command
13
+
12
14
  console = Console()
13
15
 
14
16
  _DATA_CLASSIFICATIONS = {
@@ -127,6 +129,7 @@ def _interactive_wizard(output_dir: Optional[Path]) -> None:
127
129
  @click.option("--high-risk", is_flag=True, default=False, help="Flag as high-risk AI")
128
130
  @click.option("--compliance", "-c", multiple=True, help="Compliance frameworks to tag")
129
131
  @click.option("--dir", "output_dir", type=click.Path(path_type=Path), default=None)
132
+ @timed_cli_command("iris declare")
130
133
  def declare(
131
134
  name: Optional[str],
132
135
  owner: Optional[str],
@@ -0,0 +1,223 @@
1
+ """Org-wide discovery CLI — iris discover org."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ import click
12
+
13
+ from iris_core.discovery.coordinator import DiscoveryCoordinator
14
+ from iris_core.discovery.sources import (
15
+ CICDDiscoverySource,
16
+ KubernetesDiscoverySource,
17
+ MCPRegistryDiscoverySource,
18
+ SCMDiscoverySource,
19
+ )
20
+
21
+ SOURCE_MAP = {
22
+ "scm": SCMDiscoverySource,
23
+ "cicd": CICDDiscoverySource,
24
+ "k8s": KubernetesDiscoverySource,
25
+ "mcp": MCPRegistryDiscoverySource,
26
+ }
27
+
28
+ FRAMEWORK_LABELS = {
29
+ "langchain": "LangChain",
30
+ "openai_sdk": "OpenAI SDK",
31
+ "anthropic_sdk": "Anthropic SDK",
32
+ "bedrock": "Bedrock",
33
+ "gemini": "Gemini",
34
+ "crewai": "CrewAI",
35
+ "autogen": "Autogen",
36
+ "mcp_server": "MCP Server",
37
+ "custom": "Custom/unknown",
38
+ "unknown": "Custom/unknown",
39
+ }
40
+
41
+
42
+ def _format_duration(seconds: float) -> str:
43
+ minutes = int(seconds // 60)
44
+ secs = int(seconds % 60)
45
+ if minutes:
46
+ return f"{minutes}m {secs}s"
47
+ return f"{secs}s"
48
+
49
+
50
+ def _resolve_scm_config(org_name: str, config_file: Optional[Path]) -> dict:
51
+ if config_file and config_file.exists():
52
+ data = json.loads(config_file.read_text())
53
+ scm = data.get("scm", data)
54
+ scm.setdefault("org", org_name)
55
+ return scm
56
+
57
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
58
+ config: dict = {"provider": "github", "org": org_name}
59
+ if token:
60
+ config["token"] = token
61
+ if os.environ.get("IRIS_DISCOVERY_REPOS"):
62
+ config["repos"] = json.loads(os.environ["IRIS_DISCOVERY_REPOS"])
63
+ return config
64
+
65
+
66
+ def _build_configs(
67
+ source_names: List[str],
68
+ org_name: str,
69
+ config_file: Optional[Path],
70
+ ) -> dict:
71
+ configs: dict = {}
72
+ if config_file and config_file.exists():
73
+ all_cfg = json.loads(config_file.read_text())
74
+ else:
75
+ all_cfg = {}
76
+
77
+ if "scm" in source_names:
78
+ configs["scm_repo"] = all_cfg.get("scm", _resolve_scm_config(org_name, config_file))
79
+ configs["scm_repo"].setdefault("org", org_name)
80
+
81
+ if "cicd" in source_names:
82
+ cicd = all_cfg.get("cicd", {})
83
+ if configs.get("scm_repo", {}).get("repos"):
84
+ cicd["repos"] = configs["scm_repo"]["repos"]
85
+ configs["cicd_pipeline"] = cicd
86
+
87
+ if "k8s" in source_names:
88
+ k8s = all_cfg.get("k8s", {})
89
+ kubeconfig = os.environ.get("KUBECONFIG")
90
+ if kubeconfig:
91
+ k8s.setdefault("kubeconfig", kubeconfig)
92
+ configs["k8s_workload"] = k8s
93
+
94
+ if "mcp" in source_names:
95
+ configs["mcp_registry"] = all_cfg.get("mcp", {})
96
+
97
+ return configs
98
+
99
+
100
+ def render_org_discovery_report(inventory, duration_seconds: float) -> str:
101
+ """Render CLI output matching the Brownfield demo format."""
102
+ lines: List[str] = []
103
+ org = inventory.org_id
104
+
105
+ repos = 0
106
+ pipelines = 0
107
+ clusters = 0
108
+ for src in inventory.sources_scanned:
109
+ if src.get("source_type") == "scm_repo":
110
+ repos = src.get("repos_scanned", repos)
111
+ elif src.get("source_type") == "cicd_pipeline":
112
+ pipelines = src.get("findings_count", pipelines)
113
+ elif src.get("source_type") == "k8s_workload":
114
+ clusters = src.get("clusters_scanned", 1 if src.get("status") == "complete" else 0)
115
+
116
+ cicd_count = sum(
117
+ 1
118
+ for a in inventory.agents
119
+ if a.source_type == "cicd_pipeline" and a.confidence == "certain"
120
+ )
121
+ if pipelines == 0 and cicd_count:
122
+ pipelines = cicd_count
123
+
124
+ lines.append(f"IRIS Org Discovery — {org}")
125
+ lines.append(
126
+ f"Scanned: {repos} repos, {pipelines} CI/CD pipelines, {clusters} K8s clusters"
127
+ )
128
+ lines.append(
129
+ f"Duration: {_format_duration(duration_seconds)}, Status: {inventory.status}"
130
+ )
131
+ lines.append("")
132
+ lines.append(f"Found {inventory.total_agents} agents across your organization.")
133
+ lines.append(
134
+ f"{inventory.ungoverned_count} are ungoverned — no IRIS policy, no audit trail."
135
+ )
136
+ lines.append("")
137
+ lines.append("By framework:")
138
+
139
+ ungoverned_by_fw = inventory.ungoverned_by_framework()
140
+ fw_counts = inventory.by_framework
141
+ sorted_fw = sorted(
142
+ fw_counts.keys(),
143
+ key=lambda k: fw_counts[k],
144
+ reverse=True,
145
+ )
146
+ for fw in sorted_fw:
147
+ label = FRAMEWORK_LABELS.get(fw, fw.replace("_", " ").title())
148
+ total = fw_counts[fw]
149
+ ungov = ungoverned_by_fw.get(fw, 0)
150
+ lines.append(f" {label:<18} {total:>4} ({ungov} ungoverned)")
151
+
152
+ prod_unowned = inventory.ungoverned_production_without_owner()
153
+ if prod_unowned:
154
+ lines.append("")
155
+ lines.append(
156
+ f"{prod_unowned} ungoverned agents are in PRODUCTION with no owner on record."
157
+ )
158
+
159
+ lines.append("Run: iris discover org --report for the full inventory.")
160
+ return "\n".join(lines)
161
+
162
+
163
+ @click.group()
164
+ def discover():
165
+ """Org-wide AI agent discovery across SCM, CI/CD, Kubernetes, and MCP."""
166
+ pass
167
+
168
+
169
+ @discover.command("org")
170
+ @click.option(
171
+ "--sources",
172
+ default="scm,cicd,k8s,mcp",
173
+ show_default=True,
174
+ help="Comma-separated discovery sources",
175
+ )
176
+ @click.option("--org-name", required=True, help="Organization identifier for this scan")
177
+ @click.option(
178
+ "--config",
179
+ "config_file",
180
+ type=click.Path(exists=False, path_type=Path),
181
+ default=None,
182
+ help="JSON config file with source credentials and repo fixtures",
183
+ )
184
+ @click.option("--report", is_flag=True, help="Output full JSON inventory")
185
+ @click.option(
186
+ "--vault-dir",
187
+ type=click.Path(path_type=Path),
188
+ default=None,
189
+ help="Evidence vault directory for scan results",
190
+ )
191
+ def discover_org(sources: str, org_name: str, config_file: Optional[Path], report: bool, vault_dir: Optional[Path]):
192
+ """
193
+ Scan your organization for AI agents across all configured surfaces.
194
+
195
+ Example:
196
+ iris discover org --sources scm --org-name acme-corp
197
+ """
198
+ source_names = [s.strip() for s in sources.split(",") if s.strip()]
199
+ unknown = set(source_names) - set(SOURCE_MAP)
200
+ if unknown:
201
+ raise click.ClickException(f"Unknown sources: {', '.join(sorted(unknown))}")
202
+
203
+ if not config_file and not os.environ.get("GITHUB_TOKEN") and "scm" in source_names:
204
+ if not os.environ.get("IRIS_DISCOVERY_REPOS"):
205
+ token = click.prompt("GitHub token (contents:read)", hide_input=True, default="", show_default=False)
206
+ if token:
207
+ os.environ["GITHUB_TOKEN"] = token
208
+ org_name = click.prompt("GitHub organization name", default=org_name)
209
+
210
+ configs = _build_configs(source_names, org_name, config_file)
211
+ instances = [SOURCE_MAP[name]() for name in source_names]
212
+ coordinator = DiscoveryCoordinator(vault_dir=vault_dir)
213
+
214
+ start = time.monotonic()
215
+ inventory = coordinator.run_scan_sync(org_name, instances, configs)
216
+ duration = time.monotonic() - start
217
+
218
+ if report:
219
+ payload = inventory.to_dict()
220
+ payload["duration_seconds"] = duration
221
+ click.echo(json.dumps(payload, indent=2))
222
+ else:
223
+ click.echo(render_org_discovery_report(inventory, duration))