devopsiq 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.
- agent/__init__.py +5 -0
- agent/agent.py +232 -0
- agent/investigation.py +339 -0
- agent/prompts.py +125 -0
- agent/store.py +147 -0
- devopsiq-0.1.0.dist-info/METADATA +662 -0
- devopsiq-0.1.0.dist-info/RECORD +30 -0
- devopsiq-0.1.0.dist-info/WHEEL +5 -0
- devopsiq-0.1.0.dist-info/entry_points.txt +2 -0
- devopsiq-0.1.0.dist-info/licenses/LICENSE +21 -0
- devopsiq-0.1.0.dist-info/top_level.txt +3 -0
- main.py +310 -0
- tools/__init__.py +6 -0
- tools/ansible.py +110 -0
- tools/argocd.py +91 -0
- tools/base.py +112 -0
- tools/cloud.py +101 -0
- tools/docker.py +280 -0
- tools/git_ci.py +257 -0
- tools/helm.py +168 -0
- tools/investigation.py +357 -0
- tools/istio.py +43 -0
- tools/kubernetes.py +464 -0
- tools/monitoring.py +162 -0
- tools/newrelic.py +167 -0
- tools/preflight.py +59 -0
- tools/registry.py +49 -0
- tools/system.py +162 -0
- tools/terraform.py +90 -0
- tools/trivy.py +83 -0
tools/newrelic.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Read-only New Relic tools (Phase 9).
|
|
2
|
+
|
|
3
|
+
Query New Relic's NerdGraph GraphQL API over curl: NRQL queries
|
|
4
|
+
(newrelic_nrql) and the account's open alert incidents (newrelic_alerts).
|
|
5
|
+
This extends the tools/monitoring.py pattern from GET to POST — the argv is
|
|
6
|
+
still fixed, but here curl carries a `-d` payload built by json.dumps, so
|
|
7
|
+
quoting is safe and the NRQL text can never break out of its JSON string.
|
|
8
|
+
|
|
9
|
+
Credentials come from ENVIRONMENT CONFIGURATION only, never the model:
|
|
10
|
+
- NEW_RELIC_API_KEY — a NerdGraph (user) API key; unset → honest ToolError
|
|
11
|
+
naming the variable. It is passed to curl as a header value only.
|
|
12
|
+
- NEW_RELIC_ACCOUNT_ID — the numeric account id; must be digits (it is
|
|
13
|
+
interpolated into the GraphQL variables as an integer, so a non-digit
|
|
14
|
+
value is refused rather than smuggled in).
|
|
15
|
+
|
|
16
|
+
The model supplies ONLY the NRQL text (bounded length). newrelic_alerts
|
|
17
|
+
takes no model input at all — its payload is byte-identical every call.
|
|
18
|
+
Nothing here can create, acknowledge or close an incident; NerdGraph
|
|
19
|
+
mutations are never sent (the GraphQL documents below are queries only).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
from tools.base import Tool, ToolError, read_command_output
|
|
26
|
+
from tools.registry import register
|
|
27
|
+
|
|
28
|
+
_TIMEOUT_S = 20
|
|
29
|
+
_MAX_QUERY_CHARS = 500
|
|
30
|
+
_NERDGRAPH_URL = "https://api.newrelic.com/graphql"
|
|
31
|
+
|
|
32
|
+
# Queries use GraphQL variables ($account, $nrql) — the payload is built by
|
|
33
|
+
# json.dumps, so no NRQL text can escape its string slot.
|
|
34
|
+
_NRQL_QUERY = (
|
|
35
|
+
"query ($accountId: Int!, $nrql: Nrql!) {"
|
|
36
|
+
" actor {"
|
|
37
|
+
" account(id: $accountId) {"
|
|
38
|
+
" nrql(query: $nrql) {"
|
|
39
|
+
" results"
|
|
40
|
+
" metadata { facets queries}"
|
|
41
|
+
" }"
|
|
42
|
+
" }"
|
|
43
|
+
" }"
|
|
44
|
+
"}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_ALERTS_QUERY = (
|
|
48
|
+
"query ($accountId: Int!) {"
|
|
49
|
+
" actor {"
|
|
50
|
+
" account(id: $accountId) {"
|
|
51
|
+
" alerts {"
|
|
52
|
+
" incidents(filter: {states: [ACTIVE, ACKNOWLEDGED]}) {"
|
|
53
|
+
" incidentId title priority state startedAt"
|
|
54
|
+
" labels { key value }"
|
|
55
|
+
" }"
|
|
56
|
+
" }"
|
|
57
|
+
" }"
|
|
58
|
+
" }"
|
|
59
|
+
"}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _checked_query(args: dict, label: str) -> str:
|
|
64
|
+
query = args.get(label)
|
|
65
|
+
if not isinstance(query, str) or not query.strip():
|
|
66
|
+
raise ToolError(f"{label} must be a non-empty NRQL query string")
|
|
67
|
+
if len(query) > _MAX_QUERY_CHARS:
|
|
68
|
+
raise ToolError(f"{label} must be at most {_MAX_QUERY_CHARS} characters")
|
|
69
|
+
return query
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _credentials() -> tuple[str, int]:
|
|
73
|
+
api_key = os.environ.get("NEW_RELIC_API_KEY", "").strip()
|
|
74
|
+
if not api_key:
|
|
75
|
+
raise ToolError(
|
|
76
|
+
"no New Relic credentials configured: set the NEW_RELIC_API_KEY "
|
|
77
|
+
"environment variable (a NerdGraph user key) and retry"
|
|
78
|
+
)
|
|
79
|
+
account_id = os.environ.get("NEW_RELIC_ACCOUNT_ID", "").strip()
|
|
80
|
+
if not account_id:
|
|
81
|
+
raise ToolError(
|
|
82
|
+
"no New Relic account configured: set the "
|
|
83
|
+
"NEW_RELIC_ACCOUNT_ID environment variable (digits only) and retry"
|
|
84
|
+
)
|
|
85
|
+
if not account_id.isdigit():
|
|
86
|
+
raise ToolError(
|
|
87
|
+
"NEW_RELIC_ACCOUNT_ID must be the numeric account id "
|
|
88
|
+
f"(got {account_id!r})"
|
|
89
|
+
)
|
|
90
|
+
return api_key, int(account_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _nerdgraph(api_key: str, payload: str) -> str:
|
|
94
|
+
return read_command_output(
|
|
95
|
+
(
|
|
96
|
+
"curl", "-sS", "--max-time", "15",
|
|
97
|
+
"-H", f"API-Key: {api_key}",
|
|
98
|
+
"-H", "content-type: application/json",
|
|
99
|
+
"-d", payload,
|
|
100
|
+
_NERDGRAPH_URL,
|
|
101
|
+
),
|
|
102
|
+
timeout=_TIMEOUT_S,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _newrelic_nrql(args: dict) -> str:
|
|
107
|
+
nrql = _checked_query(args, "query")
|
|
108
|
+
api_key, account_id = _credentials()
|
|
109
|
+
payload = json.dumps({
|
|
110
|
+
"query": _NRQL_QUERY,
|
|
111
|
+
"variables": {"accountId": account_id, "nrql": nrql},
|
|
112
|
+
})
|
|
113
|
+
return _nerdgraph(api_key, payload)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _newrelic_alerts(args: dict) -> str:
|
|
117
|
+
# No model input reaches the payload — args are accepted and ignored.
|
|
118
|
+
api_key, account_id = _credentials()
|
|
119
|
+
payload = json.dumps({
|
|
120
|
+
"query": _ALERTS_QUERY,
|
|
121
|
+
"variables": {"accountId": account_id},
|
|
122
|
+
})
|
|
123
|
+
return _nerdgraph(api_key, payload)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
NEWRELIC_NRQL = Tool(
|
|
127
|
+
name="newrelic_nrql",
|
|
128
|
+
description=(
|
|
129
|
+
"Run one NRQL query against the configured New Relic account "
|
|
130
|
+
"(NEW_RELIC_API_KEY + NEW_RELIC_ACCOUNT_ID env) via the NerdGraph "
|
|
131
|
+
"API and return the JSON results. Use for APM/infra evidence: "
|
|
132
|
+
"error rates, response times, throughput, host resource metrics, "
|
|
133
|
+
"e.g. 'SELECT average(duration) FROM Transaction WHERE "
|
|
134
|
+
"appName = 'api' TIMESERIES SINCE 30 minutes ago'. Query text "
|
|
135
|
+
"only; credentials and account are fixed by the operator's "
|
|
136
|
+
"environment. Read-only."
|
|
137
|
+
),
|
|
138
|
+
parameters={
|
|
139
|
+
"type": "object",
|
|
140
|
+
"properties": {
|
|
141
|
+
"query": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "NRQL query text, e.g. 'SELECT count(*) "
|
|
144
|
+
"FROM Transaction SINCE 1 hour ago'.",
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
"required": ["query"],
|
|
148
|
+
"additionalProperties": False,
|
|
149
|
+
},
|
|
150
|
+
executor=_newrelic_nrql,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
NEWRELIC_ALERTS = Tool(
|
|
154
|
+
name="newrelic_alerts",
|
|
155
|
+
description=(
|
|
156
|
+
"List the New Relic account's OPEN alert incidents (ACTIVE or "
|
|
157
|
+
"ACKNOWLEDGED) as JSON via NerdGraph: incident id, title, priority, "
|
|
158
|
+
"state, start time, entity labels. Use as the first step when an "
|
|
159
|
+
"alert fires — 'what is alerting right now'. No parameters; "
|
|
160
|
+
"credentials come from the environment. Read-only."
|
|
161
|
+
),
|
|
162
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
163
|
+
executor=_newrelic_alerts,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
register(NEWRELIC_NRQL)
|
|
167
|
+
register(NEWRELIC_ALERTS)
|
tools/preflight.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Read-only host inspection tool — the first *real* tool.
|
|
2
|
+
|
|
3
|
+
This proves the whole tool loop (model requests a tool -> local executor
|
|
4
|
+
runs a real command -> real output is fed back as evidence) while staying
|
|
5
|
+
strictly read-only.
|
|
6
|
+
|
|
7
|
+
Safety model (defense in depth):
|
|
8
|
+
- The model may only choose among the static commands below; its parameters
|
|
9
|
+
select an allowlisted command and can never carry arbitrary command text.
|
|
10
|
+
- The executor re-validates every argument against the allowlist before
|
|
11
|
+
running anything. Never trust the model's arguments.
|
|
12
|
+
- All commands are read-only and harmless: date, uname, uptime, df, free.
|
|
13
|
+
Mutating verbs cannot appear here by construction.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from tools.base import Tool, ToolError, read_command_output
|
|
17
|
+
from tools.registry import register
|
|
18
|
+
|
|
19
|
+
# Static, allowlisted invocations. NEVER interpolate model-provided text here.
|
|
20
|
+
_COMMANDS: dict[str, tuple[str, ...]] = {
|
|
21
|
+
"date_utc": ("date", "-u", "+%Y-%m-%dT%H:%M:%SZ"),
|
|
22
|
+
"uname": ("uname", "-a"),
|
|
23
|
+
"uptime": ("uptime",),
|
|
24
|
+
"disk_usage": ("df", "-h", "-T"),
|
|
25
|
+
"memory": ("free", "-h"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run_allowlisted(args: dict) -> str:
|
|
30
|
+
command_key = args.get("command")
|
|
31
|
+
if not isinstance(command_key, str) or command_key not in _COMMANDS:
|
|
32
|
+
allowed = ", ".join(sorted(_COMMANDS))
|
|
33
|
+
raise ToolError(f"unknown command {command_key!r}; allowed commands: {allowed}")
|
|
34
|
+
return read_command_output(_COMMANDS[command_key])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SYSTEM_INFO = Tool(
|
|
38
|
+
name="system_info",
|
|
39
|
+
description=(
|
|
40
|
+
"Inspect the host machine the agent runs on. Returns one read-only "
|
|
41
|
+
"fact: current UTC timestamp, OS/kernel info, uptime, disk usage, or "
|
|
42
|
+
"memory usage."
|
|
43
|
+
),
|
|
44
|
+
parameters={
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": {
|
|
47
|
+
"command": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"enum": sorted(_COMMANDS),
|
|
50
|
+
"description": "Which read-only fact to collect from the host.",
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"required": ["command"],
|
|
54
|
+
"additionalProperties": False,
|
|
55
|
+
},
|
|
56
|
+
executor=_run_allowlisted,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
register(SYSTEM_INFO)
|
tools/registry.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tool registry: the single place tools are declared and executed.
|
|
2
|
+
|
|
3
|
+
Tools self-register by calling `register(tool)` at import time (see
|
|
4
|
+
tools/preflight.py). The agent pulls the full list with `get_tools()` and
|
|
5
|
+
executes model-requested calls through `execute_tool()`, which never raises:
|
|
6
|
+
every failure is converted into an error string the model can read and adapt
|
|
7
|
+
to, keeping the conversation loop alive.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from tools.base import Tool, ToolError
|
|
13
|
+
|
|
14
|
+
_TOOLS: dict[str, Tool] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(tool: Tool) -> None:
|
|
18
|
+
if tool.name in _TOOLS:
|
|
19
|
+
raise ValueError(f"a tool named {tool.name!r} is already registered")
|
|
20
|
+
_TOOLS[tool.name] = tool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_tools() -> list[Tool]:
|
|
24
|
+
return list(_TOOLS.values())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def execute_tool(name: str, arguments: str) -> str:
|
|
28
|
+
"""Execute one tool call and return the text to feed back to the model.
|
|
29
|
+
|
|
30
|
+
Never raises. Failures (unknown tool, unparseable arguments, executor
|
|
31
|
+
errors) are returned as "Tool error: ..." strings so the model can read
|
|
32
|
+
them, apologize, or re-request with valid arguments.
|
|
33
|
+
"""
|
|
34
|
+
tool = _TOOLS.get(name)
|
|
35
|
+
if tool is None:
|
|
36
|
+
known = ", ".join(sorted(_TOOLS)) or "none"
|
|
37
|
+
return f"Tool error: unknown tool {name!r}. Available tools: {known}."
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
args = json.loads(arguments) if arguments and arguments.strip() else {}
|
|
41
|
+
if not isinstance(args, dict):
|
|
42
|
+
raise TypeError("tool arguments must be a JSON object")
|
|
43
|
+
return tool.executor(args)
|
|
44
|
+
except json.JSONDecodeError as exc:
|
|
45
|
+
return f"Tool error: could not parse arguments JSON: {exc}"
|
|
46
|
+
except ToolError as exc:
|
|
47
|
+
return f"Tool error: {exc}"
|
|
48
|
+
except Exception as exc: # noqa: BLE001 - last resort; keep the loop alive
|
|
49
|
+
return f"Tool error: {type(exc).__name__}: {exc}"
|
tools/system.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Read-only Linux system tools (Phase 5).
|
|
2
|
+
|
|
3
|
+
Four tools for on-host operational problems: service status (systemctl),
|
|
4
|
+
service journal logs (journalctl), open TCP ports (ss), and the top CPU
|
|
5
|
+
consumers (ps). Same contract and safety model as the other modules.
|
|
6
|
+
|
|
7
|
+
Safety model:
|
|
8
|
+
- Only fixed argv templates are ever built: `systemctl status <unit>
|
|
9
|
+
--no-pager`, `journalctl -u <unit> --no-pager -n <lines>`, `ss -tlnp`,
|
|
10
|
+
`ps aux --sort=-%cpu --no-headers`. No free-form command text, no shell.
|
|
11
|
+
- The unit name is validated against systemd's allowed character set
|
|
12
|
+
(letters, digits, '.', '-', '_', '@', ':') with no leading dash — this
|
|
13
|
+
blocks path separators ('/'), flag injection ("-l"), and garbage.
|
|
14
|
+
- Every command is read-only: status/log inspection and listing, nothing
|
|
15
|
+
that starts, stops, edits, or reloads a unit.
|
|
16
|
+
- systemctl/journalctl/ss/ps must be installed on the host; if missing the
|
|
17
|
+
exact error is returned — nothing is invented.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from tools.base import Tool, ToolError, read_command_output
|
|
23
|
+
from tools.registry import register
|
|
24
|
+
|
|
25
|
+
# systemd unit names: letters, digits, and '.', '-', '_', '@', ':' — no '/',
|
|
26
|
+
# no leading dash, max 255 chars.
|
|
27
|
+
_UNIT_RE = re.compile(r"[A-Za-z0-9](?:[A-Za-z0-9._@:\-]{0,253})?")
|
|
28
|
+
_UNIT_LIMIT = 255
|
|
29
|
+
|
|
30
|
+
# journalctl can sit waiting on a busy journal; bound every call.
|
|
31
|
+
_TIMEOUT_S = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_unit(value, kind: str) -> str:
|
|
35
|
+
if (
|
|
36
|
+
not isinstance(value, str)
|
|
37
|
+
or len(value) > _UNIT_LIMIT
|
|
38
|
+
or not _UNIT_RE.fullmatch(value)
|
|
39
|
+
):
|
|
40
|
+
raise ToolError(
|
|
41
|
+
f"invalid systemd unit name {value!r}: expected letters, digits, "
|
|
42
|
+
"'.', '-', '_', '@' or ':', max 255 chars (e.g. 'sshd', "
|
|
43
|
+
"'docker.service', 'ssh@0.0.0.0:22')"
|
|
44
|
+
)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sys_service_status(args: dict) -> str:
|
|
49
|
+
unit = _check_unit(args.get("unit"), "unit")
|
|
50
|
+
return read_command_output(
|
|
51
|
+
("systemctl", "status", unit, "--no-pager"), timeout=_TIMEOUT_S
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _sys_service_logs(args: dict) -> str:
|
|
56
|
+
unit = _check_unit(args.get("unit"), "unit")
|
|
57
|
+
try:
|
|
58
|
+
lines = int(args.get("lines", 100))
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
raise ToolError("lines must be an integer between 1 and 500")
|
|
61
|
+
if not 1 <= lines <= 500:
|
|
62
|
+
raise ToolError("lines must be between 1 and 500")
|
|
63
|
+
return read_command_output(
|
|
64
|
+
("journalctl", "-u", unit, "--no-pager", "-n", str(lines)),
|
|
65
|
+
timeout=_TIMEOUT_S,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _sys_open_ports(args: dict) -> str:
|
|
70
|
+
return read_command_output(("ss", "-tlnp"), timeout=_TIMEOUT_S)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _sys_top_processes(args: dict) -> str:
|
|
74
|
+
return read_command_output(
|
|
75
|
+
("ps", "aux", "--sort=-%cpu", "--no-headers"), timeout=_TIMEOUT_S
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
SYS_SERVICE_STATUS = Tool(
|
|
80
|
+
name="sys_service_status",
|
|
81
|
+
description=(
|
|
82
|
+
"Fetch the status of one systemd unit (service, socket, target, ...): "
|
|
83
|
+
"loaded/active state, Main PID, and the most recent journal lines. The "
|
|
84
|
+
"primary tool for 'why is this service down / restarting'. Read-only."
|
|
85
|
+
),
|
|
86
|
+
parameters={
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {
|
|
89
|
+
"unit": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"description": "Unit name, e.g. 'sshd', 'docker.service', "
|
|
92
|
+
"'multi-user.target', 'ssh@0.0.0.0:22'.",
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"required": ["unit"],
|
|
96
|
+
"additionalProperties": False,
|
|
97
|
+
},
|
|
98
|
+
executor=_sys_service_status,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
SYS_SERVICE_LOGS = Tool(
|
|
102
|
+
name="sys_service_logs",
|
|
103
|
+
description=(
|
|
104
|
+
"Fetch the tail of a systemd unit's journal logs via journalctl. Use "
|
|
105
|
+
"with sys_service_status to see the actual error lines a service wrote "
|
|
106
|
+
"before failing/restarting. Read-only."
|
|
107
|
+
),
|
|
108
|
+
parameters={
|
|
109
|
+
"type": "object",
|
|
110
|
+
"properties": {
|
|
111
|
+
"unit": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"description": "Unit name, e.g. 'sshd', 'docker.service'.",
|
|
114
|
+
},
|
|
115
|
+
"lines": {
|
|
116
|
+
"type": "integer",
|
|
117
|
+
"minimum": 1,
|
|
118
|
+
"maximum": 500,
|
|
119
|
+
"default": 100,
|
|
120
|
+
"description": "How many lines to tail (1–500).",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
"required": ["unit"],
|
|
124
|
+
"additionalProperties": False,
|
|
125
|
+
},
|
|
126
|
+
executor=_sys_service_logs,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
SYS_OPEN_PORTS = Tool(
|
|
130
|
+
name="sys_open_ports",
|
|
131
|
+
description=(
|
|
132
|
+
"List TCP ports currently listening on the host (ss -tlnp), with the "
|
|
133
|
+
"process that owns each socket. Use for 'is port X in use', 'what is "
|
|
134
|
+
"bound to this port', port conflicts. Read-only."
|
|
135
|
+
),
|
|
136
|
+
parameters={
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {},
|
|
139
|
+
"additionalProperties": False,
|
|
140
|
+
},
|
|
141
|
+
executor=_sys_open_ports,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
SYS_TOP_PROCESSES = Tool(
|
|
145
|
+
name="sys_top_processes",
|
|
146
|
+
description=(
|
|
147
|
+
"List the processes consuming the most CPU on the host (ps aux sorted "
|
|
148
|
+
"by %cpu, no header). Use for 'what is eating CPU', runaway processes. "
|
|
149
|
+
"Read-only."
|
|
150
|
+
),
|
|
151
|
+
parameters={
|
|
152
|
+
"type": "object",
|
|
153
|
+
"properties": {},
|
|
154
|
+
"additionalProperties": False,
|
|
155
|
+
},
|
|
156
|
+
executor=_sys_top_processes,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
register(SYS_SERVICE_STATUS)
|
|
160
|
+
register(SYS_SERVICE_LOGS)
|
|
161
|
+
register(SYS_OPEN_PORTS)
|
|
162
|
+
register(SYS_TOP_PROCESSES)
|
tools/terraform.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Read-only Terraform tools (Phase 5).
|
|
2
|
+
|
|
3
|
+
Three tools for infrastructure-as-code problems: what the current state holds
|
|
4
|
+
(terraform state list), what a plan would change (terraform plan), and a
|
|
5
|
+
human-readable render of the state (terraform show). All operate on the
|
|
6
|
+
current working directory the agent was launched from.
|
|
7
|
+
|
|
8
|
+
Safety model:
|
|
9
|
+
- Only `show`, `state list`, and `plan` exist. There is no apply, destroy,
|
|
10
|
+
import, force-unlock, refresh (mutating), workspace delete, or taint.
|
|
11
|
+
- `plan` is a dry run: it computes the diff but changes nothing. Pinned to
|
|
12
|
+
`-input=false` so it can never sit waiting on a prompt, and `-no-color`
|
|
13
|
+
keeps output model-readable.
|
|
14
|
+
- A generous timeout (60s for plan — providers may need to fetch schemas)
|
|
15
|
+
bounds slow modules.
|
|
16
|
+
- terraform must be installed and the directory must be initialized
|
|
17
|
+
(`terraform init`). If not, the exact error is returned — nothing is
|
|
18
|
+
invented. On a fresh directory the model sees terraform's own
|
|
19
|
+
"Please run 'terraform init'" and must report it, not fabricate state.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from tools.base import Tool, ToolError, read_command_output
|
|
23
|
+
from tools.registry import register
|
|
24
|
+
|
|
25
|
+
# Plan can take much longer than the default 10s on a real module (provider
|
|
26
|
+
# schema downloads); everything else stays snappy.
|
|
27
|
+
_PLAN_TIMEOUT_S = 60
|
|
28
|
+
_OTHER_TIMEOUT_S = 15
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tf_show(args: dict) -> str:
|
|
32
|
+
return read_command_output(
|
|
33
|
+
("terraform", "show", "-no-color"), timeout=_OTHER_TIMEOUT_S
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tf_state_list(args: dict) -> str:
|
|
38
|
+
return read_command_output(
|
|
39
|
+
("terraform", "state", "list"), timeout=_OTHER_TIMEOUT_S
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _tf_plan(args: dict) -> str:
|
|
44
|
+
return read_command_output(
|
|
45
|
+
("terraform", "plan", "-no-color", "-input=false"),
|
|
46
|
+
timeout=_PLAN_TIMEOUT_S,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
TF_SHOW = Tool(
|
|
51
|
+
name="tf_show",
|
|
52
|
+
description=(
|
|
53
|
+
"Render the current Terraform state of the working directory "
|
|
54
|
+
"(terraform show): resources with their attribute values as stored. "
|
|
55
|
+
"Use to see what IaC currently believes exists. Requires an "
|
|
56
|
+
"initialized directory. Read-only."
|
|
57
|
+
),
|
|
58
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
59
|
+
executor=_tf_show,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
TF_STATE_LIST = Tool(
|
|
63
|
+
name="tf_state_list",
|
|
64
|
+
description=(
|
|
65
|
+
"List every resource address in the Terraform state of the working "
|
|
66
|
+
"directory (terraform state list), e.g. aws_instance.web. Use to see "
|
|
67
|
+
"what infrastructure is tracked and spot drift from config. "
|
|
68
|
+
"Read-only."
|
|
69
|
+
),
|
|
70
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
71
|
+
executor=_tf_state_list,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
TF_PLAN = Tool(
|
|
75
|
+
name="tf_plan",
|
|
76
|
+
description=(
|
|
77
|
+
"Dry-run Terraform plan for the working directory "
|
|
78
|
+
"(terraform plan -no-color -input=false): prints what would be "
|
|
79
|
+
"added/changed/destroyed — WITHOUT changing anything. Use to assess "
|
|
80
|
+
"the impact of local config changes or detect drift. Requires an "
|
|
81
|
+
"initialized directory. Read-only; may take up to 60s on large "
|
|
82
|
+
"modules."
|
|
83
|
+
),
|
|
84
|
+
parameters={"type": "object", "properties": {}, "additionalProperties": False},
|
|
85
|
+
executor=_tf_plan,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
register(TF_SHOW)
|
|
89
|
+
register(TF_STATE_LIST)
|
|
90
|
+
register(TF_PLAN)
|
tools/trivy.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Read-only security-scanning tools (Phase 9).
|
|
2
|
+
|
|
3
|
+
One tool: trivy image scan — a vulnerability report for a container image
|
|
4
|
+
(`trivy image --scanners vuln --format table <image>`). Trivy scans are
|
|
5
|
+
read-only: they pull the image and the vulnerability database and print a
|
|
6
|
+
report; nothing on this host or the registry is modified.
|
|
7
|
+
|
|
8
|
+
Safety model:
|
|
9
|
+
- Fixed argv template — the model may only choose the image reference.
|
|
10
|
+
- Image references are validated: letters, digits, '.', '-', '_', '/', ':',
|
|
11
|
+
'@' only; must start (and end) alphanumeric, no leading '-', max 200
|
|
12
|
+
characters. This blocks flag injection (refs starting with '-') and
|
|
13
|
+
anything shell-ish — and there is no shell anyway (argv only).
|
|
14
|
+
- The subprocess timeout is long (180s) because a cold trivy download of
|
|
15
|
+
the vulnerability database can take a while; the tool says so in its
|
|
16
|
+
description so the model can warn the user.
|
|
17
|
+
- trivy must be installed; if missing, the exact error is returned.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from tools.base import Tool, ToolError, read_command_output
|
|
23
|
+
from tools.registry import register
|
|
24
|
+
|
|
25
|
+
# Container image reference: registry/repo:tag@digest shape, alnum at the
|
|
26
|
+
# ends, no leading '-' (no flag injection), no spaces.
|
|
27
|
+
_IMAGE_RE = re.compile(
|
|
28
|
+
r"[a-zA-Z0-9][a-zA-Z0-9._@/\-]*(?::[a-zA-Z0-9._\-]+)?"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
_TIMEOUT_S = 180
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _check_image(value, kind: str = "image reference") -> str:
|
|
35
|
+
if (
|
|
36
|
+
not isinstance(value, str)
|
|
37
|
+
or not 1 <= len(value) <= 200
|
|
38
|
+
or not _IMAGE_RE.fullmatch(value)
|
|
39
|
+
or not value[-1].isalnum()
|
|
40
|
+
or " " in value
|
|
41
|
+
):
|
|
42
|
+
raise ToolError(
|
|
43
|
+
f"invalid {kind} {value!r}: expected a container image reference "
|
|
44
|
+
"like 'nginx:1.27' or 'registry.example.com/team/api:v2' "
|
|
45
|
+
"(letters, digits, '.', '-', '_', '/', ':', '@'; no leading '-')"
|
|
46
|
+
)
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _trivy_image_scan(args: dict) -> str:
|
|
51
|
+
image = _check_image(args.get("image"))
|
|
52
|
+
return read_command_output(
|
|
53
|
+
("trivy", "image", "--scanners", "vuln", "--format", "table", image),
|
|
54
|
+
timeout=_TIMEOUT_S,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
TRIVY_IMAGE_SCAN = Tool(
|
|
59
|
+
name="trivy_image_scan",
|
|
60
|
+
description=(
|
|
61
|
+
"Scan a container image for vulnerabilities with Trivy and return "
|
|
62
|
+
"the report table: CVE id, severity, package, fixed version. Use "
|
|
63
|
+
"when a pod/container incident smells like a CVE, when triaging "
|
|
64
|
+
"'is this image safe to keep', or before recommending an image "
|
|
65
|
+
"bump. NOTE: the first run downloads the vulnerability database "
|
|
66
|
+
"and can take a couple of minutes. Read-only."
|
|
67
|
+
),
|
|
68
|
+
parameters={
|
|
69
|
+
"type": "object",
|
|
70
|
+
"properties": {
|
|
71
|
+
"image": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "Image reference to scan, e.g. 'nginx:1.27' "
|
|
74
|
+
"or 'registry.example.com/team/api:v2'.",
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"required": ["image"],
|
|
78
|
+
"additionalProperties": False,
|
|
79
|
+
},
|
|
80
|
+
executor=_trivy_image_scan,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
register(TRIVY_IMAGE_SCAN)
|