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.
Files changed (56) hide show
  1. splitagent/__init__.py +8 -0
  2. splitagent/__main__.py +6 -0
  3. splitagent/agents/__init__.py +10 -0
  4. splitagent/agents/base.py +477 -0
  5. splitagent/agents/blue.py +57 -0
  6. splitagent/agents/chat.py +60 -0
  7. splitagent/agents/prompts.py +462 -0
  8. splitagent/agents/red.py +75 -0
  9. splitagent/cli.py +701 -0
  10. splitagent/config.py +697 -0
  11. splitagent/core/__init__.py +19 -0
  12. splitagent/core/bus.py +62 -0
  13. splitagent/core/context.py +587 -0
  14. splitagent/core/context_manager.py +381 -0
  15. splitagent/core/engine.py +424 -0
  16. splitagent/core/models.py +310 -0
  17. splitagent/core/proc.py +73 -0
  18. splitagent/core/sandbox.py +184 -0
  19. splitagent/core/toolbox.py +520 -0
  20. splitagent/core/workspace.py +420 -0
  21. splitagent/desktop/__init__.py +7 -0
  22. splitagent/desktop/api.py +525 -0
  23. splitagent/desktop/app.py +1131 -0
  24. splitagent/desktop/web/app.js +3067 -0
  25. splitagent/desktop/web/assets/Inter.ttf +0 -0
  26. splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  27. splitagent/desktop/web/index.html +760 -0
  28. splitagent/desktop/web/styles.css +1612 -0
  29. splitagent/errors.py +27 -0
  30. splitagent/llm/__init__.py +8 -0
  31. splitagent/llm/client.py +488 -0
  32. splitagent/llm/types.py +172 -0
  33. splitagent/report/__init__.py +9 -0
  34. splitagent/report/cvss.py +93 -0
  35. splitagent/report/generator.py +733 -0
  36. splitagent/tools/__init__.py +8 -0
  37. splitagent/tools/base.py +135 -0
  38. splitagent/tools/defense.py +475 -0
  39. splitagent/tools/exploit.py +318 -0
  40. splitagent/tools/http_pool.py +109 -0
  41. splitagent/tools/knowledge.py +376 -0
  42. splitagent/tools/recon.py +182 -0
  43. splitagent/tools/registry.py +62 -0
  44. splitagent/tools/validate.py +908 -0
  45. splitagent/tools/web.py +386 -0
  46. splitagent/tools/workspace_tools.py +411 -0
  47. splitagent/ui/__init__.py +5 -0
  48. splitagent/ui/app.py +389 -0
  49. splitagent/ui/stream.py +234 -0
  50. splitagent/ui/theme.py +72 -0
  51. splitagent-0.0.3.dist-info/METADATA +987 -0
  52. splitagent-0.0.3.dist-info/RECORD +56 -0
  53. splitagent-0.0.3.dist-info/WHEEL +5 -0
  54. splitagent-0.0.3.dist-info/entry_points.txt +2 -0
  55. splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
  56. 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()