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,420 @@
1
+ """Workspace: the agent's own directory for tools, notes and recon data.
2
+
3
+ Mirrors how OpenCode roots an agent in a working directory and reads
4
+ ``AGENTS.md`` instructions from it: the agents get a persistent, structured
5
+ place to install tooling, keep reconnaissance output, write notes and build
6
+ their own context between runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from splitagent.config import ProjectConfig, WorkspaceConfig
20
+ from splitagent.core import proc
21
+
22
+ INSTRUCTION_FILES = ("AGENTS.md", "CONTEXT.md", "SPLITAGENT.md")
23
+
24
+ README_TEMPLATE = """# SplitAgent workspace
25
+
26
+ This directory belongs to the agents. It persists across runs.
27
+
28
+ ```
29
+ tools/ installed tooling (virtualenvs, binaries, scripts)
30
+ recon/ raw reconnaissance output (scans, crawls, screenshots)
31
+ loot/ downloaded artefacts and evidence
32
+ notes/ agent notes and structured context
33
+ sessions/ per-run logs and traces
34
+ cache/ package and request caches
35
+ ```
36
+
37
+ Everything here is ignored by git. Put operator instructions in `AGENTS.md`.
38
+ """
39
+
40
+ AGENTS_TEMPLATE = """# AGENTS.md
41
+
42
+ Operator instructions for the SplitAgent agents. Rules here are loaded into the
43
+ system prompt of the Red Agent, the Blue Agent and the copilot, and take
44
+ precedence over their defaults.
45
+
46
+ ## Scope and rules of engagement
47
+ - Only test hosts listed in the engagement scope.
48
+ - Non-destructive probes only; never delete data or cause outages.
49
+
50
+ ## Preferred approach
51
+ - Plan before acting: pick the smallest set of techniques that answers the
52
+ question, and prefer passive recon before active scanning.
53
+ - Be resilient: rotate user agents, add delays, and avoid signature patterns
54
+ that a WAF would block.
55
+
56
+ ## Installed tooling
57
+ <!-- Describe the tools you installed under tools/ and how to invoke them. -->
58
+
59
+ ## Notes
60
+ <!-- Durable context the agents should remember across runs. -->
61
+ """
62
+
63
+
64
+ @dataclass
65
+ class Workspace:
66
+ root: Path
67
+ config: WorkspaceConfig
68
+
69
+ # -- structure --------------------------------------------------------- #
70
+ def ensure(self) -> Path:
71
+ for name in (
72
+ "tools",
73
+ "recon",
74
+ "loot",
75
+ "notes",
76
+ "sessions",
77
+ "cache",
78
+ "tools/bin",
79
+ ):
80
+ (self.root / name).mkdir(parents=True, exist_ok=True)
81
+ readme = self.root / "README.md"
82
+ if not readme.exists():
83
+ readme.write_text(README_TEMPLATE, encoding="utf-8")
84
+ agents = self.root / "AGENTS.md"
85
+ if not agents.exists():
86
+ agents.write_text(AGENTS_TEMPLATE, encoding="utf-8")
87
+ return self.root
88
+
89
+ @property
90
+ def env(self) -> dict[str, str]:
91
+ return {
92
+ "SPLITAGENT_WORKSPACE": str(self.root),
93
+ "SPLITAGENT_TOOLS": str(self.root / "tools"),
94
+ "SPLITAGENT_RECON": str(self.root / "recon"),
95
+ "SPLITAGENT_NOTES": str(self.root / "notes"),
96
+ }
97
+
98
+ def path_for(self, kind: str) -> Path:
99
+ mapping = {
100
+ "tools": self.root / "tools",
101
+ "recon": self.root / "recon",
102
+ "loot": self.root / "loot",
103
+ "notes": self.root / "notes",
104
+ "sessions": self.root / "sessions",
105
+ "cache": self.root / "cache",
106
+ }
107
+ target = mapping.get(kind, self.root)
108
+ target.mkdir(parents=True, exist_ok=True)
109
+ return target
110
+
111
+ # -- instructions ------------------------------------------------------ #
112
+ def instructions(self, project: ProjectConfig) -> str:
113
+ """Operator guidance loaded into the agents' system prompt."""
114
+ blocks: list[str] = []
115
+ if project.workspace.instructions.strip():
116
+ blocks.append(project.workspace.instructions.strip())
117
+ for name in project.workspace.instruction_files:
118
+ candidate = Path(name).expanduser()
119
+ if not candidate.is_absolute():
120
+ candidate = self.root / name
121
+ if candidate.is_file():
122
+ blocks.append(
123
+ f"Instructions from: {candidate}\n"
124
+ f"{candidate.read_text(encoding='utf-8', errors='replace')}"
125
+ )
126
+ agents = self.root / "AGENTS.md"
127
+ if agents.is_file() and "AGENTS.md" not in project.workspace.instruction_files:
128
+ blocks.append(
129
+ f"Instructions from: {agents}\n"
130
+ f"{agents.read_text(encoding='utf-8', errors='replace')}"
131
+ )
132
+ return "\n\n".join(blocks)
133
+
134
+ def notes_digest(self, limit: int = 8) -> str:
135
+ """A structured digest of the agent's own notes, oldest first."""
136
+ files = sorted(
137
+ (self.root / "notes").glob("*"),
138
+ key=lambda p: p.stat().st_mtime,
139
+ reverse=True,
140
+ )[:limit]
141
+ if not files:
142
+ return "(no notes yet)"
143
+ lines: list[str] = []
144
+ for path in reversed(files):
145
+ try:
146
+ text = path.read_text(encoding="utf-8", errors="replace").strip()
147
+ except OSError:
148
+ continue
149
+ snippet = " ".join(text.split())[:300]
150
+ lines.append(f"- {path.name}: {snippet}")
151
+ return "\n".join(lines) or "(no notes yet)"
152
+
153
+ def inventory(self) -> list[dict[str, Any]]:
154
+ tools = self.path_for("tools")
155
+ entries: list[dict[str, Any]] = []
156
+ for path in sorted(tools.iterdir()):
157
+ if path.name == "bin":
158
+ continue
159
+ entries.append(
160
+ {
161
+ "name": path.name,
162
+ "kind": "dir" if path.is_dir() else "file",
163
+ }
164
+ )
165
+ for path in sorted((tools / "bin").iterdir() if (tools / "bin").exists() else []):
166
+ entries.append({"name": f"bin/{path.name}", "kind": "binary"})
167
+ return entries
168
+
169
+ def stats(self) -> dict[str, int]:
170
+ counts: dict[str, int] = {}
171
+ for kind in ("tools", "recon", "loot", "notes", "sessions"):
172
+ directory = self.path_for(kind)
173
+ counts[kind] = sum(1 for _ in directory.rglob("*") if _.is_file())
174
+ return counts
175
+
176
+
177
+ def workspace_for(project: ProjectConfig, base: Path | None = None) -> Workspace:
178
+ configured = project.workspace.path.strip()
179
+ if configured:
180
+ root = Path(configured).expanduser()
181
+ if not root.is_absolute():
182
+ root = (base or Path.cwd()) / root
183
+ else:
184
+ root = (base or Path.cwd()) / "splitagent-workspace"
185
+ return Workspace(root=root, config=project.workspace)
186
+
187
+
188
+ # --------------------------------------------------------------------------- #
189
+ # Tool installation
190
+ # --------------------------------------------------------------------------- #
191
+ INSTALLERS = {
192
+ "pip": [sys.executable, "-m", "pip", "install", "--disable-pip-version-check"],
193
+ "pipx": ["pipx", "install"],
194
+ "npm": ["npm", "install", "--no-audit", "--no-fund", "-g"],
195
+ "go": ["go", "install"],
196
+ "cargo": ["cargo", "install"],
197
+ "apt": ["apt-get", "install", "-y"],
198
+ "brew": ["brew", "install"],
199
+ "winget": [
200
+ "winget",
201
+ "install",
202
+ "--id",
203
+ "{package}",
204
+ "--accept-source-agreements",
205
+ "--accept-package-agreements",
206
+ "--disable-interactivity",
207
+ ],
208
+ "binary": None, # handled as a download
209
+ }
210
+
211
+ # Tools worth trying before a full install, and how to get them per platform.
212
+ TOOL_INSTALL_HINTS: dict[str, list[tuple[str, str]]] = {
213
+ "nmap": [("winget", "Insecure.Nmap"), ("apt", "nmap"), ("brew", "nmap")],
214
+ "masscan": [("winget", "Robertof.Masscan"), ("apt", "masscan"), ("brew", "masscan")],
215
+ "nikto": [("apt", "nikto"), ("brew", "nikto")],
216
+ "sqlmap": [("pip", "sqlmap"), ("apt", "sqlmap"), ("brew", "sqlmap")],
217
+ "ffuf": [("go", "github.com/ffuf/ffuf/v2@latest"), ("brew", "ffuf")],
218
+ "gobuster": [("go", "github.com/OJ/gobuster/v3@latest"), ("apt", "gobuster")],
219
+ "nuclei": [("go", "github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest")],
220
+ "subfinder": [("go", "github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest")],
221
+ "httpx": [("go", "github.com/projectdiscovery/httpx/cmd/httpx@latest")],
222
+ "whatweb": [("apt", "whatweb"), ("brew", "whatweb")],
223
+ "dnsx": [("go", "github.com/projectdiscovery/dnsx/cmd/dnsx@latest")],
224
+ }
225
+
226
+ # Location of tools installed through Go, which land in GOBIN/GOPATH/bin.
227
+ GO_BIN_DIRS = ("gopath_bin", "gobin")
228
+
229
+
230
+ def go_bin_dir() -> Path | None:
231
+ for env in ("GOBIN", "GOPATH"):
232
+ value = os.environ.get(env)
233
+ if not value:
234
+ continue
235
+ path = Path(value)
236
+ return path if env == "GOBIN" else path / "bin"
237
+ home = Path.home()
238
+ candidate = home / "go" / "bin"
239
+ return candidate if candidate.exists() else None
240
+
241
+
242
+ def available_installers() -> list[str]:
243
+ known = ("pip", "pipx", "npm", "go", "cargo", "apt", "brew", "winget", "git")
244
+ return [name for name in known if shutil.which(name)]
245
+
246
+
247
+ # Managers that only exist inside the toolbox container.
248
+ TOOLBOX_INSTALLERS = ("apt", "pip", "pipx", "go", "npm", "cargo", "git")
249
+
250
+
251
+ def toolbox_installers() -> list[str]:
252
+ return list(TOOLBOX_INSTALLERS)
253
+
254
+
255
+ def install_suggestions(tool: str) -> list[dict[str, str]]:
256
+ """Preferred (manager, package) pairs for a well-known security tool."""
257
+ hints = TOOL_INSTALL_HINTS.get(tool.lower())
258
+ if not hints:
259
+ return []
260
+ available = set(available_installers())
261
+ return [
262
+ {"manager": manager, "package": package}
263
+ for manager, package in hints
264
+ if manager in available
265
+ ]
266
+
267
+
268
+ def which_tool(name: str, workspace: Workspace | None = None) -> str | None:
269
+ """Resolve a tool: PATH first, then the workspace bin, then Go's bin dir."""
270
+ found = shutil.which(name)
271
+ if found:
272
+ return found
273
+ if workspace is not None:
274
+ candidate = workspace.path_for("tools") / "bin" / name
275
+ for suffix in ("", ".exe", ".cmd", ".bat"):
276
+ if Path(str(candidate) + suffix).is_file():
277
+ return str(candidate) + suffix
278
+ go_dir = go_bin_dir()
279
+ if go_dir is not None:
280
+ for suffix in ("", ".exe"):
281
+ candidate = go_dir / f"{name}{suffix}"
282
+ if candidate.is_file():
283
+ return str(candidate)
284
+ return None
285
+
286
+
287
+ def install_command(manager: str, package: str, workspace: Workspace) -> list[str]:
288
+ if manager == "pip":
289
+ venv = workspace.path_for("tools") / "venv"
290
+ python = _venv_python(venv)
291
+ return [str(python), "-m", "pip", "install", "--disable-pip-version-check", package]
292
+ if manager == "git":
293
+ target = workspace.path_for("tools") / package.rstrip("/").split("/")[-1].replace(
294
+ ".git", ""
295
+ )
296
+ return ["git", "clone", "--depth", "1", package, str(target)]
297
+ template = INSTALLERS.get(manager)
298
+ if template is None:
299
+ raise ValueError(f"unknown installer '{manager}'")
300
+ # Templates may already carry a {package} placeholder (winget); otherwise
301
+ # the package name is appended.
302
+ if any("{package}" in part for part in template):
303
+ return [part.replace("{package}", package) for part in template]
304
+ return [*template, package]
305
+
306
+
307
+ def _venv_python(venv: Path) -> Path:
308
+ if os.name == "nt":
309
+ return venv / "Scripts" / "python.exe"
310
+ return venv / "bin" / "python"
311
+
312
+
313
+ def ensure_venv(workspace: Workspace) -> Path:
314
+ """A dedicated virtualenv inside the workspace so installs stay contained."""
315
+ venv = workspace.path_for("tools") / "venv"
316
+ python = _venv_python(venv)
317
+ if python.exists():
318
+ return python
319
+ import venv as venv_module
320
+
321
+ venv_module.create(venv, with_pip=True, clear=False)
322
+ return _venv_python(venv)
323
+
324
+
325
+ def run_install(
326
+ workspace: Workspace,
327
+ manager: str,
328
+ package: str,
329
+ timeout: int = 900,
330
+ ) -> dict[str, Any]:
331
+ workspace.ensure()
332
+ if manager == "pip":
333
+ ensure_venv(workspace)
334
+ if shutil.which(manager) is None and manager not in ("pip", "pipx"):
335
+ hints = install_suggestions(str(package).split("@")[0])
336
+ return {
337
+ "ok": False,
338
+ "error": f"'{manager}' is not installed on this machine",
339
+ "alternatives": hints,
340
+ "available_installers": available_installers(),
341
+ }
342
+ command = install_command(manager, package, workspace)
343
+ env = {**os.environ, **workspace.env}
344
+ if manager == "go":
345
+ # Keep Go binaries reachable by run_tool and the agents.
346
+ target = workspace.path_for("tools") / "bin"
347
+ env["GOBIN"] = str(target)
348
+ env["PATH"] = str(target) + os.pathsep + env.get("PATH", "")
349
+ try:
350
+ completed = proc.run(command, cwd=str(workspace.root), timeout=timeout, env=env)
351
+ except FileNotFoundError as exc:
352
+ return {"ok": False, "error": f"{command[0]} not found: {exc}"}
353
+ except subprocess.TimeoutExpired:
354
+ return {
355
+ "ok": False,
356
+ "error": f"installation timed out after {timeout}s",
357
+ "command": " ".join(command),
358
+ }
359
+ result = completed
360
+ stdout = (result.stdout or b"").decode("utf-8", "replace")
361
+ stderr = (result.stderr or b"").decode("utf-8", "replace")
362
+ output = stdout + stderr
363
+ return {
364
+ "ok": result.returncode == 0,
365
+ "manager": manager,
366
+ "package": package,
367
+ "command": " ".join(command),
368
+ "exit_code": result.returncode,
369
+ "output": output[-4000:],
370
+ }
371
+
372
+
373
+ def tool_environment(workspace: Workspace) -> dict[str, str]:
374
+ """Environment with the workspace bin and Go bin on PATH."""
375
+ env = {**os.environ, **workspace.env}
376
+ extra: list[str] = [str(workspace.path_for("tools") / "bin")]
377
+ go_dir = go_bin_dir()
378
+ if go_dir is not None:
379
+ extra.append(str(go_dir))
380
+ venv_bin = workspace.path_for("tools") / "venv" / ("Scripts" if os.name == "nt" else "bin")
381
+ if venv_bin.exists():
382
+ extra.append(str(venv_bin))
383
+ env["PATH"] = os.pathsep.join(extra) + os.pathsep + env.get("PATH", "")
384
+ return env
385
+
386
+
387
+ def run_workspace_command(
388
+ workspace: Workspace,
389
+ argv: list[str],
390
+ timeout: int = 300,
391
+ cwd_kind: str = "tools",
392
+ ) -> dict[str, Any]:
393
+ """Run a tool from inside the workspace in a controlled way."""
394
+ workspace.ensure()
395
+ if not argv:
396
+ return {"ok": False, "error": "empty command"}
397
+ cwd = workspace.path_for(cwd_kind)
398
+ env = tool_environment(workspace)
399
+ try:
400
+ completed = proc.run(argv, cwd=str(cwd), timeout=timeout, env=env)
401
+ except FileNotFoundError:
402
+ resolution = which_tool(argv[0], workspace)
403
+ return {
404
+ "ok": False,
405
+ "error": f"command not found: {argv[0]}"
406
+ + (f" (found at {resolution})" if resolution else ""),
407
+ "suggestions": install_suggestions(argv[0]),
408
+ }
409
+ except subprocess.TimeoutExpired:
410
+ return {"ok": False, "error": f"timed out after {timeout}s"}
411
+ result = completed
412
+ stdout = (result.stdout or b"").decode("utf-8", "replace")
413
+ stderr = (result.stderr or b"").decode("utf-8", "replace")
414
+ output = stdout + stderr
415
+ return {
416
+ "ok": result.returncode == 0,
417
+ "command": " ".join(argv),
418
+ "exit_code": result.returncode,
419
+ "output": output[-8000:],
420
+ }
@@ -0,0 +1,7 @@
1
+ """Native desktop application (pywebview) with an OpenCode-style UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from splitagent.desktop.app import DesktopApp
6
+
7
+ __all__ = ["DesktopApp"]