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,411 @@
1
+ """Workspace tools: the agents' own directory, tooling and notes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ import re
7
+ from typing import Any
8
+
9
+ from splitagent.core.workspace import (
10
+ available_installers,
11
+ install_suggestions,
12
+ run_install,
13
+ run_workspace_command,
14
+ toolbox_installers,
15
+ which_tool,
16
+ workspace_for,
17
+ )
18
+ from splitagent.tools.base import Tool, ToolContext
19
+
20
+ # A token that looks like a host, URL or IP inside a shell command.
21
+ _URL_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
22
+ _HOST_HINT_RE = re.compile(r"[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
23
+ _SCP_RE = re.compile(r"^[^@/]+@([A-Za-z0-9.-]+):")
24
+
25
+
26
+ def _scope_hosts_from_argv(argv: list[str]) -> list[str]:
27
+ """Best-effort extraction of network destinations from a command line.
28
+
29
+ run_tool/install_tool shell out, so a tool can reach hosts the higher-level
30
+ probes already validate. We pull out anything URL-, host- or IP-shaped and
31
+ let ``check_scope`` decide. A token that is clearly a local flag or file is
32
+ skipped so ordinary invocations are not blocked.
33
+ """
34
+ found: list[str] = []
35
+ for raw in argv:
36
+ token = str(raw).strip().strip("\"'")
37
+ if not token or token.startswith("-"):
38
+ continue
39
+ if _URL_RE.match(token):
40
+ found.append(token)
41
+ continue
42
+ # git remotes: git@host:path or user@host:path
43
+ scp = _SCP_RE.match(token)
44
+ if scp:
45
+ found.append(scp.group(1))
46
+ continue
47
+ if "/" in token or "\\" in token or token.startswith("."):
48
+ continue
49
+ try:
50
+ ipaddress.ip_address(token)
51
+ found.append(token)
52
+ continue
53
+ except ValueError:
54
+ pass
55
+ if _HOST_HINT_RE.fullmatch(token):
56
+ found.append(token)
57
+ return found
58
+
59
+
60
+ def _enforce_scope(ctx: ToolContext, argv: list[str]) -> None:
61
+ """Raise ScopeError if the command targets a host outside the scope."""
62
+ for host in _scope_hosts_from_argv(argv):
63
+ ctx.check_scope(host)
64
+
65
+
66
+ def _workspace(ctx: ToolContext):
67
+ workspace = ctx.settings.get("workspace")
68
+ if workspace is not None:
69
+ return workspace
70
+ project = ctx.settings.get("project")
71
+ return workspace_for(project) if project is not None else None
72
+
73
+
74
+ def _toolbox(ctx: ToolContext):
75
+ """The active toolbox, if the engine set one up for this session."""
76
+ return ctx.settings.get("toolbox")
77
+
78
+
79
+ def _backend(ctx: ToolContext) -> str:
80
+ return "toolbox" if _toolbox(ctx) is not None else "local"
81
+
82
+
83
+ def _write_file(ctx: ToolContext, path: str, content: str) -> dict[str, Any]:
84
+ workspace = _workspace(ctx)
85
+ if workspace is None:
86
+ return {"error": "workspace is not configured"}
87
+ workspace.ensure()
88
+ target = (workspace.root / path).resolve()
89
+ if not str(target).startswith(str(workspace.root.resolve())):
90
+ return {"error": "path escapes the workspace"}
91
+ target.parent.mkdir(parents=True, exist_ok=True)
92
+ target.write_text(content, encoding="utf-8")
93
+ return {"ok": True, "path": str(target), "bytes": len(content)}
94
+
95
+
96
+ def _read_file(ctx: ToolContext, path: str, max_bytes: int = 20000) -> dict[str, Any]:
97
+ workspace = _workspace(ctx)
98
+ if workspace is None:
99
+ return {"error": "workspace is not configured"}
100
+ target = (workspace.root / path).resolve()
101
+ if not str(target).startswith(str(workspace.root.resolve())):
102
+ return {"error": "path escapes the workspace"}
103
+ if not target.is_file():
104
+ return {"error": f"not a file: {path}"}
105
+ text = target.read_text(encoding="utf-8", errors="replace")
106
+ return {
107
+ "path": str(target),
108
+ "bytes": target.stat().st_size,
109
+ "content": text[:max_bytes],
110
+ "truncated": len(text) > max_bytes,
111
+ }
112
+
113
+
114
+ def _list_dir(ctx: ToolContext, path: str = ".") -> dict[str, Any]:
115
+ workspace = _workspace(ctx)
116
+ if workspace is None:
117
+ return {"error": "workspace is not configured"}
118
+ workspace.ensure()
119
+ target = (workspace.root / path).resolve()
120
+ if not str(target).startswith(str(workspace.root.resolve())):
121
+ return {"error": "path escapes the workspace"}
122
+ if not target.exists():
123
+ return {"error": f"not found: {path}"}
124
+ entries = []
125
+ for item in sorted(target.iterdir()):
126
+ entries.append(
127
+ {
128
+ "name": item.name,
129
+ "kind": "dir" if item.is_dir() else "file",
130
+ "size": item.stat().st_size if item.is_file() else None,
131
+ }
132
+ )
133
+ return {"path": str(target), "entries": entries[:200]}
134
+
135
+
136
+ def _install_tool(ctx: ToolContext, manager: str, package: str) -> dict[str, Any]:
137
+ workspace = _workspace(ctx)
138
+ if workspace is None:
139
+ return {"error": "workspace is not configured"}
140
+ if not workspace.config.allow_install:
141
+ return {"error": "tool installation is disabled by the operator"}
142
+ # Installing from a remote can reach a host outside the authorized scope.
143
+ _enforce_scope(ctx, str(package).split())
144
+
145
+ toolbox = _toolbox(ctx)
146
+ if toolbox is not None:
147
+ result = toolbox.install(manager, package)
148
+ result["backend"] = "toolbox"
149
+ result["supported_managers"] = toolbox_installers()
150
+ return result
151
+
152
+ timeout = max(30, int(workspace.config.max_install_seconds))
153
+ result = run_install(workspace, manager, package, timeout=timeout)
154
+ result["backend"] = "local"
155
+ result["available_installers"] = available_installers()
156
+ return result
157
+
158
+
159
+ def _run_command(
160
+ ctx: ToolContext, argv: list[str], cwd: str = "tools", timeout: int = 300
161
+ ) -> dict[str, Any]:
162
+ workspace = _workspace(ctx)
163
+ if workspace is None:
164
+ return {"error": "workspace is not configured"}
165
+ if not workspace.config.allow_external_tools:
166
+ return {"error": "running external tools is disabled by the operator"}
167
+ command = [str(a) for a in argv]
168
+ # run_tool shells out, so enforce the same scope every network probe obeys.
169
+ _enforce_scope(ctx, command)
170
+
171
+ toolbox = _toolbox(ctx)
172
+ if toolbox is not None:
173
+ # Inside the toolbox everything runs against /workspace.
174
+ workdir = "/workspace" if cwd == "root" else f"/workspace/{cwd}"
175
+ result = toolbox.exec(command, cwd=workdir, timeout=timeout)
176
+ result["backend"] = "toolbox"
177
+ if not result.get("ok") and "not found" in str(result.get("output", "")):
178
+ result["suggestions"] = install_suggestions(command[0])
179
+ return result
180
+
181
+ # Resolve the binary across PATH, the workspace bin and Go's bin.
182
+ resolved = which_tool(command[0], workspace)
183
+ if resolved:
184
+ command[0] = resolved
185
+ result = run_workspace_command(workspace, command, timeout=timeout, cwd_kind=cwd)
186
+ result["backend"] = "local"
187
+ return result
188
+
189
+
190
+ def _check_tool(ctx: ToolContext, name: str) -> dict[str, Any]:
191
+ toolbox = _toolbox(ctx)
192
+ if toolbox is not None:
193
+ path = toolbox.which(name)
194
+ return {
195
+ "tool": name,
196
+ "available": path is not None,
197
+ "path": path or "",
198
+ "backend": "toolbox",
199
+ "install_suggestions": install_suggestions(name),
200
+ }
201
+ workspace = _workspace(ctx)
202
+ resolved = which_tool(name, workspace)
203
+ return {
204
+ "tool": name,
205
+ "available": resolved is not None,
206
+ "path": resolved or "",
207
+ "backend": "local",
208
+ "install_suggestions": install_suggestions(name),
209
+ }
210
+
211
+
212
+ def _workspace_info(ctx: ToolContext) -> dict[str, Any]:
213
+ workspace = _workspace(ctx)
214
+ if workspace is None:
215
+ return {"error": "workspace is not configured"}
216
+ workspace.ensure()
217
+ toolbox = _toolbox(ctx)
218
+ backend = "toolbox" if toolbox is not None else "local"
219
+ info = {
220
+ "root": str(workspace.root),
221
+ "backend": backend,
222
+ "container_path": "/workspace" if toolbox is not None else None,
223
+ "structure": {
224
+ "tools": str(workspace.path_for("tools")),
225
+ "recon": str(workspace.path_for("recon")),
226
+ "loot": str(workspace.path_for("loot")),
227
+ "notes": str(workspace.path_for("notes")),
228
+ "sessions": str(workspace.path_for("sessions")),
229
+ },
230
+ "installers": (toolbox_installers() if toolbox is not None else available_installers()),
231
+ "security_tools": {
232
+ name: _check_tool(ctx, name)["available"]
233
+ for name in (
234
+ "nmap",
235
+ "nuclei",
236
+ "ffuf",
237
+ "gobuster",
238
+ "sqlmap",
239
+ "nikto",
240
+ "subfinder",
241
+ "httpx",
242
+ "masscan",
243
+ "whatweb",
244
+ )
245
+ },
246
+ "allow_install": workspace.config.allow_install,
247
+ "allow_external_tools": workspace.config.allow_external_tools,
248
+ "inventory": workspace.inventory()[:60],
249
+ "file_counts": workspace.stats(),
250
+ "notes": workspace.notes_digest(),
251
+ }
252
+ if toolbox is not None:
253
+ info["toolbox"] = {
254
+ "container": toolbox.config.container,
255
+ "image": toolbox.config.image,
256
+ "edition": toolbox.config.edition,
257
+ "isolated": True,
258
+ "note": (
259
+ "You are running inside an isolated container. The host is never "
260
+ "touched: install and run anything here freely."
261
+ ),
262
+ }
263
+ return info
264
+
265
+
266
+ def workspace_tools(ctx: ToolContext) -> list[Tool]:
267
+ return [
268
+ Tool(
269
+ name="workspace_info",
270
+ description=(
271
+ "Inspect your persistent working directory: structure, installed "
272
+ "tooling, available package managers and your own notes. Call "
273
+ "this before planning so you know what you already have."
274
+ ),
275
+ parameters={"type": "object", "properties": {}},
276
+ func=lambda: _workspace_info(ctx),
277
+ scope="shared",
278
+ ),
279
+ Tool(
280
+ name="workspace_write",
281
+ description=(
282
+ "Write a file inside your workspace (notes, recon output, "
283
+ "scripts). Use it to keep structured context for yourself: "
284
+ "`notes/<topic>.md` for plans, `recon/<target>.json` for data."
285
+ ),
286
+ parameters={
287
+ "type": "object",
288
+ "properties": {
289
+ "path": {
290
+ "type": "string",
291
+ "description": "Relative path, e.g. notes/plan.md",
292
+ },
293
+ "content": {"type": "string"},
294
+ },
295
+ "required": ["path", "content"],
296
+ },
297
+ func=lambda path, content: _write_file(ctx, path, content),
298
+ scope="shared",
299
+ ),
300
+ Tool(
301
+ name="workspace_read",
302
+ description="Read a file from your workspace.",
303
+ parameters={
304
+ "type": "object",
305
+ "properties": {"path": {"type": "string"}},
306
+ "required": ["path"],
307
+ },
308
+ func=lambda path: _read_file(ctx, path),
309
+ scope="shared",
310
+ ),
311
+ Tool(
312
+ name="workspace_list",
313
+ description="List a directory inside your workspace.",
314
+ parameters={
315
+ "type": "object",
316
+ "properties": {"path": {"type": "string", "default": "."}},
317
+ },
318
+ func=lambda path=".": _list_dir(ctx, path),
319
+ scope="shared",
320
+ ),
321
+ Tool(
322
+ name="install_tool",
323
+ description=(
324
+ "Install a tool for the engagement. In toolbox mode this runs "
325
+ "inside the isolated container (apt/pip/pipx/go/npm/cargo/git), "
326
+ "so it never touches the host. In local mode pip goes into an "
327
+ "isolated virtualenv and npm/go/cargo/apt/brew/winget use the "
328
+ "system manager. Check `workspace_info` first for what exists."
329
+ ),
330
+ parameters={
331
+ "type": "object",
332
+ "properties": {
333
+ "manager": {
334
+ "type": "string",
335
+ "enum": [
336
+ "apt",
337
+ "pip",
338
+ "pipx",
339
+ "go",
340
+ "npm",
341
+ "cargo",
342
+ "git",
343
+ "brew",
344
+ "winget",
345
+ ],
346
+ },
347
+ "package": {
348
+ "type": "string",
349
+ "description": "Package name, or a git URL for `git`.",
350
+ },
351
+ },
352
+ "required": ["manager", "package"],
353
+ },
354
+ func=lambda manager, package: _install_tool(ctx, manager, package),
355
+ scope="shared",
356
+ dangerous=True,
357
+ parallel_safe=False,
358
+ ),
359
+ Tool(
360
+ name="check_tool",
361
+ description=(
362
+ "Check whether a tool is available (PATH, your workspace bin or "
363
+ "Go's bin) and get install suggestions if it is missing. Call "
364
+ "this for nmap, nuclei, ffuf, sqlmap, nikto, etc. before "
365
+ "installing anything."
366
+ ),
367
+ parameters={
368
+ "type": "object",
369
+ "properties": {
370
+ "name": {
371
+ "type": "string",
372
+ "description": "Executable name, e.g. nmap",
373
+ }
374
+ },
375
+ "required": ["name"],
376
+ },
377
+ func=lambda name: _check_tool(ctx, name),
378
+ scope="shared",
379
+ ),
380
+ Tool(
381
+ name="run_tool",
382
+ description=(
383
+ "Run a command from your workspace (cwd defaults to tools/). "
384
+ "The binary is resolved from PATH, your tools/bin and Go's bin, "
385
+ "so installed scanners like nmap, nuclei or ffuf work directly. "
386
+ "Redirect heavy output to recon/ and prefer non-invasive flags "
387
+ "plus rate limits to avoid tripping firewalls or IDS."
388
+ ),
389
+ parameters={
390
+ "type": "object",
391
+ "properties": {
392
+ "argv": {
393
+ "type": "array",
394
+ "items": {"type": "string"},
395
+ "description": 'Command and arguments, e.g. ["nmap","-sV","127.0.0.1"]',
396
+ },
397
+ "cwd": {
398
+ "type": "string",
399
+ "enum": ["tools", "recon", "notes", "loot", "root"],
400
+ "default": "tools",
401
+ },
402
+ "timeout": {"type": "integer", "default": 300},
403
+ },
404
+ "required": ["argv"],
405
+ },
406
+ func=lambda argv, cwd="tools", timeout=300: _run_command(ctx, argv, cwd, timeout),
407
+ scope="shared",
408
+ dangerous=True,
409
+ parallel_safe=False,
410
+ ),
411
+ ]
@@ -0,0 +1,5 @@
1
+ """Terminal user interfaces (Rich streaming + Textual TUI)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["theme"]