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.
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)