splitagent 0.0.3__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.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""The isolated toolbox: a disposable container the agents work inside.
|
|
2
|
+
|
|
3
|
+
Unlike :mod:`splitagent.core.sandbox` (which is the *target* under test), the
|
|
4
|
+
toolbox is the *attacker's machine*. Every tool the agents install or run goes
|
|
5
|
+
here, so the host is never modified. The workspace is bind-mounted at
|
|
6
|
+
``/workspace`` so tooling and reconnaissance data survive container rebuilds.
|
|
7
|
+
|
|
8
|
+
Lifecycle:
|
|
9
|
+
|
|
10
|
+
* ``detect()`` - is the docker CLI present and is the daemon answering?
|
|
11
|
+
* ``start_daemon()``- launch Docker Desktop and wait for the engine
|
|
12
|
+
* ``build()`` - build the image from ``docker/toolbox.Dockerfile``
|
|
13
|
+
* ``up()`` - create and start the container (builds first if needed)
|
|
14
|
+
* ``exec()`` - run a command inside the running container
|
|
15
|
+
* ``down()`` - stop the container (the image and workspace remain)
|
|
16
|
+
|
|
17
|
+
Everything degrades gracefully: with no Docker the callers fall back to local
|
|
18
|
+
execution, which is why ``mode="auto"`` is the default.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from splitagent.config import ExecutionConfig
|
|
34
|
+
from splitagent.core import proc
|
|
35
|
+
|
|
36
|
+
# Shapes accepted for model-supplied package names. They are always passed as
|
|
37
|
+
# argv to sh -lc ("$1") and never interpolated, so these are defence in depth
|
|
38
|
+
# against anything that would still be meaningful to the package manager.
|
|
39
|
+
_SAFE_PACKAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]*")
|
|
40
|
+
_SAFE_PIP_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._+-]*(==[A-Za-z0-9._+-]+)?")
|
|
41
|
+
_SAFE_GO_RE = re.compile(r"[A-Za-z0-9._/-]+@(latest|v?[A-Za-z0-9._-]+)")
|
|
42
|
+
_SAFE_NPM_RE = re.compile(r"@?[A-Za-z0-9][A-Za-z0-9._/-]*")
|
|
43
|
+
_SAFE_GIT_URL_RE = re.compile(
|
|
44
|
+
r"(https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+|git@[A-Za-z0-9._-]+:[A-Za-z0-9._/-]+)"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
DOCKERFILE = Path(__file__).resolve().parents[2] / "docker" / "toolbox.Dockerfile"
|
|
48
|
+
|
|
49
|
+
# Windows/macOS Desktop install locations, used by start_daemon().
|
|
50
|
+
DESKTOP_CANDIDATES = (
|
|
51
|
+
r"C:\Program Files\Docker\Docker\Docker Desktop.exe",
|
|
52
|
+
"/Applications/Docker.app",
|
|
53
|
+
"/Applications/Docker.app/Contents/MacOS/Docker",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Tools the toolbox ships with, used for status reporting.
|
|
57
|
+
KNOWN_TOOLS = (
|
|
58
|
+
"nmap",
|
|
59
|
+
"masscan",
|
|
60
|
+
"nikto",
|
|
61
|
+
"sqlmap",
|
|
62
|
+
"whatweb",
|
|
63
|
+
"wafw00f",
|
|
64
|
+
"gobuster",
|
|
65
|
+
"ffuf",
|
|
66
|
+
"dirb",
|
|
67
|
+
"nuclei",
|
|
68
|
+
"subfinder",
|
|
69
|
+
"httpx",
|
|
70
|
+
"dnsx",
|
|
71
|
+
"curl",
|
|
72
|
+
"git",
|
|
73
|
+
"python3",
|
|
74
|
+
"pip3",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
DAEMON_WAIT_SECONDS = 120
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ToolboxStatus:
|
|
82
|
+
docker_cli: bool = False
|
|
83
|
+
daemon: bool = False
|
|
84
|
+
image: bool = False
|
|
85
|
+
running: bool = False
|
|
86
|
+
container: str = ""
|
|
87
|
+
image_name: str = ""
|
|
88
|
+
edition: str = "standard"
|
|
89
|
+
mode: str = "auto"
|
|
90
|
+
message: str = ""
|
|
91
|
+
tools: dict[str, bool] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def usable(self) -> bool:
|
|
95
|
+
return self.docker_cli and self.daemon
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"docker_cli": self.docker_cli,
|
|
100
|
+
"daemon": self.daemon,
|
|
101
|
+
"image": self.image,
|
|
102
|
+
"running": self.running,
|
|
103
|
+
"container": self.container,
|
|
104
|
+
"image_name": self.image_name,
|
|
105
|
+
"edition": self.edition,
|
|
106
|
+
"mode": self.mode,
|
|
107
|
+
"message": self.message,
|
|
108
|
+
"usable": self.usable,
|
|
109
|
+
"tools": self.tools,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def docker_available() -> bool:
|
|
114
|
+
return shutil.which("docker") is not None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class Toolbox:
|
|
118
|
+
"""Manage the agent execution container."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, config: ExecutionConfig, workspace_root: Path | None = None) -> None:
|
|
121
|
+
self.config = config
|
|
122
|
+
self.workspace_root = workspace_root
|
|
123
|
+
self._last_build_output = ""
|
|
124
|
+
|
|
125
|
+
# -- low level --------------------------------------------------------- #
|
|
126
|
+
def _run(
|
|
127
|
+
self,
|
|
128
|
+
*args: str,
|
|
129
|
+
timeout: int = 120,
|
|
130
|
+
check: bool = False,
|
|
131
|
+
) -> tuple[int, str, str]:
|
|
132
|
+
if not docker_available():
|
|
133
|
+
raise RuntimeError("docker was not found on PATH")
|
|
134
|
+
try:
|
|
135
|
+
completed = proc.run(["docker", *args], timeout=timeout)
|
|
136
|
+
except FileNotFoundError as exc: # pragma: no cover - guarded above
|
|
137
|
+
raise RuntimeError(f"docker not found: {exc}") from exc
|
|
138
|
+
# Docker output is UTF-8; never let the host code page break the run.
|
|
139
|
+
out = (completed.stdout or b"").decode("utf-8", "replace").strip()
|
|
140
|
+
err = (completed.stderr or b"").decode("utf-8", "replace").strip()
|
|
141
|
+
if check and completed.returncode != 0:
|
|
142
|
+
raise RuntimeError(err or out or f"docker exited with {completed.returncode}")
|
|
143
|
+
return completed.returncode or 0, out, err
|
|
144
|
+
|
|
145
|
+
# -- detection --------------------------------------------------------- #
|
|
146
|
+
def detect(self, probe: bool = False) -> ToolboxStatus:
|
|
147
|
+
status = ToolboxStatus(
|
|
148
|
+
docker_cli=docker_available(),
|
|
149
|
+
container=self.config.container,
|
|
150
|
+
image_name=self.config.image,
|
|
151
|
+
edition=self.config.edition,
|
|
152
|
+
mode=self.config.mode,
|
|
153
|
+
)
|
|
154
|
+
if not status.docker_cli:
|
|
155
|
+
status.message = "Docker is not installed"
|
|
156
|
+
return status
|
|
157
|
+
|
|
158
|
+
code, _, _ = self._run("info", "--format", "{{.ServerVersion}}", timeout=25)
|
|
159
|
+
status.daemon = code == 0
|
|
160
|
+
if not status.daemon:
|
|
161
|
+
status.message = "Docker is installed but the daemon is not running"
|
|
162
|
+
return status
|
|
163
|
+
|
|
164
|
+
code, out, _ = self._run(
|
|
165
|
+
"images",
|
|
166
|
+
"--format",
|
|
167
|
+
"{{.Repository}}:{{.Tag}}",
|
|
168
|
+
timeout=25,
|
|
169
|
+
)
|
|
170
|
+
status.image = self.config.image in out.splitlines()
|
|
171
|
+
|
|
172
|
+
code, out, _ = self._run(
|
|
173
|
+
"ps",
|
|
174
|
+
"--filter",
|
|
175
|
+
f"name=^{self.config.container}$",
|
|
176
|
+
"--format",
|
|
177
|
+
"{{.Names}}",
|
|
178
|
+
timeout=25,
|
|
179
|
+
)
|
|
180
|
+
status.running = self.config.container in out.splitlines()
|
|
181
|
+
|
|
182
|
+
if status.running:
|
|
183
|
+
if probe:
|
|
184
|
+
status.tools = self.probe_tools()
|
|
185
|
+
status.message = "running"
|
|
186
|
+
elif status.image:
|
|
187
|
+
status.message = "image ready, container stopped"
|
|
188
|
+
else:
|
|
189
|
+
status.message = "image not built yet"
|
|
190
|
+
return status
|
|
191
|
+
|
|
192
|
+
def probe_tools(self) -> dict[str, bool]:
|
|
193
|
+
"""Which known tools exist inside the running container."""
|
|
194
|
+
found: dict[str, bool] = {}
|
|
195
|
+
for tool in KNOWN_TOOLS:
|
|
196
|
+
code, out, _ = self._run(
|
|
197
|
+
"exec",
|
|
198
|
+
self.config.container,
|
|
199
|
+
"sh",
|
|
200
|
+
"-lc",
|
|
201
|
+
f"command -v {tool} >/dev/null 2>&1 && echo yes",
|
|
202
|
+
timeout=20,
|
|
203
|
+
)
|
|
204
|
+
found[tool] = code == 0 and "yes" in out
|
|
205
|
+
return found
|
|
206
|
+
|
|
207
|
+
# -- daemon ------------------------------------------------------------ #
|
|
208
|
+
def start_daemon(self, wait: int = DAEMON_WAIT_SECONDS) -> tuple[bool, str]:
|
|
209
|
+
"""Try to bring the Docker engine up without user interaction."""
|
|
210
|
+
if not docker_available():
|
|
211
|
+
return False, "docker CLI not found"
|
|
212
|
+
code, _, _ = self._run("info", timeout=20)
|
|
213
|
+
if code == 0:
|
|
214
|
+
return True, "already running"
|
|
215
|
+
|
|
216
|
+
launched = self._launch_desktop()
|
|
217
|
+
deadline = time.time() + wait
|
|
218
|
+
while time.time() < deadline:
|
|
219
|
+
code, _, _ = self._run("info", timeout=15)
|
|
220
|
+
if code == 0:
|
|
221
|
+
return True, "started"
|
|
222
|
+
time.sleep(3)
|
|
223
|
+
if launched:
|
|
224
|
+
return False, "Docker Desktop was started but the engine did not answer in time"
|
|
225
|
+
return False, "could not start Docker automatically"
|
|
226
|
+
|
|
227
|
+
def _launch_desktop(self) -> bool:
|
|
228
|
+
for candidate in DESKTOP_CANDIDATES:
|
|
229
|
+
path = Path(candidate)
|
|
230
|
+
if not path.exists():
|
|
231
|
+
continue
|
|
232
|
+
argv = ["open", str(path)] if sys.platform == "darwin" else [str(path)]
|
|
233
|
+
try:
|
|
234
|
+
proc.popen(argv, detach=True)
|
|
235
|
+
return True
|
|
236
|
+
except OSError:
|
|
237
|
+
continue
|
|
238
|
+
return False
|
|
239
|
+
|
|
240
|
+
# -- image ------------------------------------------------------------- #
|
|
241
|
+
def build(self, timeout: int = 1800) -> dict[str, Any]:
|
|
242
|
+
if not DOCKERFILE.is_file():
|
|
243
|
+
return {"ok": False, "error": f"Dockerfile not found at {DOCKERFILE}"}
|
|
244
|
+
code, out, err = self._run(
|
|
245
|
+
"build",
|
|
246
|
+
"-f",
|
|
247
|
+
str(DOCKERFILE),
|
|
248
|
+
"--build-arg",
|
|
249
|
+
f"EDITION={self.config.edition}",
|
|
250
|
+
"-t",
|
|
251
|
+
self.config.image,
|
|
252
|
+
str(DOCKERFILE.parent.parent),
|
|
253
|
+
timeout=timeout,
|
|
254
|
+
)
|
|
255
|
+
self._last_build_output = (out + "\n" + err)[-8000:]
|
|
256
|
+
if code != 0:
|
|
257
|
+
return {
|
|
258
|
+
"ok": False,
|
|
259
|
+
"error": "docker build failed",
|
|
260
|
+
"output": self._last_build_output,
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
"ok": True,
|
|
264
|
+
"image": self.config.image,
|
|
265
|
+
"edition": self.config.edition,
|
|
266
|
+
"output": self._last_build_output[-2000:],
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
def remove_image(self) -> None:
|
|
270
|
+
self._run("rmi", "-f", self.config.image, timeout=60)
|
|
271
|
+
|
|
272
|
+
# -- container --------------------------------------------------------- #
|
|
273
|
+
def up(self, build_if_missing: bool = True, timeout: int = 1800) -> dict[str, Any]:
|
|
274
|
+
status = self.detect()
|
|
275
|
+
if not status.docker_cli:
|
|
276
|
+
return {"ok": False, "error": "Docker is not installed"}
|
|
277
|
+
if not status.daemon:
|
|
278
|
+
if not self.config.auto_start:
|
|
279
|
+
return {"ok": False, "error": "Docker daemon is not running"}
|
|
280
|
+
started, message = self.start_daemon()
|
|
281
|
+
if not started:
|
|
282
|
+
return {"ok": False, "error": message}
|
|
283
|
+
status = self.detect()
|
|
284
|
+
|
|
285
|
+
if not status.image:
|
|
286
|
+
if not build_if_missing:
|
|
287
|
+
return {"ok": False, "error": "toolbox image is not built"}
|
|
288
|
+
built = self.build(timeout=timeout)
|
|
289
|
+
if not built["ok"]:
|
|
290
|
+
return built
|
|
291
|
+
|
|
292
|
+
self._ensure_network()
|
|
293
|
+
self._run("rm", "-f", self.config.container, timeout=60)
|
|
294
|
+
|
|
295
|
+
args = [
|
|
296
|
+
"run",
|
|
297
|
+
"-d",
|
|
298
|
+
"--name",
|
|
299
|
+
self.config.container,
|
|
300
|
+
"--hostname",
|
|
301
|
+
"splitagent",
|
|
302
|
+
"--network",
|
|
303
|
+
self._network_name(),
|
|
304
|
+
"--cap-add",
|
|
305
|
+
"NET_RAW",
|
|
306
|
+
"--cap-add",
|
|
307
|
+
"NET_ADMIN",
|
|
308
|
+
]
|
|
309
|
+
if self.workspace_root is not None:
|
|
310
|
+
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
args += ["-v", f"{self.workspace_root}:/workspace"]
|
|
312
|
+
if self.config.network_mode == "host" and os.name != "nt":
|
|
313
|
+
args += ["--network", "host"]
|
|
314
|
+
if self.config.cpus:
|
|
315
|
+
args += ["--cpus", self.config.cpus]
|
|
316
|
+
if self.config.memory:
|
|
317
|
+
args += ["--memory", self.config.memory]
|
|
318
|
+
args += [self.config.image]
|
|
319
|
+
|
|
320
|
+
code, out, err = self._run(*args, timeout=180)
|
|
321
|
+
if code != 0:
|
|
322
|
+
return {"ok": False, "error": err or "failed to start the toolbox"}
|
|
323
|
+
return {"ok": True, "container": self.config.container, "id": out.splitlines()[-1]}
|
|
324
|
+
|
|
325
|
+
def _network_name(self) -> str:
|
|
326
|
+
return self.config.network or "splitagent-net"
|
|
327
|
+
|
|
328
|
+
def _ensure_network(self) -> None:
|
|
329
|
+
network = self._network_name()
|
|
330
|
+
_code, out, _ = self._run("network", "ls", "--format", "{{.Name}}", timeout=30)
|
|
331
|
+
if network in out.splitlines():
|
|
332
|
+
return
|
|
333
|
+
self._run("network", "create", network, timeout=60)
|
|
334
|
+
|
|
335
|
+
def down(self) -> None:
|
|
336
|
+
self._run("rm", "-f", self.config.container, timeout=60)
|
|
337
|
+
|
|
338
|
+
def restart(self) -> dict[str, Any]:
|
|
339
|
+
return self.up()
|
|
340
|
+
|
|
341
|
+
def reset(self) -> dict[str, Any]:
|
|
342
|
+
"""Throw the container away and create a clean one."""
|
|
343
|
+
self.down()
|
|
344
|
+
return self.up()
|
|
345
|
+
|
|
346
|
+
# -- execution --------------------------------------------------------- #
|
|
347
|
+
def exec(
|
|
348
|
+
self,
|
|
349
|
+
argv: list[str],
|
|
350
|
+
cwd: str = "/workspace/tools",
|
|
351
|
+
timeout: int = 300,
|
|
352
|
+
env: dict[str, str] | None = None,
|
|
353
|
+
) -> dict[str, Any]:
|
|
354
|
+
"""Run a command inside the container.
|
|
355
|
+
|
|
356
|
+
The container has a writable root, so commands run as root there and can
|
|
357
|
+
use apt freely. The workspace bind-mount is the only host-visible path.
|
|
358
|
+
"""
|
|
359
|
+
if not argv:
|
|
360
|
+
return {"ok": False, "error": "empty command"}
|
|
361
|
+
args = ["exec", "-w", cwd, "-e", "HOME=/root"]
|
|
362
|
+
for key, value in (env or {}).items():
|
|
363
|
+
args += ["-e", f"{key}={value}"]
|
|
364
|
+
args.append(self.config.container)
|
|
365
|
+
args += [str(a) for a in argv]
|
|
366
|
+
try:
|
|
367
|
+
code, out, err = self._run(*args, timeout=timeout)
|
|
368
|
+
except RuntimeError as exc:
|
|
369
|
+
return {"ok": False, "error": str(exc)}
|
|
370
|
+
output = (out + "\n" + err).strip()
|
|
371
|
+
return {
|
|
372
|
+
"ok": code == 0,
|
|
373
|
+
"command": " ".join(str(a) for a in argv),
|
|
374
|
+
"exit_code": code,
|
|
375
|
+
"output": output[-8000:],
|
|
376
|
+
"where": "toolbox",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
def exec_shell(self, script: str, timeout: int = 600) -> dict[str, Any]:
|
|
380
|
+
return self.exec(["sh", "-lc", script], timeout=timeout)
|
|
381
|
+
|
|
382
|
+
def install(self, manager: str, package: str, timeout: int = 900) -> dict[str, Any]:
|
|
383
|
+
"""Install a package inside the container.
|
|
384
|
+
|
|
385
|
+
apt/pip/pipx/go/npm all work; the container is disposable so this is
|
|
386
|
+
always safe. The workspace is mounted, so anything installed under
|
|
387
|
+
``/workspace/tools`` survives.
|
|
388
|
+
"""
|
|
389
|
+
if not self.config.allow_install:
|
|
390
|
+
return {"ok": False, "error": "installation is disabled in the toolbox"}
|
|
391
|
+
|
|
392
|
+
# Packages are model-controlled strings. They are passed as positional
|
|
393
|
+
# arguments to sh -lc ("$1"), never interpolated, so shell metacharacters
|
|
394
|
+
# cannot break out. Each manager additionally validates the shape it
|
|
395
|
+
# expects (a requirement spec, a module path, or a git URL/repo).
|
|
396
|
+
if manager in ("apt", "apt-get"):
|
|
397
|
+
if not _SAFE_PACKAGE_RE.fullmatch(package):
|
|
398
|
+
return {"ok": False, "error": f"unsafe package name: {package!r}"}
|
|
399
|
+
script = 'apt-get update -qq && apt-get install -y --no-install-recommends "$1"'
|
|
400
|
+
elif manager == "pip":
|
|
401
|
+
if not _SAFE_PIP_RE.fullmatch(package):
|
|
402
|
+
return {"ok": False, "error": f"unsafe pip requirement: {package!r}"}
|
|
403
|
+
script = (
|
|
404
|
+
'/opt/venv/bin/pip install --no-cache-dir "$1" 2>/dev/null || '
|
|
405
|
+
'pip install --break-system-packages --no-cache-dir "$1"'
|
|
406
|
+
)
|
|
407
|
+
elif manager == "pipx":
|
|
408
|
+
if not _SAFE_PIP_RE.fullmatch(package):
|
|
409
|
+
return {"ok": False, "error": f"unsafe pipx requirement: {package!r}"}
|
|
410
|
+
script = 'pipx install "$1"'
|
|
411
|
+
elif manager == "go":
|
|
412
|
+
if not _SAFE_GO_RE.fullmatch(package):
|
|
413
|
+
return {"ok": False, "error": f"unsafe go module path: {package!r}"}
|
|
414
|
+
script = 'GOBIN=/workspace/tools/bin go install "$1"'
|
|
415
|
+
elif manager == "npm":
|
|
416
|
+
if not _SAFE_NPM_RE.fullmatch(package):
|
|
417
|
+
return {"ok": False, "error": f"unsafe npm package: {package!r}"}
|
|
418
|
+
script = 'npm install -g --no-audit --no-fund "$1"'
|
|
419
|
+
elif manager == "cargo":
|
|
420
|
+
if not _SAFE_PACKAGE_RE.fullmatch(package):
|
|
421
|
+
return {"ok": False, "error": f"unsafe crate name: {package!r}"}
|
|
422
|
+
script = 'cargo install "$1"'
|
|
423
|
+
elif manager == "git":
|
|
424
|
+
if not _SAFE_GIT_URL_RE.fullmatch(package):
|
|
425
|
+
return {"ok": False, "error": f"unsafe git remote: {package!r}"}
|
|
426
|
+
name = package.rstrip("/").split("/")[-1].replace(".git", "")
|
|
427
|
+
if not _SAFE_PACKAGE_RE.fullmatch(name):
|
|
428
|
+
name = "repo"
|
|
429
|
+
script = 'git clone --depth 1 "$1" "/workspace/tools/$2"'
|
|
430
|
+
else:
|
|
431
|
+
return {
|
|
432
|
+
"ok": False,
|
|
433
|
+
"error": f"manager '{manager}' is not supported inside the toolbox",
|
|
434
|
+
"supported": ["apt", "pip", "pipx", "go", "npm", "cargo", "git"],
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if manager == "git":
|
|
438
|
+
args = ["sh", "-lc", script, "--", package, name]
|
|
439
|
+
else:
|
|
440
|
+
args = ["sh", "-lc", script, "--", package]
|
|
441
|
+
code, out, err = self._run("exec", self.config.container, *args, timeout=timeout)
|
|
442
|
+
output = (out + "\n" + err).strip()
|
|
443
|
+
result = {
|
|
444
|
+
"ok": code == 0,
|
|
445
|
+
"command": " ".join(args),
|
|
446
|
+
"exit_code": code,
|
|
447
|
+
"output": output[-8000:],
|
|
448
|
+
"where": "toolbox",
|
|
449
|
+
"manager": manager,
|
|
450
|
+
"package": package,
|
|
451
|
+
}
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
def which(self, name: str) -> str | None:
|
|
455
|
+
"""Resolve a binary inside the running container."""
|
|
456
|
+
if not _SAFE_PACKAGE_RE.fullmatch(name or ""):
|
|
457
|
+
return None
|
|
458
|
+
_code, out, _ = self._run(
|
|
459
|
+
"exec",
|
|
460
|
+
self.config.container,
|
|
461
|
+
"sh",
|
|
462
|
+
"-lc",
|
|
463
|
+
'command -v "$1" || true',
|
|
464
|
+
"--",
|
|
465
|
+
name,
|
|
466
|
+
timeout=20,
|
|
467
|
+
)
|
|
468
|
+
path = out.strip().splitlines()[-1] if out.strip() else ""
|
|
469
|
+
return path or None
|
|
470
|
+
|
|
471
|
+
def logs(self, tail: int = 100) -> str:
|
|
472
|
+
_, out, err = self._run("logs", "--tail", str(tail), self.config.container)
|
|
473
|
+
return out or err
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def resolve_mode(config: ExecutionConfig, status: ToolboxStatus) -> str:
|
|
477
|
+
"""Decide the effective execution backend."""
|
|
478
|
+
if config.mode == "local":
|
|
479
|
+
return "local"
|
|
480
|
+
if config.mode == "toolbox":
|
|
481
|
+
return "toolbox"
|
|
482
|
+
# auto
|
|
483
|
+
if not status.docker_cli:
|
|
484
|
+
return "local"
|
|
485
|
+
if status.daemon and (status.image or config.installed):
|
|
486
|
+
return "toolbox"
|
|
487
|
+
if status.running:
|
|
488
|
+
return "toolbox"
|
|
489
|
+
return "local"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def build_command(edition: str = "standard") -> list[str]:
|
|
493
|
+
"""The exact docker build command, surfaced to the operator."""
|
|
494
|
+
repo_root = DOCKERFILE.parent.parent
|
|
495
|
+
return [
|
|
496
|
+
"docker",
|
|
497
|
+
"build",
|
|
498
|
+
"-f",
|
|
499
|
+
str(DOCKERFILE),
|
|
500
|
+
"--build-arg",
|
|
501
|
+
f"EDITION={edition}",
|
|
502
|
+
"-t",
|
|
503
|
+
"splitagent-toolbox:latest",
|
|
504
|
+
str(repo_root),
|
|
505
|
+
]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def estimated_size(edition: str) -> str:
|
|
509
|
+
return "~450 MB" if edition == "standard" else "~2.5 GB"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def _async_run(*args: str) -> tuple[int, str, str]: # pragma: no cover
|
|
513
|
+
proc = await asyncio.create_subprocess_exec(
|
|
514
|
+
"docker",
|
|
515
|
+
*args,
|
|
516
|
+
stdout=asyncio.subprocess.PIPE,
|
|
517
|
+
stderr=asyncio.subprocess.PIPE,
|
|
518
|
+
)
|
|
519
|
+
out, err = await proc.communicate()
|
|
520
|
+
return proc.returncode or 0, out.decode(), err.decode()
|