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/base.py ADDED
@@ -0,0 +1,112 @@
1
+ """Tool contract for the DevOps investigation agent.
2
+
3
+ All tools are READ-ONLY by construction: an executor inspects the system and
4
+ returns text evidence; nothing in this module can mutate state, and tools
5
+ never accept arbitrary commands (they select from hard-coded, allowlisted
6
+ invocations).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from typing import Callable, Sequence
15
+
16
+ # Ceiling for how much of a tool result is fed back to the model in one turn.
17
+ MAX_TOOL_OUTPUT_CHARS = 8_000
18
+
19
+
20
+ class ToolError(Exception):
21
+ """Raised by an executor when a tool could not produce its result."""
22
+
23
+
24
+ def truncate_output(text: str, limit: int = MAX_TOOL_OUTPUT_CHARS) -> str:
25
+ """Cut very long output so the TOTAL returned length stays <= limit.
26
+
27
+ Head and tail of the original are kept; a short digest line connects
28
+ them. 64 characters are reserved for that line, which is enough for any
29
+ realistic input size.
30
+ """
31
+ if len(text) <= limit:
32
+ return text
33
+
34
+ body = max(2, limit - 64) # budget left for actual content
35
+ head_len = body // 2
36
+ tail_len = body - head_len
37
+
38
+ omitted = len(text) - body
39
+ marker = f"\n… [{omitted} characters omitted] …\n"
40
+ if len(marker) > limit - body:
41
+ marker = "\n… (output truncated) …\n"
42
+
43
+ return text[:head_len] + marker + text[-tail_len:]
44
+
45
+
46
+ def read_command_output(argv: Sequence[str], timeout: int = 10) -> str:
47
+ """Run an allowlisted read-only command and return its output as evidence.
48
+
49
+ Raises ToolError (so the caller never feeds unvalidated input to a
50
+ shell) when the binary is missing, the command times out, or it exits
51
+ non-zero — in the failure cases, the captured output is embedded in the
52
+ error so the model sees real diagnostics rather than a generic message.
53
+
54
+ Successful output is prefixed with the literal command line (so the
55
+ model can tell it is real captured output, not a canned string) and
56
+ truncated to MAX_TOOL_OUTPUT_CHARS.
57
+ """
58
+ binary = argv[0]
59
+ if shutil.which(binary) is None:
60
+ raise ToolError(
61
+ f"required executable {binary!r} is not installed on this host"
62
+ )
63
+ try:
64
+ proc = subprocess.run(
65
+ argv,
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=timeout,
69
+ check=False,
70
+ )
71
+ except subprocess.TimeoutExpired as exc:
72
+ raise ToolError(f"command {binary} timed out after {timeout}s") from exc
73
+
74
+ output = ((proc.stdout or "") + (proc.stderr or "")).strip()
75
+ if proc.returncode != 0:
76
+ raise ToolError(
77
+ f"command {binary} exited with code {proc.returncode}:\n"
78
+ f"{output or '(no output)'}"
79
+ )
80
+ if not output:
81
+ output = f"(command {' '.join(argv)} returned no output, exit code 0)"
82
+ return truncate_output(f"$ {' '.join(argv)}\n{output}")
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class Tool:
87
+ """A read-only capability the model can call while investigating.
88
+
89
+ Attributes:
90
+ name: stable identifier the model uses to request this tool.
91
+ description: when/how to use it; sent to the model alongside the schema.
92
+ parameters: JSON Schema describing the arguments the tool accepts.
93
+ executor: local callable(args: dict) -> str. It receives the *parsed*
94
+ JSON arguments and returns the text fed back to the model as
95
+ evidence. Must be read-only and must never fabricate output.
96
+ """
97
+
98
+ name: str
99
+ description: str
100
+ parameters: dict
101
+ executor: Callable[[dict], str]
102
+
103
+ def schema(self) -> dict:
104
+ """Render this tool in the OpenAI-compatible `tools` request format."""
105
+ return {
106
+ "type": "function",
107
+ "function": {
108
+ "name": self.name,
109
+ "description": self.description,
110
+ "parameters": self.parameters,
111
+ },
112
+ }
tools/cloud.py ADDED
@@ -0,0 +1,101 @@
1
+ """Read-only cloud-account tools (Phase 8).
2
+
3
+ Identity and listing probes for the three major cloud CLIs: who am I / which
4
+ account is active (aws sts get-caller-identity, gcloud config list, az account
5
+ show) and a first-level resource listing (az group list). These answer "which
6
+ cloud account am I even looking at" — usually the first question when an
7
+ incident report and the infrastructure disagree.
8
+
9
+ Safety model:
10
+ - Only fixed argv templates with read-only verbs exist. There is no create,
11
+ delete, update, start, stop, tag, or IAM-mutation path, and no
12
+ user-supplied argument anywhere: neither AWS, gcloud nor az ever receives
13
+ model-chosen text, so flag/dispatch injection is impossible.
14
+ - `aws sts get-caller-identity` needs no region; listing tools are kept to
15
+ account/subscription level on purpose — region-scoped resource sweeps
16
+ (ec2 describe-*, compute instances list ...) can be added later behind the
17
+ same template pattern.
18
+ - The CLIs must be installed and configured (aws credentials, gcloud/az
19
+ logins). Missing pieces surface as the exact CLI error — never invented.
20
+ - Cloud CLIs are slow on a cold start (helper processes, token refresh), so
21
+ the subprocess timeout is 30s rather than the usual 10s.
22
+ """
23
+
24
+ from tools.base import Tool, read_command_output
25
+ from tools.registry import register
26
+
27
+ _TIMEOUT_S = 30
28
+
29
+
30
+ def _aws_identity(args: dict) -> str:
31
+ return read_command_output(
32
+ ("aws", "sts", "get-caller-identity", "--output", "json"),
33
+ timeout=_TIMEOUT_S,
34
+ )
35
+
36
+
37
+ def _gcloud_identity(args: dict) -> str:
38
+ return read_command_output(
39
+ ("gcloud", "config", "list", "--format=json"),
40
+ timeout=_TIMEOUT_S,
41
+ )
42
+
43
+
44
+ def _az_account(args: dict) -> str:
45
+ return read_command_output(("az", "account", "show"), timeout=_TIMEOUT_S)
46
+
47
+
48
+ def _az_groups(args: dict) -> str:
49
+ return read_command_output(("az", "group", "list"), timeout=_TIMEOUT_S)
50
+
51
+
52
+ AWS_IDENTITY = Tool(
53
+ name="aws_identity",
54
+ description=(
55
+ "AWS caller identity as JSON (aws sts get-caller-identity): account "
56
+ "id, user/role ARN. Use to answer 'which AWS account and principal "
57
+ "are the configured credentials for'. Requires aws CLI with working "
58
+ "credentials. Read-only."
59
+ ),
60
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
61
+ executor=_aws_identity,
62
+ )
63
+
64
+ GCLOUD_IDENTITY = Tool(
65
+ name="gcloud_identity",
66
+ description=(
67
+ "Active gcloud configuration as JSON (gcloud config list): account, "
68
+ "project, region, zone. Use to answer 'which GCP project/region is "
69
+ "this host pointed at'. Requires the gcloud CLI. Read-only."
70
+ ),
71
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
72
+ executor=_gcloud_identity,
73
+ )
74
+
75
+ AZ_ACCOUNT = Tool(
76
+ name="az_account",
77
+ description=(
78
+ "The active Azure subscription as JSON (az account show): id, name, "
79
+ "state, tenant. Use to answer 'which Azure subscription am I looking "
80
+ "at'. Requires the az CLI and a logged-in account. Read-only."
81
+ ),
82
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
83
+ executor=_az_account,
84
+ )
85
+
86
+ AZ_GROUPS = Tool(
87
+ name="az_groups",
88
+ description=(
89
+ "List Azure resource groups in the active subscription as JSON (az "
90
+ "group list): name, location, tags. Use to see the top-level layout "
91
+ "of an Azure subscription during an investigation. Requires az CLI. "
92
+ "Read-only."
93
+ ),
94
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
95
+ executor=_az_groups,
96
+ )
97
+
98
+ register(AWS_IDENTITY)
99
+ register(GCLOUD_IDENTITY)
100
+ register(AZ_ACCOUNT)
101
+ register(AZ_GROUPS)
tools/docker.py ADDED
@@ -0,0 +1,280 @@
1
+ """Read-only Docker tools (Phases 5, 8, 9).
2
+
3
+ Ten tools for container problems on the host: list containers (docker ps),
4
+ inspect one (JSON), tail logs, live resource stats (docker stats --no-stream),
5
+ the local image list, networks, volumes, disk usage (Phase 8), and (Phase 9)
6
+ Docker Compose project listing + a project's services. Same contract/safety
7
+ model as the other modules.
8
+
9
+ Safety model:
10
+ - Only fixed argv templates exist with the read-only docker verbs ps, inspect,
11
+ logs, stats, images, network ls, volume ls, system df, compose ls, compose
12
+ ps. There is no run, start, stop, restart, rm, rmi, pull, push, exec, build,
13
+ create, prune or compose-up/down path.
14
+ - `docker stats` is hard-coded with `--no-stream`: without it the command
15
+ follows forever and would hang a turn.
16
+ - Container/image names are validated (letters, digits, '.', '-', '_', no
17
+ leading dash) — blocks flag injection and path traversal.
18
+ - Docker must be installed and the daemon running; if not, the exact error
19
+ is returned — nothing is invented. A dangling or dead daemon surfaces as
20
+ a real ToolError, which the model must report honestly.
21
+ """
22
+
23
+ import re
24
+
25
+ from tools.base import Tool, ToolError, read_command_output
26
+ from tools.registry import register
27
+
28
+ # Docker names: letters, digits, '.', '-', '_'; must start/end alphanumeric.
29
+ _NAME_RE = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9._\-]{0,127}")
30
+ _TIMEOUT_S = 10
31
+
32
+
33
+ def _check_name(value, kind: str) -> str:
34
+ if (
35
+ not isinstance(value, str)
36
+ or len(value) > 128
37
+ or not _NAME_RE.fullmatch(value)
38
+ ):
39
+ raise ToolError(
40
+ f"invalid Docker {kind} name {value!r}: expected letters, digits, "
41
+ "'.', '-' or '_', max 128 chars"
42
+ )
43
+ return value
44
+
45
+
46
+ def _checked_lines(args: dict) -> int:
47
+ try:
48
+ lines = int(args.get("lines", 100))
49
+ except (TypeError, ValueError):
50
+ raise ToolError("lines must be an integer between 1 and 500")
51
+ if not 1 <= lines <= 500:
52
+ raise ToolError("lines must be between 1 and 500")
53
+ return lines
54
+
55
+
56
+ def _docker_ps(args: dict) -> str:
57
+ return read_command_output(("docker", "ps", "-a"), timeout=_TIMEOUT_S)
58
+
59
+
60
+ def _docker_inspect(args: dict) -> str:
61
+ name = _check_name(args.get("name"), "container or image")
62
+ return read_command_output(("docker", "inspect", name), timeout=_TIMEOUT_S)
63
+
64
+
65
+ def _docker_logs(args: dict) -> str:
66
+ name = _check_name(args.get("container"), "container")
67
+ return read_command_output(
68
+ ("docker", "logs", "--tail", str(_checked_lines(args)), name),
69
+ timeout=_TIMEOUT_S,
70
+ )
71
+
72
+
73
+ def _docker_stats(args: dict) -> str:
74
+ return read_command_output(("docker", "stats", "--no-stream"), timeout=_TIMEOUT_S)
75
+
76
+
77
+ def _docker_images(args: dict) -> str:
78
+ return read_command_output(("docker", "images"), timeout=_TIMEOUT_S)
79
+
80
+
81
+ DOCKER_PS = Tool(
82
+ name="docker_ps",
83
+ description=(
84
+ "List all containers (running and stopped) with status: container id, "
85
+ "image, command, status (Up/X minutes, Exited (code) ...), names. The "
86
+ "first tool for 'is my container running / why did it stop'. Read-only."
87
+ ),
88
+ parameters={
89
+ "type": "object",
90
+ "properties": {},
91
+ "additionalProperties": False,
92
+ },
93
+ executor=_docker_ps,
94
+ )
95
+
96
+ DOCKER_INSPECT = Tool(
97
+ name="docker_inspect",
98
+ description=(
99
+ "Inspect one container or image by name as JSON — state, exit code, "
100
+ "restart count, mounts, env, image id. Use with docker_ps to see the "
101
+ "details behind a container's state. Read-only."
102
+ ),
103
+ parameters={
104
+ "type": "object",
105
+ "properties": {
106
+ "name": {
107
+ "type": "string",
108
+ "description": "Container or image name/id to inspect.",
109
+ }
110
+ },
111
+ "required": ["name"],
112
+ "additionalProperties": False,
113
+ },
114
+ executor=_docker_inspect,
115
+ )
116
+
117
+ DOCKER_LOGS = Tool(
118
+ name="docker_logs",
119
+ description=(
120
+ "Fetch the tail of a container's stdout/stderr logs as plain text. Use "
121
+ "with docker_ps/docker_inspect to see the error that made a container "
122
+ "exit or crash-loop. Read-only."
123
+ ),
124
+ parameters={
125
+ "type": "object",
126
+ "properties": {
127
+ "container": {"type": "string", "description": "Container name/id."},
128
+ "lines": {
129
+ "type": "integer",
130
+ "minimum": 1,
131
+ "maximum": 500,
132
+ "default": 100,
133
+ "description": "How many lines to tail (1–500).",
134
+ },
135
+ },
136
+ "required": ["container"],
137
+ "additionalProperties": False,
138
+ },
139
+ executor=_docker_logs,
140
+ )
141
+
142
+ DOCKER_STATS = Tool(
143
+ name="docker_stats",
144
+ description=(
145
+ "One snapshot of live resource usage for running containers: CPU %, "
146
+ "memory used/limit, net/block I/O. Use for 'which container burns CPU "
147
+ "or RAM'. Read-only; never blocks (always --no-stream)."
148
+ ),
149
+ parameters={
150
+ "type": "object",
151
+ "properties": {},
152
+ "additionalProperties": False,
153
+ },
154
+ executor=_docker_stats,
155
+ )
156
+
157
+ DOCKER_IMAGES = Tool(
158
+ name="docker_images",
159
+ description=(
160
+ "List local container images: repository, tag, image id, size. Use to "
161
+ "check whether an image/tag exists locally or is dangling. Read-only."
162
+ ),
163
+ parameters={
164
+ "type": "object",
165
+ "properties": {},
166
+ "additionalProperties": False,
167
+ },
168
+ executor=_docker_images,
169
+ )
170
+
171
+ # --- Phase 8: Docker depth — networks, volumes, disk usage -------------------
172
+
173
+ def _docker_networks(args: dict) -> str:
174
+ return read_command_output(("docker", "network", "ls"), timeout=_TIMEOUT_S)
175
+
176
+
177
+ def _docker_volumes(args: dict) -> str:
178
+ return read_command_output(("docker", "volume", "ls"), timeout=_TIMEOUT_S)
179
+
180
+
181
+ def _docker_disk_usage(args: dict) -> str:
182
+ return read_command_output(("docker", "system", "df"), timeout=_TIMEOUT_S)
183
+
184
+
185
+ DOCKER_NETWORKS = Tool(
186
+ name="docker_networks",
187
+ description=(
188
+ "List Docker networks on the host (docker network ls): name, driver, "
189
+ "scope. Use for 'which network does my container use', connectivity "
190
+ "and bridge/overlay questions. Read-only."
191
+ ),
192
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
193
+ executor=_docker_networks,
194
+ )
195
+
196
+ DOCKER_VOLUMES = Tool(
197
+ name="docker_volumes",
198
+ description=(
199
+ "List Docker volumes on the host (docker volume ls): volume names and "
200
+ "drivers. Use for 'does the volume my data lives in exist', dangling "
201
+ "volume checks. Read-only."
202
+ ),
203
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
204
+ executor=_docker_volumes,
205
+ )
206
+
207
+ DOCKER_DISK_USAGE = Tool(
208
+ name="docker_disk_usage",
209
+ description=(
210
+ "Docker disk usage on the host (docker system df): how much space "
211
+ "images, containers, volumes and the build cache consume, with "
212
+ "reclaimable amounts. Use for 'is the disk full because of Docker'. "
213
+ "Read-only."
214
+ ),
215
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
216
+ executor=_docker_disk_usage,
217
+ )
218
+
219
+ # --- Phase 9: Docker Compose — compose project listing and one project's
220
+ # services. Same contract: fixed argv, validated project name, ps/ls only. ---
221
+
222
+ def _docker_compose_ls(args: dict) -> str:
223
+ return read_command_output(("docker", "compose", "ls"), timeout=_TIMEOUT_S)
224
+
225
+
226
+ def _docker_compose_ps(args: dict) -> str:
227
+ project = args.get("project")
228
+ if project is not None:
229
+ _check_name(project, "compose project")
230
+ argv = ("docker", "compose", "-p", project, "ps", "-a")
231
+ else:
232
+ # No -p: reads the compose project of the current working directory.
233
+ argv = ("docker", "compose", "ps", "-a")
234
+ return read_command_output(argv, timeout=_TIMEOUT_S)
235
+
236
+
237
+ DOCKER_COMPOSE_LS = Tool(
238
+ name="docker_compose_ls",
239
+ description=(
240
+ "List running Docker Compose projects on the host (docker compose "
241
+ "ls): project name, status, config files. Use to see which compose "
242
+ "stacks exist before looking at one's services. Read-only."
243
+ ),
244
+ parameters={"type": "object", "properties": {}, "additionalProperties": False},
245
+ executor=_docker_compose_ls,
246
+ )
247
+
248
+ DOCKER_COMPOSE_PS = Tool(
249
+ name="docker_compose_ps",
250
+ description=(
251
+ "List the containers of a Docker Compose project (docker compose "
252
+ "ps -a): service, state, ports — running AND stopped. Use with "
253
+ "docker_compose_ls to see what a stack is made of, or which service "
254
+ "inside a project exited. Without 'project', uses the compose file "
255
+ "in the current working directory. Read-only."
256
+ ),
257
+ parameters={
258
+ "type": "object",
259
+ "properties": {
260
+ "project": {
261
+ "type": "string",
262
+ "description": "Optional compose project name (from "
263
+ "docker_compose_ls).",
264
+ }
265
+ },
266
+ "additionalProperties": False,
267
+ },
268
+ executor=_docker_compose_ps,
269
+ )
270
+
271
+ register(DOCKER_PS)
272
+ register(DOCKER_INSPECT)
273
+ register(DOCKER_LOGS)
274
+ register(DOCKER_STATS)
275
+ register(DOCKER_IMAGES)
276
+ register(DOCKER_NETWORKS)
277
+ register(DOCKER_VOLUMES)
278
+ register(DOCKER_DISK_USAGE)
279
+ register(DOCKER_COMPOSE_LS)
280
+ register(DOCKER_COMPOSE_PS)