orithos-cli 0.1.0__py3-none-any.whl

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 (40) hide show
  1. orithos_cli/__init__.py +6 -0
  2. orithos_cli/agent.py +78 -0
  3. orithos_cli/agent_wizard.py +255 -0
  4. orithos_cli/auth.py +21 -0
  5. orithos_cli/cli.py +82 -0
  6. orithos_cli/compliance.py +133 -0
  7. orithos_cli/config.py +114 -0
  8. orithos_cli/configure.py +103 -0
  9. orithos_cli/connection.py +69 -0
  10. orithos_cli/connection_wizard.py +129 -0
  11. orithos_cli/discovery.py +78 -0
  12. orithos_cli/graph.py +102 -0
  13. orithos_cli/guardrail.py +131 -0
  14. orithos_cli/mcp.py +170 -0
  15. orithos_cli/output.py +178 -0
  16. orithos_cli/probes.py +50 -0
  17. orithos_cli/remediation.py +111 -0
  18. orithos_cli/runtime.py +100 -0
  19. orithos_cli/scan.py +758 -0
  20. orithos_cli/skill.py +64 -0
  21. orithos_cli/skillscan/__init__.py +37 -0
  22. orithos_cli/skillscan/checks/__init__.py +28 -0
  23. orithos_cli/skillscan/checks/credentials.py +112 -0
  24. orithos_cli/skillscan/checks/iocs.py +95 -0
  25. orithos_cli/skillscan/checks/manifest.py +156 -0
  26. orithos_cli/skillscan/checks/network.py +117 -0
  27. orithos_cli/skillscan/checks/obfuscation.py +130 -0
  28. orithos_cli/skillscan/checks/permissions.py +108 -0
  29. orithos_cli/skillscan/checks/shell.py +140 -0
  30. orithos_cli/skillscan/collect.py +205 -0
  31. orithos_cli/skillscan/model.py +99 -0
  32. orithos_cli/skillscan/report.py +130 -0
  33. orithos_cli/template.py +60 -0
  34. orithos_cli/verify.py +85 -0
  35. orithos_cli/wizard.py +314 -0
  36. orithos_cli-0.1.0.dist-info/METADATA +99 -0
  37. orithos_cli-0.1.0.dist-info/RECORD +40 -0
  38. orithos_cli-0.1.0.dist-info/WHEEL +5 -0
  39. orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
  40. orithos_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,103 @@
1
+ """orithos configure — one-time CLI setup."""
2
+
3
+ import httpx
4
+ import click
5
+
6
+ from orithos_cli.config import (
7
+ CLIConfig,
8
+ CONFIG_FILE,
9
+ DEFAULT_API_URL,
10
+ write_config,
11
+ )
12
+
13
+
14
+ def _validate_key(api_url: str, api_key: str) -> str | None:
15
+ try:
16
+ r = httpx.get(
17
+ f"{api_url}/v1/scans?limit=1",
18
+ headers={"Authorization": f"Bearer {api_key}"},
19
+ timeout=10,
20
+ )
21
+ if r.status_code == 200:
22
+ return None
23
+ if r.status_code == 401:
24
+ return "API key rejected (401). Check the key and try again."
25
+ return f"Unexpected response: HTTP {r.status_code}"
26
+ except httpx.ConnectError:
27
+ return f"Cannot connect to {api_url}. Check the URL."
28
+ except httpx.TimeoutException:
29
+ return "Connection timed out."
30
+ except Exception as e:
31
+ return str(e)
32
+
33
+
34
+ @click.command("configure")
35
+ @click.option("--api-key", "-k", default="", help="Your Orithos API key (tsk_...)")
36
+ @click.option("--show", is_flag=True, help="Show current config and exit")
37
+ @click.option("--api-url", hidden=True, default="") # hidden escape hatch for self-hosted
38
+ def configure(api_key: str, show: bool, api_url: str) -> None:
39
+ """Set up CLI credentials.
40
+
41
+ Writes to ~/.orithos/config.json. Run once after creating an API key.
42
+ """
43
+ if show:
44
+ _show_config()
45
+ return
46
+
47
+ existing = CLIConfig.from_env()
48
+ target_url = api_url or existing.api_url or DEFAULT_API_URL
49
+
50
+ if api_key:
51
+ click.echo("Validating API key...", nl=False)
52
+ err = _validate_key(target_url, api_key)
53
+ if err:
54
+ click.echo(f" failed\nError: {err}", err=True)
55
+ raise click.Abort()
56
+ click.echo(" OK")
57
+ cfg = CLIConfig(
58
+ api_url=target_url,
59
+ org_id=existing.org_id,
60
+ timeout=existing.timeout,
61
+ api_key=api_key,
62
+ )
63
+ path = write_config(cfg)
64
+ click.echo(f"Saved to {path}")
65
+ return
66
+
67
+ api_key = click.prompt("API key", hide_input=True)
68
+ if not api_key:
69
+ click.echo("Error: API key is required.", err=True)
70
+ raise click.Abort()
71
+
72
+ click.echo("Validating API key...", nl=False)
73
+ err = _validate_key(target_url, api_key)
74
+ if err:
75
+ click.echo(f" failed\nError: {err}", err=True)
76
+ raise click.Abort()
77
+ click.echo(" OK")
78
+
79
+ cfg = CLIConfig(
80
+ api_url=target_url,
81
+ org_id=existing.org_id,
82
+ timeout=existing.timeout,
83
+ api_key=api_key,
84
+ )
85
+ path = write_config(cfg)
86
+ click.echo(f"Saved to {path}")
87
+
88
+
89
+ def _show_config() -> None:
90
+ cfg = CLIConfig.from_env()
91
+ file_path = CONFIG_FILE if CONFIG_FILE.exists() else None
92
+ click.echo(f"Config file: {file_path or 'not found'}")
93
+ click.echo(f" API URL: {cfg.api_url}")
94
+ click.echo(f" Org ID: {cfg.org_id}")
95
+ click.echo(f" Timeout: {cfg.timeout}")
96
+ key_display = (
97
+ cfg.api_key[:12] + "…" + cfg.api_key[-4:]
98
+ if len(cfg.api_key) > 20
99
+ else "(not set)"
100
+ )
101
+ click.echo(f" API Key: {key_display}")
102
+ click.echo()
103
+ click.echo("Override with env vars: ORITHOS_API_KEY, ORITHOS_API_URL")
@@ -0,0 +1,69 @@
1
+ """Connection management subcommands for Orithos CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from orithos_cli.config import get_config
9
+
10
+
11
+ @click.group(name="connection")
12
+ def connection_group() -> None:
13
+ """Manage AI agent connections."""
14
+ pass
15
+
16
+
17
+ @connection_group.command("new")
18
+ def new_connection() -> None:
19
+ """Interactive connection setup — endpoint, auth, model."""
20
+ from orithos_cli.connection_wizard import interactive_connection_wizard
21
+
22
+ interactive_connection_wizard()
23
+
24
+
25
+ @connection_group.command("create")
26
+ def create_connection() -> None:
27
+ """Create a new connection — interactive wizard."""
28
+ from orithos_cli.connection_wizard import interactive_connection_wizard
29
+
30
+ interactive_connection_wizard()
31
+
32
+
33
+ @connection_group.command("list")
34
+ def list_connections() -> None:
35
+ """List all connections."""
36
+ cfg = get_config()
37
+ try:
38
+ resp = httpx.get(f"{cfg.api_url}/v1/connections", headers=cfg.auth_headers(), timeout=cfg.timeout)
39
+ resp.raise_for_status()
40
+ except httpx.HTTPStatusError as exc:
41
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
42
+ raise SystemExit(1)
43
+ data = resp.json()
44
+ items = data if isinstance(data, list) else data.get("items", [])
45
+ if not items:
46
+ click.echo("No connections found.")
47
+ return
48
+ for c in items:
49
+ health_color = "green" if c.get("health_status") == "healthy" else "red"
50
+ click.secho(f"[{c['id'][:8]}] {c.get('name', '?')}", bold=True)
51
+ click.echo(f" URL: {c.get('base_url', '?')} Model: {c.get('model_name', '?')}")
52
+ click.echo(f" Health: ", nl=False)
53
+ click.secho(f"{c.get('health_status', 'unknown')}", fg=health_color)
54
+ if c.get("total_scans"):
55
+ click.echo(f" Scans: {c['total_scans']} Risk: {c.get('risk_score', 'N/A')}")
56
+
57
+
58
+ @connection_group.command("delete")
59
+ @click.argument("connection-id")
60
+ def delete_connection(connection_id: str) -> None:
61
+ """Delete a connection by ID."""
62
+ cfg = get_config()
63
+ try:
64
+ resp = httpx.delete(f"{cfg.api_url}/v1/connections/{connection_id}", headers=cfg.auth_headers(), timeout=cfg.timeout)
65
+ resp.raise_for_status()
66
+ except httpx.HTTPStatusError as exc:
67
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
68
+ raise SystemExit(1)
69
+ click.echo(f"Connection {connection_id[:8]} deleted.")
@@ -0,0 +1,129 @@
1
+ """Interactive connection wizard for Orithos CLI — matching dashboard form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import httpx
7
+ import click
8
+
9
+ from orithos_cli.config import get_config, fmt_http_error
10
+
11
+
12
+ def _s(text: str = "", fg: str | None = None, dim: bool = False, bold: bool = False) -> str:
13
+ return click.style(text, fg=fg, dim=dim, bold=bold)
14
+
15
+
16
+ API_FORMAT_OPTIONS = [
17
+ ("openai", "OpenAI-compatible", "OpenAI / v1/chat/completions format"),
18
+ ("anthropic", "Anthropic", "Anthropic / v1/messages format"),
19
+ ("custom", "Custom", "Custom API format"),
20
+ ]
21
+
22
+ AUTH_OPTIONS = [
23
+ ("bearer_token", "Bearer Token", "Authorization: Bearer <key>"),
24
+ ("x-api-key", "X-API-Key", "X-API-Key header"),
25
+ ]
26
+
27
+
28
+ def _pick(options: list[tuple[str, str, str]], prompt: str, default: int = 1) -> str:
29
+ for i, (_, label, desc) in enumerate(options, 1):
30
+ click.echo(f" [{i}] {_s(label, bold=True)} \u2014 {_s(desc, dim=True)}")
31
+ choice = click.prompt(prompt, type=int, default=default)
32
+ if choice < 1 or choice > len(options):
33
+ choice = default
34
+ return options[choice - 1][0]
35
+
36
+
37
+ def interactive_connection_wizard() -> None:
38
+ cfg = get_config()
39
+
40
+ click.echo()
41
+ click.echo(_s("\u2726 Orithos Connection Wizard", fg="green", bold=True))
42
+ click.echo(_s(" Configure an LLM endpoint connection.", dim=True))
43
+ click.echo()
44
+
45
+ # Step 1: Identity
46
+ click.echo(_s("Step 1/3: Endpoint Details", fg="green"))
47
+ name = click.prompt(" Connection name", default="")
48
+ if not name:
49
+ click.echo(_s(" Name is required.", fg="red"))
50
+ raise SystemExit(1)
51
+ base_url = click.prompt(" Base URL", default="")
52
+ if not base_url:
53
+ click.echo(_s(" URL is required.", fg="red"))
54
+ raise SystemExit(1)
55
+ api_format = _pick(API_FORMAT_OPTIONS, " API format")
56
+ model_name = click.prompt(" Model name (optional)", default="")
57
+ click.echo()
58
+
59
+ # Step 2: Authentication
60
+ click.echo(_s("Step 2/3: Authentication", fg="green"))
61
+ auth_scheme = _pick(AUTH_OPTIONS, " Auth scheme")
62
+ api_key = click.prompt(" API key (optional, hidden)", hide_input=True, default="")
63
+ custom_header_raw = click.prompt(" Custom header (optional, format: HeaderName: value)", default="")
64
+ click.echo()
65
+
66
+ # Step 3: Confirm & Save
67
+ click.echo(_s("Step 3/3: Confirm & Save", fg="green"))
68
+
69
+ custom_headers: dict[str, str] | None = None
70
+ if custom_header_raw and ":" in custom_header_raw:
71
+ idx = custom_header_raw.index(":")
72
+ key = custom_header_raw[:idx].strip()
73
+ val = custom_header_raw[idx + 1:].strip()
74
+ if key and val:
75
+ custom_headers = {key: val}
76
+
77
+ click.echo(_s("\u250c" + "\u2500" * 47 + "\u2510", dim=True))
78
+ for line in [
79
+ f" Name: {name}",
80
+ f" URL: {base_url}",
81
+ f" Format: {api_format}",
82
+ f" Model: {model_name or '(not set)'}",
83
+ f" Auth: {auth_scheme}",
84
+ f" API key: {'(set)' if api_key else '(not set)'}",
85
+ f" Custom hdr: {custom_header_raw or '(none)'}",
86
+ ]:
87
+ click.echo(_s(f"\u2502 {line:<45}\u2502", dim=True))
88
+ click.echo(_s("\u2514" + "\u2500" * 47 + "\u2518", dim=True))
89
+ click.echo()
90
+
91
+ if not click.confirm(_s(" Save connection?", dim=True), default=True):
92
+ click.echo(_s(" Cancelled.", fg="yellow"))
93
+ return
94
+
95
+ payload: dict = {
96
+ "name": name,
97
+ "base_url": base_url,
98
+ "api_format": api_format,
99
+ }
100
+ if model_name:
101
+ payload["model_name"] = model_name
102
+ if auth_scheme:
103
+ payload["auth_scheme"] = auth_scheme
104
+ if api_key:
105
+ payload["api_key"] = api_key
106
+ if custom_headers:
107
+ payload["custom_headers"] = custom_headers
108
+
109
+ try:
110
+ r = httpx.post(
111
+ f"{cfg.api_url}/v1/connections",
112
+ json=payload,
113
+ headers=cfg.auth_headers(),
114
+ timeout=cfg.timeout,
115
+ )
116
+ r.raise_for_status()
117
+ except httpx.HTTPStatusError as exc:
118
+ click.echo(_s(f"Error: {fmt_http_error(exc)}", fg="red"), err=True)
119
+ raise SystemExit(1)
120
+ except httpx.RequestError as exc:
121
+ click.echo(_s(f"Connection error: {exc}", fg="red"), err=True)
122
+ raise SystemExit(1)
123
+
124
+ data = r.json()
125
+ cid = data.get("id", "?")[:8]
126
+ click.echo(_s(f"\u2726 Connection created: {cid}", fg="green", bold=True))
127
+ click.echo(_s(f" Name: {data.get('name', '?')}", dim=True))
128
+ click.echo(_s(f" URL: {data.get('base_url', '?')}", dim=True))
129
+ click.echo(_s(f" Auth: {data.get('auth_scheme', '?')}", dim=True))
@@ -0,0 +1,78 @@
1
+ """Asset discovery subcommands for Orithos CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from orithos_cli.config import get_config
9
+
10
+
11
+ @click.group(name="discovery")
12
+ def discovery_group() -> None:
13
+ """Discover AI assets from connected repositories."""
14
+ pass
15
+
16
+
17
+ @discovery_group.command("scan-repo")
18
+ @click.option("--repo", required=True, help="GitHub repo URL (e.g. https://github.com/org/repo)")
19
+ @click.option("--token", envvar="GITHUB_TOKEN", default=None, help="GitHub personal access token for private repos")
20
+ def scan_repo(repo: str, token: str | None) -> None:
21
+ """Scan a GitHub repository for AI agent assets."""
22
+ cfg = get_config()
23
+ url = f"{cfg.api_url}/v1/discovery/scan-repo"
24
+ payload: dict = {"repo_url": repo}
25
+ if token:
26
+ payload["github_token"] = token
27
+ resp = httpx.post(url, json=payload, headers=cfg.auth_headers(), timeout=cfg.timeout)
28
+ resp.raise_for_status()
29
+ data = resp.json()
30
+ discovered = data.get("discovered", [])
31
+ if not discovered:
32
+ click.echo("No AI assets detected in this repository.")
33
+ return
34
+ click.secho(f"Discovered {len(discovered)} asset(s) in {data.get('repo', repo)}:", bold=True)
35
+ for a in discovered:
36
+ confidence_color = "green" if a.get("confidence", 0) >= 0.8 else ("yellow" if a.get("confidence", 0) >= 0.5 else "white")
37
+ click.secho(f" [{a.get('type', '?')}] {a.get('source_file', '?')}", fg=confidence_color)
38
+ click.echo(f" Confidence: {a.get('confidence', 0):.0%}")
39
+
40
+
41
+ @discovery_group.command("assets")
42
+ @click.option("--status", default="pending", help="Filter by status (pending|added|dismissed)")
43
+ def list_assets(status: str) -> None:
44
+ """List discovered assets for the organization."""
45
+ cfg = get_config()
46
+ url = f"{cfg.api_url}/v1/discovery/assets?status={status}"
47
+ resp = httpx.get(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
48
+ resp.raise_for_status()
49
+ data = resp.json()
50
+ if not data:
51
+ click.echo(f"No {status} assets found.")
52
+ return
53
+ for a in data:
54
+ click.secho(f"[{a.get('asset_type', '?')}] {a.get('source_file', '?')}", bold=True)
55
+ click.echo(f" ID: {a['id'][:8]} Confidence: {a.get('confidence', 0):.0%}")
56
+ click.echo(f" Repo: {a.get('repo_url', '?')}")
57
+
58
+
59
+ @discovery_group.command("add")
60
+ @click.argument("asset-id")
61
+ def add_asset(asset_id: str) -> None:
62
+ """Mark a discovered asset as added (connection created)."""
63
+ cfg = get_config()
64
+ url = f"{cfg.api_url}/v1/discovery/assets/{asset_id}/add"
65
+ resp = httpx.post(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
66
+ resp.raise_for_status()
67
+ click.echo("Asset marked as added.")
68
+
69
+
70
+ @discovery_group.command("dismiss")
71
+ @click.argument("asset-id")
72
+ def dismiss_asset(asset_id: str) -> None:
73
+ """Dismiss a discovered asset (not relevant)."""
74
+ cfg = get_config()
75
+ url = f"{cfg.api_url}/v1/discovery/assets/{asset_id}/dismiss"
76
+ resp = httpx.post(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
77
+ resp.raise_for_status()
78
+ click.echo("Asset dismissed.")
orithos_cli/graph.py ADDED
@@ -0,0 +1,102 @@
1
+ """Security graph subcommands for Orithos CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import click
7
+ import httpx
8
+
9
+ from orithos_cli.config import get_config
10
+
11
+
12
+ @click.group(name="graph")
13
+ def graph_group() -> None:
14
+ """Explore the AI security tool risk graph."""
15
+ pass
16
+
17
+
18
+ @graph_group.command("topology")
19
+ @click.option("--json", "as_json", is_flag=True, help="Raw JSON output")
20
+ def get_topology(as_json: bool) -> None:
21
+ """Show the full tool risk graph with risk scores."""
22
+ cfg = get_config()
23
+ url = f"{cfg.api_url}/v1/graph/topology"
24
+ resp = httpx.get(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
25
+ resp.raise_for_status()
26
+ data = resp.json()
27
+
28
+ if as_json:
29
+ click.echo(json.dumps(data, indent=2))
30
+ return
31
+
32
+ nodes = data.get("nodes", [])
33
+ edges = data.get("edges", [])
34
+
35
+ click.secho(f"Tool Risk Graph — {len(nodes)} nodes, {len(edges)} edges", bold=True)
36
+ click.echo("")
37
+
38
+ for n in nodes:
39
+ risk_color = {"critical": "red", "high": "yellow", "medium": "blue", "low": "green"}.get(
40
+ n.get("risk_level", ""), "white"
41
+ )
42
+ click.secho(f" [{n.get('risk_level', '?').upper()}] {n['tool_name']}", fg=risk_color)
43
+ click.echo(f" Family: {n.get('tool_family', '?')}")
44
+ caps = n.get("capabilities", [])
45
+ if caps:
46
+ click.echo(f" Capabilities: {', '.join(caps[:5])}")
47
+
48
+ if edges:
49
+ click.echo("")
50
+ click.secho("Edges:", bold=True)
51
+ for e in edges:
52
+ risk_color = {"critical": "red", "high": "yellow"}.get(e.get("risk_contribution", ""), "white")
53
+ click.echo(f" {e['source_tool']} → {e['target_tool']} ", nl=False)
54
+ click.secho(f"[{e.get('risk_contribution', '?')}]", fg=risk_color)
55
+
56
+
57
+ @graph_group.command("agent")
58
+ @click.argument("agent-id")
59
+ @click.option("--json", "as_json", is_flag=True, help="Raw JSON output")
60
+ def get_agent_graph(agent_id: str, as_json: bool) -> None:
61
+ """Show the tool dependency graph for a specific agent.
62
+
63
+ AGENT_ID is the UUID of the agent.
64
+ """
65
+ cfg = get_config()
66
+ url = f"{cfg.api_url}/v1/graph/agent/{agent_id}"
67
+ resp = httpx.get(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
68
+ resp.raise_for_status()
69
+ data = resp.json()
70
+
71
+ if as_json:
72
+ click.echo(json.dumps(data, indent=2))
73
+ return
74
+
75
+ agent = data.get("agent", {})
76
+ click.secho(f"Agent: {agent.get('name', '?')} ({agent.get('model', '?')})", bold=True)
77
+ click.echo(f" ID: {agent.get('id', '?')[:8]}")
78
+
79
+ findings = data.get("findings", [])
80
+ if findings:
81
+ click.echo("")
82
+ click.secho(f"Findings ({len(findings)}):", bold=True)
83
+ for f in findings[:10]:
84
+ sev_color = {"critical": "red", "high": "yellow"}.get(f.get("severity", ""), "white")
85
+ click.secho(f" [{f.get('severity', '?').upper()}] {f.get('attack_path', '?')[:80]}", fg=sev_color)
86
+
87
+ policies = data.get("policies", [])
88
+ if policies:
89
+ click.echo("")
90
+ click.secho(f"Policies ({len(policies)}):", bold=True)
91
+ for p in policies:
92
+ click.echo(f" {p.get('tool_name', '?')} → {p.get('decision', '?')}")
93
+
94
+ graph = data.get("graph", {})
95
+ g_nodes = graph.get("nodes", [])
96
+ g_edges = graph.get("edges", [])
97
+ if g_nodes:
98
+ click.echo("")
99
+ click.secho(f"Tool Subgraph ({len(g_nodes)} nodes, {len(g_edges)} edges):", bold=True)
100
+ for n in g_nodes:
101
+ risk_color = {"critical": "red", "high": "yellow"}.get(n.get("risk_level", ""), "white")
102
+ click.secho(f" [{n.get('risk_level', '?').upper()}] {n['tool_name']}", fg=risk_color)
@@ -0,0 +1,131 @@
1
+ """Guardrail subcommands for Orithos CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import click
7
+ import httpx
8
+
9
+ from orithos_cli.config import get_config
10
+
11
+
12
+ @click.group(name="guardrail")
13
+ def guardrail_group() -> None:
14
+ """Manage AI security guardrails — generate, list, export, regenerate."""
15
+ pass
16
+
17
+
18
+ @guardrail_group.command("generate")
19
+ @click.argument("scan-id")
20
+ def generate_guardrails(scan_id: str) -> None:
21
+ """Generate guardrails from a completed scan. Calls LLM to synthesize rules.
22
+
23
+ SCAN_ID is the UUID of the scan to generate guardrails from.
24
+ """
25
+ cfg = get_config()
26
+ url = f"{cfg.api_url}/v1/guardrails/generate"
27
+ try:
28
+ resp = httpx.post(
29
+ url, json={"scan_id": scan_id}, headers=cfg.auth_headers(), timeout=120.0
30
+ )
31
+ resp.raise_for_status()
32
+ except httpx.HTTPStatusError as exc:
33
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
34
+ raise SystemExit(1)
35
+ except Exception as exc:
36
+ click.echo(f"Error: {exc}", err=True)
37
+ raise SystemExit(1)
38
+ data = resp.json()
39
+ if "error" in data:
40
+ click.echo(f"Generation failed: {data['error']}")
41
+ return
42
+ click.echo(json.dumps(data, indent=2))
43
+
44
+
45
+ @guardrail_group.command("list")
46
+ @click.option(
47
+ "--status", default=None, help="Filter by status (active|draft|review_needed)"
48
+ )
49
+ def list_guardrails(status: str | None) -> None:
50
+ """List all guardrails for the organization."""
51
+ cfg = get_config()
52
+ url = f"{cfg.api_url}/v1/guardrails"
53
+ params = {}
54
+ if status:
55
+ params["status"] = status
56
+ resp = httpx.get(
57
+ url, params=params or None, headers=cfg.auth_headers(), timeout=cfg.timeout
58
+ )
59
+ resp.raise_for_status()
60
+ data = resp.json()
61
+ items = data if isinstance(data, list) else data.get("items", [])
62
+ if not items:
63
+ click.echo("No guardrails found.")
64
+ return
65
+ for g in items:
66
+ status_color = (
67
+ "green"
68
+ if g.get("status") == "active"
69
+ else ("yellow" if g.get("status") == "review_needed" else "white")
70
+ )
71
+ click.secho(
72
+ f"[{g.get('status', '?')}] {g['name']} (v{g.get('version', 1)})",
73
+ bold=True,
74
+ fg=status_color,
75
+ )
76
+ click.echo(f" ID: {g['id'][:8]} Rules: {g.get('rule_count', 0)}")
77
+ click.echo(f" Created: {g.get('created_at', '?')}")
78
+
79
+
80
+ @guardrail_group.command("export")
81
+ @click.argument("guardrail-id")
82
+ @click.option(
83
+ "--format",
84
+ "fmt",
85
+ default="structured",
86
+ type=click.Choice(["structured", "nemo", "classifier"]),
87
+ help="Export format: structured (default), nemo (Colang YAML), classifier (LLM prompt)",
88
+ )
89
+ @click.option("--output", "-o", default=None, help="Write to file instead of stdout")
90
+ def export_guardrail(guardrail_id: str, fmt: str, output: str | None) -> None:
91
+ """Export a guardrail in different formats.
92
+
93
+ GUARDRAIL_ID is the UUID of the guardrail to export.
94
+
95
+ Structured export (default) creates a Policy resource — uses POST.
96
+ Nemo/classifier exports are read-only — uses GET.
97
+ """
98
+ cfg = get_config()
99
+
100
+ if fmt == "structured":
101
+ url = f"{cfg.api_url}/v1/guardrails/{guardrail_id}/export-policy"
102
+ resp = httpx.post(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
103
+ else:
104
+ url = f"{cfg.api_url}/v1/guardrails/{guardrail_id}/export?format={fmt}"
105
+ resp = httpx.get(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
106
+
107
+ resp.raise_for_status()
108
+ content_type = resp.headers.get("content-type", "")
109
+ if "text/" in content_type:
110
+ text = resp.text
111
+ else:
112
+ text = json.dumps(resp.json(), indent=2)
113
+
114
+ ext = {"structured": "yaml", "nemo": "yml", "classifier": "txt"}.get(fmt, "txt")
115
+ filename = output or f"guardrail-{guardrail_id[:8]}.{ext}"
116
+
117
+ with open(filename, "w") as f:
118
+ f.write(text)
119
+ click.echo(f"Exported to {filename} ({len(text)} bytes)")
120
+
121
+
122
+ @guardrail_group.command("regenerate")
123
+ @click.argument("guardrail-id")
124
+ def regenerate_guardrail(guardrail_id: str) -> None:
125
+ """Re-generate guardrail rules from the source scan using LLM."""
126
+ cfg = get_config()
127
+ url = f"{cfg.api_url}/v1/guardrails/{guardrail_id}/regenerate"
128
+ resp = httpx.post(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
129
+ resp.raise_for_status()
130
+ data = resp.json()
131
+ click.echo(json.dumps(data, indent=2))