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/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', '')}")
|