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
orithos_cli/mcp.py ADDED
@@ -0,0 +1,170 @@
1
+ """MCP (Model Context Protocol) commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import click
8
+ import httpx
9
+
10
+ from orithos_cli.config import get_config
11
+
12
+
13
+ @click.group(name="mcp")
14
+ def mcp_group() -> None:
15
+ """Interact with Orithos's MCP policy server."""
16
+ pass
17
+
18
+
19
+ @mcp_group.command("tools")
20
+ @click.option(
21
+ "--format",
22
+ "output_format",
23
+ type=click.Choice(["summary", "json"]),
24
+ default="summary",
25
+ )
26
+ def list_tools(output_format: str) -> None:
27
+ """List all available MCP tools."""
28
+ cfg = get_config()
29
+
30
+ try:
31
+ response = httpx.get(
32
+ f"{cfg.api_url}/v1/mcp/tools",
33
+ headers=cfg.auth_headers(),
34
+ timeout=cfg.timeout,
35
+ )
36
+ response.raise_for_status()
37
+ except httpx.HTTPStatusError as exc:
38
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
39
+ raise SystemExit(1)
40
+ except httpx.RequestError as exc:
41
+ click.echo(f"Connection error: {exc}", err=True)
42
+ raise SystemExit(1)
43
+
44
+ data = response.json()
45
+ tools = data.get("tools", [])
46
+
47
+ if output_format == "json":
48
+ click.echo(json.dumps(tools, indent=2))
49
+ return
50
+
51
+ click.echo(f"Orithos MCP Tools ({len(tools)} available)")
52
+ click.echo(f"{'=' * 60}")
53
+ for tool in tools:
54
+ click.echo(f"\n{tool['name']}")
55
+ click.echo(f" {tool['description']}")
56
+ input_schema = tool.get("input_schema", {})
57
+ props = input_schema.get("properties", {})
58
+ if props:
59
+ click.echo(" Parameters:")
60
+ for param_name, param_info in props.items():
61
+ required = param_name in input_schema.get("required", [])
62
+ req_mark = "*" if required else ""
63
+ click.echo(
64
+ f" {req_mark}{param_name}: {param_info.get('description', '—')}"
65
+ )
66
+
67
+
68
+ @mcp_group.command("evaluate")
69
+ @click.option("--tool-name", required=True, help="Tool name (e.g. filesystem.write)")
70
+ @click.option("--scope", default="internal", help="Scope (e.g. internal, external)")
71
+ @click.option("--action", default="read", help="Action (e.g. read, write, delete)")
72
+ def policy_evaluate(tool_name: str, scope: str, action: str) -> None:
73
+ """Evaluate a tool policy decision via MCP."""
74
+ cfg = get_config()
75
+
76
+ rpc_payload = {
77
+ "jsonrpc": "2.0",
78
+ "id": 1,
79
+ "method": "tools/call",
80
+ "params": {
81
+ "name": "orithos_policy_evaluate",
82
+ "arguments": {"tool_name": tool_name, "scope": scope, "action": action},
83
+ },
84
+ }
85
+
86
+ try:
87
+ response = httpx.post(
88
+ f"{cfg.api_url}/v1/mcp",
89
+ json=rpc_payload,
90
+ headers=cfg.auth_headers(),
91
+ timeout=cfg.timeout,
92
+ )
93
+ response.raise_for_status()
94
+ except httpx.HTTPStatusError as exc:
95
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
96
+ raise SystemExit(1)
97
+ except httpx.RequestError as exc:
98
+ click.echo(f"Connection error: {exc}", err=True)
99
+ raise SystemExit(1)
100
+
101
+ result = response.json()
102
+ tool_results = result.get("tools", [])
103
+ if tool_results:
104
+ first = tool_results[0]
105
+ output_schema = first.get("output_schema", {})
106
+ success = (
107
+ output_schema.get("properties", {}).get("success", {}).get("type")
108
+ == "boolean"
109
+ )
110
+ click.echo(f"Policy decision for {tool_name} ({scope}, {action}):")
111
+ click.echo(
112
+ f" Decision: {first.get('output_schema', {}).get('properties', {}).get('decision', {}).get('enum', ['N/A'])}"
113
+ )
114
+ click.echo(
115
+ f" Success: {first.get('output_schema', {}).get('properties', {}).get('success', 'N/A')}"
116
+ )
117
+ else:
118
+ click.echo(json.dumps(result, indent=2))
119
+
120
+
121
+ @mcp_group.command("risk-score")
122
+ @click.option(
123
+ "--tool-chain",
124
+ required=True,
125
+ help="Comma-separated tool chain (e.g. browser.fetch,memory.read,network.post)",
126
+ )
127
+ def risk_score(tool_chain: str) -> None:
128
+ """Score the risk of a tool chain via MCP."""
129
+ cfg = get_config()
130
+ tools = [t.strip() for t in tool_chain.split(",") if t.strip()]
131
+
132
+ rpc_payload = {
133
+ "jsonrpc": "2.0",
134
+ "id": 1,
135
+ "method": "tools/call",
136
+ "params": {
137
+ "name": "orithos_risk_score",
138
+ "arguments": {"tool_chain": tools},
139
+ },
140
+ }
141
+
142
+ try:
143
+ response = httpx.post(
144
+ f"{cfg.api_url}/v1/mcp",
145
+ json=rpc_payload,
146
+ headers=cfg.auth_headers(),
147
+ timeout=cfg.timeout,
148
+ )
149
+ response.raise_for_status()
150
+ except httpx.HTTPStatusError as exc:
151
+ click.echo(f"Error: {exc.response.status_code} — {exc.response.text}", err=True)
152
+ raise SystemExit(1)
153
+ except httpx.RequestError as exc:
154
+ click.echo(f"Connection error: {exc}", err=True)
155
+ raise SystemExit(1)
156
+
157
+ result = response.json()
158
+ tool_results = result.get("tools", [])
159
+ if tool_results:
160
+ output = tool_results[0].get("output_schema", {}).get("properties", {})
161
+ click.echo(f"Risk Score — tool chain: {tool_chain}")
162
+ click.echo(
163
+ f" Overall risk: {output.get('overall_risk', {}).get('enum', ['N/A'])}"
164
+ )
165
+ click.echo(f" Score: {output.get('score', 'N/A')}")
166
+ edges = output.get("vulnerable_edges", {}).get("items", {})
167
+ click.echo(f" Vulnerable edges: {output.get('vulnerable_edges', [])}")
168
+ click.echo(f" Risk factors: {output.get('risk_factors', [])}")
169
+ else:
170
+ click.echo(json.dumps(result, indent=2))
orithos_cli/output.py ADDED
@@ -0,0 +1,178 @@
1
+ """Output formatters for Orithos CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class FindingOutput:
12
+ id: str
13
+ scan_run_id: str
14
+ severity: str
15
+ attack_path: str
16
+ recommendation: str
17
+ created_at: str
18
+
19
+ def __init__(self, **kwargs):
20
+ self.id = kwargs.get("id", "")
21
+ self.scan_run_id = kwargs.get("scan_run_id", "")
22
+ self.severity = kwargs.get("severity", "low")
23
+ self.attack_path = kwargs.get("attack_path") or kwargs.get("name", "")
24
+ self.recommendation = kwargs.get("recommendation", "")
25
+ self.created_at = kwargs.get("created_at", "")
26
+
27
+
28
+ @dataclass
29
+ class ScanStatusOutput:
30
+ id: str
31
+ status: str
32
+ severity: str | None
33
+ total_tests: int
34
+ completed_tests: int
35
+
36
+
37
+ def format_summary(findings: list[FindingOutput], scan: ScanStatusOutput) -> str:
38
+ lines = [
39
+ "Orithos Scan Results",
40
+ f"{'=' * 50}",
41
+ f"Scan ID: {scan.id}",
42
+ f"Status: {scan.status}",
43
+ f"Severity: {scan.severity or 'N/A'}",
44
+ f"Tests: {scan.completed_tests}/{scan.total_tests}",
45
+ f"Findings: {len(findings)}",
46
+ ]
47
+ if findings:
48
+ lines.append("")
49
+ lines.append("Findings:")
50
+ for i, f in enumerate(findings, 1):
51
+ lines.append(f" [{i}] {f.severity.upper()}: {f.attack_path}")
52
+ lines.append(f" {f.recommendation}")
53
+ else:
54
+ lines.append("")
55
+ lines.append("No findings — agent is secure.")
56
+ return "\n".join(lines)
57
+
58
+
59
+ def format_json(findings: list[FindingOutput], scan: ScanStatusOutput) -> str:
60
+ return json.dumps(
61
+ {
62
+ "scan": {
63
+ "id": scan.id,
64
+ "status": scan.status,
65
+ "severity": scan.severity,
66
+ "total_tests": scan.total_tests,
67
+ "completed_tests": scan.completed_tests,
68
+ },
69
+ "findings": [
70
+ {
71
+ "id": f.id,
72
+ "scan_run_id": f.scan_run_id,
73
+ "severity": f.severity,
74
+ "attack_path": f.attack_path,
75
+ "recommendation": f.recommendation,
76
+ "created_at": f.created_at,
77
+ }
78
+ for f in findings
79
+ ],
80
+ },
81
+ indent=2,
82
+ )
83
+
84
+
85
+ def format_sarif(findings: list[FindingOutput], scan: ScanStatusOutput, api_url: str = "https://orithos.com") -> str:
86
+ results = []
87
+ severity_map = {"high": "error", "medium": "warning", "low": "note"}
88
+
89
+ for f in findings:
90
+ results.append(
91
+ {
92
+ "ruleId": f"TRACESHIELD-{f.severity.upper()}",
93
+ "severity": severity_map.get(f.severity, "note"),
94
+ "message": {"text": f.recommendation},
95
+ "locations": [
96
+ {
97
+ "physicalLocation": {
98
+ "artifactLocation": {"uri": f.attack_path},
99
+ "region": {"startLine": 1, "startColumn": 1},
100
+ }
101
+ }
102
+ ],
103
+ "properties": {
104
+ "attack_path": f.attack_path,
105
+ "scan_id": scan.id,
106
+ },
107
+ }
108
+ )
109
+
110
+ sarif = {
111
+ "version": "2.1.0",
112
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
113
+ "runs": [
114
+ {
115
+ "tool": {
116
+ "driver": {
117
+ "name": "Orithos",
118
+ "version": "0.1.0",
119
+ "informationUri": api_url,
120
+ "rules": [
121
+ {
122
+ "id": "TRACESHIELD-HIGH",
123
+ "name": "HighSeverityFinding",
124
+ "shortDescription": {
125
+ "text": "High severity security finding"
126
+ },
127
+ },
128
+ {
129
+ "id": "TRACESHIELD-MEDIUM",
130
+ "name": "MediumSeverityFinding",
131
+ "shortDescription": {
132
+ "text": "Medium severity security finding"
133
+ },
134
+ },
135
+ {
136
+ "id": "TRACESHIELD-LOW",
137
+ "name": "LowSeverityFinding",
138
+ "shortDescription": {
139
+ "text": "Low severity security finding"
140
+ },
141
+ },
142
+ ],
143
+ }
144
+ },
145
+ "results": results,
146
+ }
147
+ ],
148
+ }
149
+ return json.dumps(sarif, indent=2)
150
+
151
+
152
+ def format_junit(findings: list[FindingOutput], scan: ScanStatusOutput) -> str:
153
+ has_high = any(f.severity == "high" for f in findings)
154
+ has_medium = any(f.severity == "medium" for f in findings)
155
+
156
+ test_count = scan.total_tests
157
+ failure_count = len(findings)
158
+ error_count = 1 if has_high else 0
159
+
160
+ lines = [
161
+ '<?xml version="1.0" encoding="UTF-8"?>',
162
+ f'<testsuite name="Orithos" tests="{test_count}" failures="{failure_count}" errors="{error_count}" skipped="0">',
163
+ ]
164
+
165
+ for f in findings:
166
+ verdict = "failure" if f.severity in ("high", "medium") else "pass"
167
+ name = html.escape(f.attack_path)
168
+ msg = html.escape(f.attack_path)
169
+ rec = html.escape(f.recommendation)
170
+ lines.append(
171
+ f' <testcase classname="scan.{scan.id}" name="{name}" {verdict}="{f.severity}">'
172
+ )
173
+ if verdict == "failure":
174
+ lines.append(f' <{f.severity} message="{msg}">{rec}</{f.severity}>')
175
+ lines.append(" </testcase>")
176
+
177
+ lines.append("</testsuite>")
178
+ return "\n".join(lines)
orithos_cli/probes.py ADDED
@@ -0,0 +1,50 @@
1
+ """Probe catalog 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="probes")
12
+ def probes_group() -> None:
13
+ """List available security probes."""
14
+ pass
15
+
16
+
17
+ @probes_group.command("list")
18
+ @click.option("--category", default=None, help="Filter by attack category")
19
+ @click.option("--tag", default=None, help="Filter by compliance tag")
20
+ @click.option("--limit", default=30, help="Max results (default: 30)")
21
+ def list_probes(category: str | None, tag: str | None, limit: int) -> None:
22
+ """List available security probes from the probe catalog."""
23
+ cfg = get_config()
24
+ params = {}
25
+ if category:
26
+ params["category"] = category
27
+ if tag:
28
+ params["tag"] = tag
29
+ try:
30
+ resp = httpx.get(f"{cfg.api_url}/v1/probes", params=params, headers=cfg.auth_headers(), timeout=cfg.timeout)
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
+ items = data if isinstance(data, list) else data.get("items", [])
40
+ if not items:
41
+ click.echo("No probes found.")
42
+ return
43
+ click.echo(f"Probes ({len(items)} shown):")
44
+ for p in items[:limit]:
45
+ name = p.get("name", p.get("probe_key", "?"))
46
+ cat = p.get("category", p.get("attack_category", "?"))
47
+ severity = p.get("severity", p.get("severity_base", "?"))
48
+ sev_color = {"critical": "red", "high": "yellow", "medium": "blue", "low": "green"}.get(severity, "white")
49
+ click.secho(f" [{severity.upper()}] {name}", fg=sev_color)
50
+ click.echo(f" Category: {cat}")
@@ -0,0 +1,111 @@
1
+ """Remediation 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="remediation")
13
+ def remediation_group() -> None:
14
+ """Generate, verify, apply, and commit AI security remediations."""
15
+ pass
16
+
17
+
18
+ @remediation_group.command("generate")
19
+ @click.argument("finding-id")
20
+ def generate_remediation(finding_id: str) -> None:
21
+ """Generate a remediation fix for a finding.
22
+
23
+ FINDING_ID is the UUID of the finding to remediate.
24
+ """
25
+ cfg = get_config()
26
+ url = f"{cfg.api_url}/v1/remediation/generate"
27
+ resp = httpx.post(url, json={"finding_id": finding_id}, headers=cfg.auth_headers(), timeout=cfg.timeout)
28
+ resp.raise_for_status()
29
+ data = resp.json()
30
+ click.echo(json.dumps(data, indent=2))
31
+ if "remediation_id" in data:
32
+ click.echo(f"\nRemediation ID: {data['remediation_id']}")
33
+ click.echo(f"Type: {data.get('type', 'unknown')}")
34
+ if data.get("verification_probe"):
35
+ click.echo(f"Verification probe: {data['verification_probe'][:100]}...")
36
+ if data.get("expected_after_fix"):
37
+ click.echo(f"Expected after fix: {data['expected_after_fix'][:100]}...")
38
+
39
+
40
+ @remediation_group.command("verify")
41
+ @click.argument("remediation-id")
42
+ def verify_remediation(remediation_id: str) -> None:
43
+ """Run the verification probe against the live agent.
44
+
45
+ REMEDIATION_ID is the UUID of the remediation to verify.
46
+ """
47
+ cfg = get_config()
48
+ url = f"{cfg.api_url}/v1/remediation/{remediation_id}/verify"
49
+ resp = httpx.post(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
50
+ resp.raise_for_status()
51
+ data = resp.json()
52
+ status = data.get("verification_status", "unknown")
53
+ color = "green" if status == "resolved" else ("yellow" if status == "partial" else "red")
54
+ click.secho(f"Verification status: {status}", fg=color)
55
+ if data.get("details"):
56
+ click.echo(f"Details: {data['details'][:500]}")
57
+
58
+
59
+ @remediation_group.command("apply")
60
+ @click.argument("remediation-id")
61
+ def apply_remediation(remediation_id: str) -> None:
62
+ """Mark a remediation as applied (user has implemented the patch)."""
63
+ cfg = get_config()
64
+ url = f"{cfg.api_url}/v1/remediation/{remediation_id}/apply"
65
+ resp = httpx.post(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
66
+ resp.raise_for_status()
67
+ click.echo("Remediation marked as applied.")
68
+
69
+
70
+ @remediation_group.command("commit")
71
+ @click.argument("remediation-id")
72
+ @click.option("--repo", required=True, help="GitHub repo URL (e.g. https://github.com/org/repo)")
73
+ @click.option("--branch", default="main", help="Target branch (default: main)")
74
+ @click.option("--token", envvar="GITHUB_TOKEN", required=True, help="GitHub personal access token")
75
+ def commit_remediation(remediation_id: str, repo: str, branch: str, token: str) -> None:
76
+ """Create a GitHub PR with the remediation patch applied.
77
+
78
+ REMEDIATION_ID is the UUID of the remediation to commit.
79
+ """
80
+ cfg = get_config()
81
+ url = f"{cfg.api_url}/v1/remediation/{remediation_id}/commit"
82
+ resp = httpx.post(
83
+ url,
84
+ json={"repo_url": repo, "branch": branch, "github_token": token},
85
+ headers=cfg.auth_headers(),
86
+ timeout=cfg.timeout,
87
+ )
88
+ resp.raise_for_status()
89
+ data = resp.json()
90
+ click.secho("✅ PR created successfully!", fg="green")
91
+ click.echo(f"PR URL: {data.get('pr_url', 'N/A')}")
92
+ click.echo(f"PR Number: {data.get('pr_number', 'N/A')}")
93
+ click.echo(f"Branch: {data.get('branch', 'N/A')}")
94
+
95
+
96
+ @remediation_group.command("list")
97
+ @click.argument("finding-id")
98
+ def list_remediations(finding_id: str) -> None:
99
+ """List all remediations for a finding."""
100
+ cfg = get_config()
101
+ url = f"{cfg.api_url}/v1/remediation/finding/{finding_id}"
102
+ resp = httpx.get(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
103
+ resp.raise_for_status()
104
+ data = resp.json()
105
+ if not data:
106
+ click.echo("No remediations found for this finding.")
107
+ return
108
+ for r in data:
109
+ click.secho(f"[{r.get('remediation_type', '?')}] {r['id'][:8]}", bold=True)
110
+ click.echo(f" Status: {r.get('status', '?')} Verification: {r.get('verification_status', 'untested')}")
111
+ click.echo(f" Created: {r.get('created_at', '?')}")
orithos_cli/runtime.py ADDED
@@ -0,0 +1,100 @@
1
+ """Runtime evaluation and alerting 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="runtime")
12
+ def runtime_group() -> None:
13
+ """Evaluate agent outputs and manage runtime alerts."""
14
+ pass
15
+
16
+
17
+ @runtime_group.command("evaluate")
18
+ @click.option("--prompt", required=True, help="The user prompt sent to the agent")
19
+ @click.option("--response", required=True, help="The agent's response text")
20
+ @click.option("--agent-id", default=None, help="Optional agent ID for context")
21
+ def evaluate_output(prompt: str, response: str, agent_id: str | None) -> None:
22
+ """Evaluate a prompt+response pair against active guardrails."""
23
+ cfg = get_config()
24
+ url = f"{cfg.api_url}/v1/runtime/evaluate"
25
+ payload = {"prompt": prompt, "response": response}
26
+ if agent_id:
27
+ payload["agent_id"] = agent_id
28
+ resp = httpx.post(url, json=payload, headers=cfg.auth_headers(), timeout=cfg.timeout)
29
+ resp.raise_for_status()
30
+ data = resp.json()
31
+ verdict_color = {"allow": "green", "review": "yellow", "block": "red"}.get(data.get("verdict", ""), "white")
32
+ click.secho(f"Verdict: {data.get('verdict', 'unknown')}", bold=True, fg=verdict_color)
33
+ click.echo(f"Severity: {data.get('severity', 'unknown')}")
34
+ if data.get("violated_rules"):
35
+ click.echo(f"Violated rules: {', '.join(data['violated_rules'])}")
36
+ if data.get("reason"):
37
+ click.echo(f"Reason: {data['reason']}")
38
+
39
+
40
+ @click.group(name="alerting")
41
+ def alerting_group() -> None:
42
+ """Manage alerting rules and view anomaly events."""
43
+ pass
44
+
45
+
46
+ @alerting_group.command("rules")
47
+ @click.option("--list", "list_rules", is_flag=True, help="List all alert rules")
48
+ def list_alert_rules(list_rules: bool) -> None:
49
+ """List or manage alert rules."""
50
+ cfg = get_config()
51
+ url = f"{cfg.api_url}/v1/alerting/rules"
52
+ resp = httpx.get(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
53
+ resp.raise_for_status()
54
+ data = resp.json()
55
+ if not data:
56
+ click.echo("No alert rules configured.")
57
+ return
58
+ for r in data:
59
+ enabled_color = "green" if r.get("enabled") else "red"
60
+ click.secho(f"[{'ON' if r.get('enabled') else 'OFF'}] {r['name']}", fg=enabled_color)
61
+ click.echo(f" Type: {r.get('rule_type', '?')} ID: {r['id'][:8]}")
62
+ if r.get("last_triggered_at"):
63
+ click.echo(f" Last triggered: {r['last_triggered_at']}")
64
+
65
+
66
+ @alerting_group.command("evaluate")
67
+ def evaluate_alert_rules() -> None:
68
+ """Evaluate all enabled alert rules and fire notifications."""
69
+ cfg = get_config()
70
+ url = f"{cfg.api_url}/v1/alerting/evaluate"
71
+ resp = httpx.post(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
72
+ resp.raise_for_status()
73
+ data = resp.json()
74
+ triggered = data.get("triggered", [])
75
+ if triggered:
76
+ click.secho(f"{len(triggered)} rule(s) triggered:", bold=True, fg="yellow")
77
+ for t in triggered:
78
+ click.echo(f" {t.get('name', '?')} ({t.get('type', '?')})")
79
+ else:
80
+ click.echo("No rules triggered.")
81
+
82
+
83
+ @alerting_group.command("anomalies")
84
+ @click.option("--limit", default=20, help="Max results (default: 20)")
85
+ def list_anomalies(limit: int) -> None:
86
+ """List recent anomaly events."""
87
+ cfg = get_config()
88
+ url = f"{cfg.api_url}/v1/alerting/anomalies?limit={limit}"
89
+ resp = httpx.get(url, headers=cfg.auth_headers(), timeout=cfg.timeout)
90
+ resp.raise_for_status()
91
+ data = resp.json()
92
+ items = data.get("items", [])
93
+ if not items:
94
+ click.echo("No anomalies detected.")
95
+ return
96
+ for a in items:
97
+ sev_color = {"critical": "red", "high": "yellow", "medium": "blue"}.get(a.get("severity", ""), "white")
98
+ click.secho(f"[{a.get('severity', '?').upper()}] {a.get('anomaly_type', '?')}", fg=sev_color)
99
+ click.echo(f" {a.get('description', '')[:120]}")
100
+ click.echo(f" {a.get('created_at', '')}")