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.
- orithos_cli/__init__.py +6 -0
- orithos_cli/agent.py +78 -0
- orithos_cli/agent_wizard.py +255 -0
- orithos_cli/auth.py +21 -0
- orithos_cli/cli.py +82 -0
- orithos_cli/compliance.py +133 -0
- orithos_cli/config.py +114 -0
- orithos_cli/configure.py +103 -0
- orithos_cli/connection.py +69 -0
- orithos_cli/connection_wizard.py +129 -0
- orithos_cli/discovery.py +78 -0
- orithos_cli/graph.py +102 -0
- orithos_cli/guardrail.py +131 -0
- orithos_cli/mcp.py +170 -0
- orithos_cli/output.py +178 -0
- orithos_cli/probes.py +50 -0
- orithos_cli/remediation.py +111 -0
- orithos_cli/runtime.py +100 -0
- orithos_cli/scan.py +758 -0
- orithos_cli/skill.py +64 -0
- orithos_cli/skillscan/__init__.py +37 -0
- orithos_cli/skillscan/checks/__init__.py +28 -0
- orithos_cli/skillscan/checks/credentials.py +112 -0
- orithos_cli/skillscan/checks/iocs.py +95 -0
- orithos_cli/skillscan/checks/manifest.py +156 -0
- orithos_cli/skillscan/checks/network.py +117 -0
- orithos_cli/skillscan/checks/obfuscation.py +130 -0
- orithos_cli/skillscan/checks/permissions.py +108 -0
- orithos_cli/skillscan/checks/shell.py +140 -0
- orithos_cli/skillscan/collect.py +205 -0
- orithos_cli/skillscan/model.py +99 -0
- orithos_cli/skillscan/report.py +130 -0
- orithos_cli/template.py +60 -0
- orithos_cli/verify.py +85 -0
- orithos_cli/wizard.py +314 -0
- orithos_cli-0.1.0.dist-info/METADATA +99 -0
- orithos_cli-0.1.0.dist-info/RECORD +40 -0
- orithos_cli-0.1.0.dist-info/WHEEL +5 -0
- orithos_cli-0.1.0.dist-info/entry_points.txt +2 -0
- orithos_cli-0.1.0.dist-info/top_level.txt +1 -0
orithos_cli/configure.py
ADDED
|
@@ -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))
|
orithos_cli/discovery.py
ADDED
|
@@ -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)
|
orithos_cli/guardrail.py
ADDED
|
@@ -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))
|