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,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
|
+
]
|