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
splitagent/cli.py
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""Command line interface for SplitAgent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from splitagent import __version__
|
|
18
|
+
from splitagent.config import (
|
|
19
|
+
PROVIDER_PRESETS,
|
|
20
|
+
GlobalConfig,
|
|
21
|
+
ProjectConfig,
|
|
22
|
+
apply_provider_preset,
|
|
23
|
+
global_config_path,
|
|
24
|
+
load_global_config,
|
|
25
|
+
load_project_config,
|
|
26
|
+
project_config_path,
|
|
27
|
+
save_global_config,
|
|
28
|
+
save_project_config,
|
|
29
|
+
)
|
|
30
|
+
from splitagent.core.bus import EventBus
|
|
31
|
+
from splitagent.core.engine import Engine
|
|
32
|
+
from splitagent.core.sandbox import TARGET_PRESETS, DockerSandbox
|
|
33
|
+
from splitagent.errors import SplitAgentError
|
|
34
|
+
from splitagent.ui import theme as t
|
|
35
|
+
from splitagent.ui.stream import StreamRenderer
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
# Helpers
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
def _banner() -> None:
|
|
44
|
+
console.print(Text(t.BANNER, style=t.PRIMARY))
|
|
45
|
+
console.print(
|
|
46
|
+
Text(
|
|
47
|
+
f" v{__version__} - config: {global_config_path()}",
|
|
48
|
+
style=t.MUTED,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
console.print()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _prompt(label: str, default: str = "", secret: bool = False) -> str:
|
|
55
|
+
suffix = f" [{default}]" if default else ""
|
|
56
|
+
try:
|
|
57
|
+
if secret:
|
|
58
|
+
import getpass
|
|
59
|
+
|
|
60
|
+
value = getpass.getpass(f"{label}{suffix}: ")
|
|
61
|
+
else:
|
|
62
|
+
value = input(f"{label}{suffix}: ")
|
|
63
|
+
except (EOFError, KeyboardInterrupt):
|
|
64
|
+
console.print()
|
|
65
|
+
return default
|
|
66
|
+
return value.strip() or default
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_setup_wizard(config: GlobalConfig | None = None, test: bool = True) -> GlobalConfig:
|
|
70
|
+
"""Interactive provider/model setup, persisted to the global config."""
|
|
71
|
+
config = config or load_global_config()
|
|
72
|
+
console.print(Panel("Configure the model API", border_style=t.PRIMARY))
|
|
73
|
+
console.print(
|
|
74
|
+
Text(
|
|
75
|
+
"SplitAgent talks to any model over an HTTP API. Pick a provider or "
|
|
76
|
+
"choose 'custom' for a self-hosted OpenAI-compatible endpoint.",
|
|
77
|
+
style=t.MUTED,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
names = list(PROVIDER_PRESETS)
|
|
81
|
+
for index, name in enumerate(names, 1):
|
|
82
|
+
preset = PROVIDER_PRESETS[name]
|
|
83
|
+
console.print(f" [{t.PRIMARY}]{index:>2}[/] {name:<12} {preset['base_url']}")
|
|
84
|
+
choice = _prompt("Provider (name or number)", config.llm.provider or "openai")
|
|
85
|
+
if choice.isdigit() and 1 <= int(choice) <= len(names):
|
|
86
|
+
choice = names[int(choice) - 1]
|
|
87
|
+
if choice not in PROVIDER_PRESETS:
|
|
88
|
+
console.print(f"[{t.ORANGE}]Unknown provider; using 'custom'.[/]")
|
|
89
|
+
choice = "custom"
|
|
90
|
+
apply_provider_preset(config.llm, choice)
|
|
91
|
+
|
|
92
|
+
config.llm.base_url = _prompt("Base URL", config.llm.base_url)
|
|
93
|
+
config.llm.model = _prompt("Model", config.llm.model)
|
|
94
|
+
key = _prompt("API key (blank to use env var)", "", secret=True)
|
|
95
|
+
if key:
|
|
96
|
+
config.llm.api_key = key
|
|
97
|
+
temp = _prompt("Temperature", str(config.llm.temperature))
|
|
98
|
+
try:
|
|
99
|
+
config.llm.temperature = float(temp)
|
|
100
|
+
except ValueError:
|
|
101
|
+
pass
|
|
102
|
+
config.authorized = True
|
|
103
|
+
|
|
104
|
+
if test:
|
|
105
|
+
console.print(Text("Testing connection...", style=t.MUTED))
|
|
106
|
+
from splitagent.llm.client import LLMClient
|
|
107
|
+
|
|
108
|
+
async def _probe() -> tuple[bool, str]:
|
|
109
|
+
async with LLMClient(config.llm) as client:
|
|
110
|
+
return await client.test_connection()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
ok, message = asyncio.run(_probe())
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
ok, message = False, str(exc)
|
|
116
|
+
if ok:
|
|
117
|
+
console.print(f"[{t.GREEN}]Connection OK[/] - {message}")
|
|
118
|
+
else:
|
|
119
|
+
console.print(f"[{t.ORANGE}]Connection failed:[/] {message}")
|
|
120
|
+
if not _prompt("Save anyway? (y/n)", "y").lower().startswith("y"):
|
|
121
|
+
return config
|
|
122
|
+
|
|
123
|
+
path = save_global_config(config)
|
|
124
|
+
console.print(f"[{t.GREEN}]Saved[/] {path}")
|
|
125
|
+
return config
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
# Commands
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
132
|
+
_banner()
|
|
133
|
+
path = Path(args.path) if args.path else Path.cwd() / "splitagent.yaml"
|
|
134
|
+
if path.exists() and not args.force:
|
|
135
|
+
console.print(f"[{t.ORANGE}]{path} already exists (use --force).[/]")
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
project = ProjectConfig()
|
|
139
|
+
project.name = args.name or Path.cwd().name
|
|
140
|
+
|
|
141
|
+
if args.preset:
|
|
142
|
+
if args.preset not in TARGET_PRESETS:
|
|
143
|
+
console.print(
|
|
144
|
+
f"[{t.RED}]Unknown preset '{args.preset}'. Known: {', '.join(TARGET_PRESETS)}[/]"
|
|
145
|
+
)
|
|
146
|
+
return 1
|
|
147
|
+
preset = TARGET_PRESETS[args.preset]
|
|
148
|
+
project.target.url = preset["url"]
|
|
149
|
+
project.target.ports = [int(p) for p in preset["port_map"]]
|
|
150
|
+
project.run.sandbox.image = preset["image"]
|
|
151
|
+
project.run.sandbox.port_map = preset["port_map"]
|
|
152
|
+
|
|
153
|
+
if args.url:
|
|
154
|
+
project.target.url = args.url
|
|
155
|
+
if args.kind:
|
|
156
|
+
project.target.kind = args.kind
|
|
157
|
+
if args.hosts:
|
|
158
|
+
project.target.hosts = list(args.hosts)
|
|
159
|
+
if args.rounds:
|
|
160
|
+
project.run.rounds = args.rounds
|
|
161
|
+
|
|
162
|
+
if not project.target.url and not project.target.hosts and not args.yes:
|
|
163
|
+
project.target.kind = _prompt("Target kind (web/api/network/repo)", project.target.kind)
|
|
164
|
+
project.target.url = _prompt("Target URL", project.target.url)
|
|
165
|
+
hosts = _prompt("Additional hosts (comma separated)", "")
|
|
166
|
+
if hosts:
|
|
167
|
+
project.target.hosts = [h.strip() for h in hosts.split(",") if h.strip()]
|
|
168
|
+
rounds = _prompt("Rounds", str(project.run.rounds))
|
|
169
|
+
try:
|
|
170
|
+
project.run.rounds = int(rounds)
|
|
171
|
+
except ValueError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
if not project.target.scope:
|
|
175
|
+
project.target.scope = project.target.effective_hosts() or (
|
|
176
|
+
["localhost"] if project.target.url else []
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# A real server must not be audited through a lab container.
|
|
180
|
+
external = _is_external_target(project.target)
|
|
181
|
+
if external:
|
|
182
|
+
project.run.sandbox.enabled = False
|
|
183
|
+
project.run.allow_network = True
|
|
184
|
+
|
|
185
|
+
saved = save_project_config(project, path)
|
|
186
|
+
console.print(f"[{t.GREEN}]Project written to[/] {saved}")
|
|
187
|
+
if external:
|
|
188
|
+
console.print(
|
|
189
|
+
Text(
|
|
190
|
+
f"Target {project.target.effective_hosts()[0]} looks like a real "
|
|
191
|
+
"server: the Docker sandbox is off and scope enforcement is "
|
|
192
|
+
"managed through target.scope.",
|
|
193
|
+
style=t.MUTED,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
console.print(
|
|
197
|
+
Text(
|
|
198
|
+
"Next: `splitagent config setup` (once) then `splitagent run`.",
|
|
199
|
+
style=t.MUTED,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_external_target(target: Any) -> bool:
|
|
206
|
+
"""True when the target is a real host rather than a local lab."""
|
|
207
|
+
hosts = target.effective_hosts()
|
|
208
|
+
if not hosts:
|
|
209
|
+
return False
|
|
210
|
+
host = hosts[0].lower()
|
|
211
|
+
if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0", "host.docker.internal"):
|
|
212
|
+
return False
|
|
213
|
+
try:
|
|
214
|
+
import ipaddress
|
|
215
|
+
|
|
216
|
+
address = ipaddress.ip_address(host)
|
|
217
|
+
return not (address.is_loopback or address.is_private or address.is_link_local)
|
|
218
|
+
except ValueError:
|
|
219
|
+
pass
|
|
220
|
+
return not host.endswith((".local", ".test", ".internal"))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
224
|
+
action = args.action or "show"
|
|
225
|
+
if action == "setup":
|
|
226
|
+
run_setup_wizard(test=not args.no_test)
|
|
227
|
+
return 0
|
|
228
|
+
if action == "path":
|
|
229
|
+
console.print(str(global_config_path()))
|
|
230
|
+
return 0
|
|
231
|
+
if action == "show":
|
|
232
|
+
config = load_global_config()
|
|
233
|
+
_banner()
|
|
234
|
+
table = Table(title="Global configuration", border_style=t.BORDER)
|
|
235
|
+
table.add_column("Key", style=t.PRIMARY)
|
|
236
|
+
table.add_column("Value", style=t.TEXT)
|
|
237
|
+
for key, current in config.llm.redacted().items():
|
|
238
|
+
table.add_row(f"llm.{key}", str(current))
|
|
239
|
+
table.add_row("authorized", str(config.authorized))
|
|
240
|
+
console.print(table)
|
|
241
|
+
console.print(Text(f"File: {global_config_path()}", style=t.MUTED))
|
|
242
|
+
return 0
|
|
243
|
+
if action == "set":
|
|
244
|
+
config = load_global_config()
|
|
245
|
+
if args.key not in config.llm.redacted():
|
|
246
|
+
console.print(f"[{t.RED}]Unknown key '{args.key}'[/]")
|
|
247
|
+
return 1
|
|
248
|
+
value: object = args.value
|
|
249
|
+
if args.key in ("temperature",):
|
|
250
|
+
value = float(args.value)
|
|
251
|
+
elif args.key in ("max_tokens", "timeout"):
|
|
252
|
+
value = int(args.value)
|
|
253
|
+
# (value stays a str for the remaining keys)
|
|
254
|
+
elif args.key in ("stream",):
|
|
255
|
+
value = args.value.lower() in ("1", "true", "yes", "on")
|
|
256
|
+
setattr(config.llm, args.key, value)
|
|
257
|
+
path = save_global_config(config)
|
|
258
|
+
console.print(f"[{t.GREEN}]Set llm.{args.key} - saved to {path}[/]")
|
|
259
|
+
return 0
|
|
260
|
+
if action == "preset":
|
|
261
|
+
# Accept both `config preset deepseek` and `config preset --provider deepseek`.
|
|
262
|
+
provider = args.provider or args.key
|
|
263
|
+
if not provider:
|
|
264
|
+
console.print(
|
|
265
|
+
f"[{t.RED}]Usage: splitagent config preset <provider>[/]\n"
|
|
266
|
+
f"[{t.MUTED}]Known: {', '.join(PROVIDER_PRESETS)}[/]"
|
|
267
|
+
)
|
|
268
|
+
return 1
|
|
269
|
+
config = load_global_config()
|
|
270
|
+
try:
|
|
271
|
+
apply_provider_preset(config.llm, provider)
|
|
272
|
+
except SplitAgentError as exc:
|
|
273
|
+
console.print(f"[{t.RED}]{exc}[/]")
|
|
274
|
+
return 1
|
|
275
|
+
path = save_global_config(config)
|
|
276
|
+
console.print(f"[{t.GREEN}]Provider set to {provider} ({config.llm.base_url}) - {path}[/]")
|
|
277
|
+
return 0
|
|
278
|
+
console.print(f"[{t.RED}]Unknown config action '{action}'[/]")
|
|
279
|
+
return 1
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _load_project_or_fail(args: argparse.Namespace) -> ProjectConfig:
|
|
283
|
+
path = Path(args.config) if getattr(args, "config", None) else project_config_path()
|
|
284
|
+
if not path.exists():
|
|
285
|
+
raise SplitAgentError(f"No project config at {path}. Run `splitagent init` first.")
|
|
286
|
+
project = load_project_config(path)
|
|
287
|
+
if getattr(args, "rounds", None):
|
|
288
|
+
project.run.rounds = args.rounds
|
|
289
|
+
if getattr(args, "url", None):
|
|
290
|
+
project.target.url = args.url
|
|
291
|
+
if getattr(args, "max_steps", None):
|
|
292
|
+
project.run.max_steps = args.max_steps
|
|
293
|
+
if getattr(args, "no_sandbox", False):
|
|
294
|
+
project.run.sandbox.enabled = False
|
|
295
|
+
if getattr(args, "allow_network", False):
|
|
296
|
+
project.run.allow_network = True
|
|
297
|
+
return project
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _run_sync(project: ProjectConfig, args: argparse.Namespace) -> int:
|
|
301
|
+
global_config = load_global_config()
|
|
302
|
+
if not global_config.configured:
|
|
303
|
+
console.print(f"[{t.ORANGE}]No model configured.[/] Launching the setup wizard.")
|
|
304
|
+
global_config = run_setup_wizard(global_config)
|
|
305
|
+
|
|
306
|
+
bus = EventBus()
|
|
307
|
+
renderer = StreamRenderer(console=console, show_thinking=True)
|
|
308
|
+
renderer.attach(bus)
|
|
309
|
+
renderer.start()
|
|
310
|
+
try:
|
|
311
|
+
engine = Engine(global_config, project, bus=bus)
|
|
312
|
+
context = asyncio.run(engine.run())
|
|
313
|
+
except KeyboardInterrupt:
|
|
314
|
+
console.print(f"\n[{t.ORANGE}]Interrupted.[/]")
|
|
315
|
+
return 130
|
|
316
|
+
finally:
|
|
317
|
+
renderer.stop()
|
|
318
|
+
|
|
319
|
+
state = context.state
|
|
320
|
+
summary = Table(box=None, show_header=False)
|
|
321
|
+
summary.add_column(style=t.MUTED)
|
|
322
|
+
summary.add_column(style=t.TEXT)
|
|
323
|
+
summary.add_row("session", state.id)
|
|
324
|
+
summary.add_row("findings", str(len(state.findings)))
|
|
325
|
+
summary.add_row("mitigations", str(len(state.mitigations)))
|
|
326
|
+
summary.add_row("resilience", f"{state.resilience_score()}/100")
|
|
327
|
+
summary.add_row("session file", str(context.path_for()))
|
|
328
|
+
console.print(Panel(summary, title="[bold]Summary[/]", border_style=t.PRIMARY))
|
|
329
|
+
|
|
330
|
+
if not args.no_report:
|
|
331
|
+
from splitagent.report.generator import write_reports
|
|
332
|
+
|
|
333
|
+
paths = write_reports(state, project.report, Path(project.report.output_dir))
|
|
334
|
+
for path in paths:
|
|
335
|
+
console.print(f"[{t.GREEN}]report[/] {path}")
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
340
|
+
if getattr(args, "desktop", False):
|
|
341
|
+
return cmd_desktop(args)
|
|
342
|
+
_banner()
|
|
343
|
+
if args.tui:
|
|
344
|
+
return cmd_tui(args)
|
|
345
|
+
try:
|
|
346
|
+
project = _load_project_or_fail(args)
|
|
347
|
+
except SplitAgentError as exc:
|
|
348
|
+
console.print(f"[{t.RED}]{exc}[/]")
|
|
349
|
+
return 1
|
|
350
|
+
return _run_sync(project, args)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def cmd_tui(args: argparse.Namespace) -> int:
|
|
354
|
+
from splitagent.ui.app import SplitAgentApp
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
project = _load_project_or_fail(args)
|
|
358
|
+
except SplitAgentError as exc:
|
|
359
|
+
console.print(f"[{t.RED}]{exc}[/]")
|
|
360
|
+
return 1
|
|
361
|
+
global_config = load_global_config()
|
|
362
|
+
app = SplitAgentApp(global_config, project)
|
|
363
|
+
app.run()
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def cmd_desktop(args: argparse.Namespace) -> int:
|
|
368
|
+
"""Launch the native desktop application (pywebview)."""
|
|
369
|
+
try:
|
|
370
|
+
project = _load_project_or_fail(args)
|
|
371
|
+
except SplitAgentError as exc:
|
|
372
|
+
console.print(f"[{t.RED}]{exc}[/]")
|
|
373
|
+
return 1
|
|
374
|
+
try:
|
|
375
|
+
from splitagent.desktop import DesktopApp
|
|
376
|
+
except ImportError as exc: # pragma: no cover - dependency is declared
|
|
377
|
+
console.print(
|
|
378
|
+
f"[{t.RED}]The desktop app requires pywebview:[/] pip install pywebview\n{exc}"
|
|
379
|
+
)
|
|
380
|
+
return 1
|
|
381
|
+
global_config = load_global_config()
|
|
382
|
+
app = DesktopApp(global_config, project)
|
|
383
|
+
app.run()
|
|
384
|
+
return 0
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
388
|
+
from splitagent.core.context import SharedContext
|
|
389
|
+
from splitagent.report.generator import write_reports
|
|
390
|
+
|
|
391
|
+
if args.input:
|
|
392
|
+
path = Path(args.input)
|
|
393
|
+
if not path.exists():
|
|
394
|
+
console.print(f"[{t.RED}]File not found: {path}[/]")
|
|
395
|
+
return 1
|
|
396
|
+
context = SharedContext.load_path(path)
|
|
397
|
+
else:
|
|
398
|
+
if not args.session:
|
|
399
|
+
console.print(f"[{t.RED}]Provide --session <id> or --input <file>[/]")
|
|
400
|
+
return 1
|
|
401
|
+
context = SharedContext.load(args.session)
|
|
402
|
+
|
|
403
|
+
project = ProjectConfig()
|
|
404
|
+
if project_config_path().exists():
|
|
405
|
+
project = load_project_config()
|
|
406
|
+
if args.formats:
|
|
407
|
+
project.report.formats = args.formats
|
|
408
|
+
if args.output:
|
|
409
|
+
project.report.output_dir = args.output
|
|
410
|
+
try:
|
|
411
|
+
paths = write_reports(context.state, project.report, Path(project.report.output_dir))
|
|
412
|
+
except (OSError, ValueError) as exc:
|
|
413
|
+
console.print(f"[{t.RED}]Could not write report: {exc}[/]")
|
|
414
|
+
return 1
|
|
415
|
+
for path in paths:
|
|
416
|
+
console.print(f"[{t.GREEN}]report[/] {path}")
|
|
417
|
+
console.print(
|
|
418
|
+
Text(
|
|
419
|
+
f"Resilience {context.state.resilience_score()}/100 - "
|
|
420
|
+
f"{len(context.state.findings)} findings",
|
|
421
|
+
style=t.MUTED,
|
|
422
|
+
)
|
|
423
|
+
)
|
|
424
|
+
return 0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def cmd_sandbox(args: argparse.Namespace) -> int:
|
|
428
|
+
_banner()
|
|
429
|
+
project = ProjectConfig()
|
|
430
|
+
if project_config_path().exists():
|
|
431
|
+
project = load_project_config()
|
|
432
|
+
if args.preset:
|
|
433
|
+
preset = TARGET_PRESETS.get(args.preset)
|
|
434
|
+
if not preset:
|
|
435
|
+
console.print(f"[{t.RED}]Unknown preset '{args.preset}'[/]")
|
|
436
|
+
return 1
|
|
437
|
+
project.run.sandbox.image = preset["image"]
|
|
438
|
+
project.run.sandbox.port_map = preset["port_map"]
|
|
439
|
+
|
|
440
|
+
sandbox = DockerSandbox(project.run.sandbox, session_id="cli")
|
|
441
|
+
|
|
442
|
+
async def _dispatch() -> int:
|
|
443
|
+
if args.action == "up":
|
|
444
|
+
status = await sandbox.up()
|
|
445
|
+
console.print(
|
|
446
|
+
f"[{t.GREEN if status.running else t.ORANGE}]{status.message}[/]"
|
|
447
|
+
+ (f" - {status.url}" if status.url else "")
|
|
448
|
+
)
|
|
449
|
+
return 0 if status.available else 1
|
|
450
|
+
if args.action == "down":
|
|
451
|
+
await sandbox.down()
|
|
452
|
+
console.print(f"[{t.GREEN}]Sandbox removed.[/]")
|
|
453
|
+
return 0
|
|
454
|
+
if args.action == "status":
|
|
455
|
+
status = await sandbox.status()
|
|
456
|
+
console.print(f"available={status.available} running={status.running} {status.message}")
|
|
457
|
+
return 0
|
|
458
|
+
if args.action == "logs":
|
|
459
|
+
console.print(await sandbox.logs(tail=args.tail))
|
|
460
|
+
return 0
|
|
461
|
+
return 1
|
|
462
|
+
|
|
463
|
+
return asyncio.run(_dispatch())
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def cmd_toolbox(args: argparse.Namespace) -> int:
|
|
467
|
+
_banner()
|
|
468
|
+
from splitagent.core.toolbox import Toolbox, build_command, estimated_size
|
|
469
|
+
from splitagent.core.workspace import workspace_for
|
|
470
|
+
|
|
471
|
+
project = ProjectConfig()
|
|
472
|
+
if project_config_path().exists():
|
|
473
|
+
project = load_project_config()
|
|
474
|
+
if getattr(args, "edition", None):
|
|
475
|
+
project.run.execution.edition = args.edition
|
|
476
|
+
if getattr(args, "mode", None):
|
|
477
|
+
project.run.execution.mode = args.mode
|
|
478
|
+
|
|
479
|
+
workspace = workspace_for(project, base=Path.cwd())
|
|
480
|
+
workspace.ensure()
|
|
481
|
+
toolbox = Toolbox(project.run.execution, workspace.root)
|
|
482
|
+
|
|
483
|
+
action = args.action
|
|
484
|
+
if action == "status":
|
|
485
|
+
status = toolbox.detect()
|
|
486
|
+
table = Table(box=None, show_header=False)
|
|
487
|
+
table.add_column(style=t.MUTED)
|
|
488
|
+
table.add_column(style=t.TEXT)
|
|
489
|
+
table.add_row("docker cli", "yes" if status.docker_cli else "no")
|
|
490
|
+
table.add_row("daemon", "running" if status.daemon else "stopped")
|
|
491
|
+
table.add_row("image", "built" if status.image else "missing")
|
|
492
|
+
table.add_row("container", "running" if status.running else "stopped")
|
|
493
|
+
table.add_row("edition", project.run.execution.edition)
|
|
494
|
+
table.add_row("mode", project.run.execution.mode)
|
|
495
|
+
table.add_row("message", status.message)
|
|
496
|
+
if status.tools:
|
|
497
|
+
present = [name for name, ok in status.tools.items() if ok]
|
|
498
|
+
table.add_row("tools", ", ".join(present) or "(none)")
|
|
499
|
+
console.print(Panel(table, title="Toolbox", border_style=t.PRIMARY))
|
|
500
|
+
if not status.image:
|
|
501
|
+
console.print(
|
|
502
|
+
Text(
|
|
503
|
+
"Build with: " + " ".join(build_command(project.run.execution.edition)),
|
|
504
|
+
style=t.MUTED,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
if action == "install":
|
|
510
|
+
if not toolbox.detect().daemon and not toolbox.start_daemon()[0]:
|
|
511
|
+
console.print(f"[{t.RED}]Docker daemon is not available.[/]")
|
|
512
|
+
return 1
|
|
513
|
+
project.run.execution.installed = True
|
|
514
|
+
save_project_config(project, project_config_path())
|
|
515
|
+
console.print(
|
|
516
|
+
f"[{t.MUTED}]Building the {project.run.execution.edition} toolbox "
|
|
517
|
+
f"({estimated_size(project.run.execution.edition)})…[/]"
|
|
518
|
+
)
|
|
519
|
+
result = toolbox.up()
|
|
520
|
+
if not result.get("ok"):
|
|
521
|
+
console.print(f"[{t.RED}]{result.get('error')}[/]")
|
|
522
|
+
console.print(
|
|
523
|
+
Text(" ".join(build_command(project.run.execution.edition)), style=t.MUTED)
|
|
524
|
+
)
|
|
525
|
+
return 1
|
|
526
|
+
console.print(f"[{t.GREEN}]Toolbox ready[/] ({result.get('container')})")
|
|
527
|
+
return 0
|
|
528
|
+
|
|
529
|
+
if action == "up":
|
|
530
|
+
result = toolbox.up()
|
|
531
|
+
console.print(
|
|
532
|
+
f"[{t.GREEN}]{result.get('container')} started[/]"
|
|
533
|
+
if result.get("ok")
|
|
534
|
+
else f"[{t.RED}]{result.get('error')}[/]"
|
|
535
|
+
)
|
|
536
|
+
return 0 if result.get("ok") else 1
|
|
537
|
+
if action == "down":
|
|
538
|
+
toolbox.down()
|
|
539
|
+
console.print(f"[{t.GREEN}]Toolbox stopped.[/]")
|
|
540
|
+
return 0
|
|
541
|
+
if action == "reset":
|
|
542
|
+
result = toolbox.reset()
|
|
543
|
+
console.print(
|
|
544
|
+
f"[{t.GREEN}]Toolbox recreated.[/]" if result.get("ok") else f"[{t.RED}]failed[/]"
|
|
545
|
+
)
|
|
546
|
+
return 0 if result.get("ok") else 1
|
|
547
|
+
if action == "shell":
|
|
548
|
+
console.print(
|
|
549
|
+
Text(
|
|
550
|
+
f"docker exec -it {project.run.execution.container} bash",
|
|
551
|
+
style=t.MUTED,
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
return 0
|
|
555
|
+
console.print(f"[{t.RED}]Unknown action '{action}'[/]")
|
|
556
|
+
return 1
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def cmd_version(args: argparse.Namespace) -> int:
|
|
560
|
+
console.print(f"splitagent {__version__}")
|
|
561
|
+
console.print(f"config {global_config_path()}")
|
|
562
|
+
return 0
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# --------------------------------------------------------------------------- #
|
|
566
|
+
# Parser
|
|
567
|
+
# --------------------------------------------------------------------------- #
|
|
568
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
569
|
+
parser = argparse.ArgumentParser(
|
|
570
|
+
prog="splitagent",
|
|
571
|
+
description="Autonomous dual-team (purple-team) security framework.",
|
|
572
|
+
)
|
|
573
|
+
parser.add_argument("--version", action="version", version=f"splitagent {__version__}")
|
|
574
|
+
sub = parser.add_subparsers(dest="command")
|
|
575
|
+
|
|
576
|
+
p_init = sub.add_parser("init", help="Create a splitagent.yaml project file")
|
|
577
|
+
p_init.add_argument("--path", help="Where to write the config")
|
|
578
|
+
p_init.add_argument("--name")
|
|
579
|
+
p_init.add_argument("--url", help="Target URL")
|
|
580
|
+
p_init.add_argument("--kind", choices=["web", "api", "network", "repo"])
|
|
581
|
+
p_init.add_argument("--hosts", nargs="*")
|
|
582
|
+
p_init.add_argument("--rounds", type=int)
|
|
583
|
+
p_init.add_argument("--preset", choices=list(TARGET_PRESETS))
|
|
584
|
+
p_init.add_argument("--force", action="store_true")
|
|
585
|
+
p_init.add_argument("--yes", "-y", action="store_true", help="No interactive prompts")
|
|
586
|
+
p_init.set_defaults(func=cmd_init)
|
|
587
|
+
|
|
588
|
+
p_config = sub.add_parser("config", help="Manage the global (API) configuration")
|
|
589
|
+
p_config.add_argument(
|
|
590
|
+
"action",
|
|
591
|
+
nargs="?",
|
|
592
|
+
default="show",
|
|
593
|
+
choices=["show", "setup", "set", "path", "preset"],
|
|
594
|
+
)
|
|
595
|
+
p_config.add_argument("key", nargs="?")
|
|
596
|
+
p_config.add_argument("value", nargs="?")
|
|
597
|
+
p_config.add_argument("--provider", help="Provider name for the 'preset' action")
|
|
598
|
+
p_config.add_argument("--no-test", action="store_true")
|
|
599
|
+
p_config.set_defaults(func=cmd_config)
|
|
600
|
+
|
|
601
|
+
p_run = sub.add_parser("run", help="Run a full Red vs Blue audit")
|
|
602
|
+
p_run.add_argument("--config", help="Path to splitagent.yaml")
|
|
603
|
+
p_run.add_argument("--url", help="Override the target URL")
|
|
604
|
+
p_run.add_argument("--rounds", type=int)
|
|
605
|
+
p_run.add_argument("--max-steps", type=int)
|
|
606
|
+
p_run.add_argument("--no-sandbox", action="store_true")
|
|
607
|
+
p_run.add_argument("--no-report", action="store_true")
|
|
608
|
+
p_run.add_argument("--allow-network", action="store_true")
|
|
609
|
+
p_run.add_argument("--tui", action="store_true", help="Launch the Textual interface")
|
|
610
|
+
p_run.add_argument(
|
|
611
|
+
"--desktop", action="store_true", help="Launch the native desktop application"
|
|
612
|
+
)
|
|
613
|
+
p_run.set_defaults(func=cmd_run)
|
|
614
|
+
|
|
615
|
+
p_tui = sub.add_parser("tui", help="Launch the interactive interface")
|
|
616
|
+
p_tui.add_argument("--config")
|
|
617
|
+
p_tui.add_argument("--url")
|
|
618
|
+
p_tui.add_argument("--rounds", type=int)
|
|
619
|
+
p_tui.add_argument("--max-steps", type=int)
|
|
620
|
+
p_tui.add_argument("--no-sandbox", action="store_true")
|
|
621
|
+
p_tui.add_argument("--allow-network", action="store_true")
|
|
622
|
+
p_tui.set_defaults(func=cmd_tui)
|
|
623
|
+
|
|
624
|
+
p_desktop = sub.add_parser(
|
|
625
|
+
"desktop", help="Launch the native desktop application (OpenCode-style UI)"
|
|
626
|
+
)
|
|
627
|
+
p_desktop.add_argument("--config")
|
|
628
|
+
p_desktop.add_argument("--url")
|
|
629
|
+
p_desktop.add_argument("--rounds", type=int)
|
|
630
|
+
p_desktop.add_argument("--max-steps", type=int)
|
|
631
|
+
p_desktop.add_argument("--no-sandbox", action="store_true")
|
|
632
|
+
p_desktop.add_argument("--allow-network", action="store_true")
|
|
633
|
+
p_desktop.set_defaults(func=cmd_desktop)
|
|
634
|
+
|
|
635
|
+
p_report = sub.add_parser("report", help="Render a report from a session")
|
|
636
|
+
p_report.add_argument("--session", help="Session id")
|
|
637
|
+
p_report.add_argument("--input", help="Encrypted session file path")
|
|
638
|
+
p_report.add_argument("--formats", nargs="*", choices=["markdown", "html", "json"])
|
|
639
|
+
p_report.add_argument("--output", help="Output directory")
|
|
640
|
+
p_report.set_defaults(func=cmd_report)
|
|
641
|
+
|
|
642
|
+
p_sandbox = sub.add_parser("sandbox", help="Manage the Docker sandbox")
|
|
643
|
+
p_sandbox.add_argument("action", choices=["up", "down", "status", "logs"])
|
|
644
|
+
p_sandbox.add_argument("--preset", choices=list(TARGET_PRESETS))
|
|
645
|
+
p_sandbox.add_argument("--tail", type=int, default=100)
|
|
646
|
+
p_sandbox.set_defaults(func=cmd_sandbox)
|
|
647
|
+
|
|
648
|
+
p_toolbox = sub.add_parser("toolbox", help="Manage the isolated agent execution environment")
|
|
649
|
+
p_toolbox.add_argument(
|
|
650
|
+
"action",
|
|
651
|
+
choices=["status", "install", "up", "down", "reset", "shell"],
|
|
652
|
+
)
|
|
653
|
+
p_toolbox.add_argument("--edition", choices=["standard", "kali"])
|
|
654
|
+
p_toolbox.add_argument("--mode", choices=["auto", "toolbox", "local"])
|
|
655
|
+
p_toolbox.set_defaults(func=cmd_toolbox)
|
|
656
|
+
|
|
657
|
+
p_version = sub.add_parser("version", help="Show version information")
|
|
658
|
+
p_version.set_defaults(func=cmd_version)
|
|
659
|
+
|
|
660
|
+
return parser
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _force_utf8() -> None:
|
|
664
|
+
"""Make the console survive the model's unicode output on Windows.
|
|
665
|
+
|
|
666
|
+
Reports and summaries contain arrows, box-drawing and accented characters;
|
|
667
|
+
a cp1252 console would raise UnicodeEncodeError on them.
|
|
668
|
+
"""
|
|
669
|
+
for stream in (sys.stdout, sys.stderr):
|
|
670
|
+
try:
|
|
671
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
672
|
+
except (AttributeError, ValueError): # pragma: no cover
|
|
673
|
+
pass
|
|
674
|
+
if os.name == "nt": # pragma: no cover - platform specific
|
|
675
|
+
try:
|
|
676
|
+
import ctypes
|
|
677
|
+
|
|
678
|
+
ctypes.windll.kernel32.SetConsoleOutputCP(65001) # UTF-8
|
|
679
|
+
except Exception:
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def main(argv: list[str] | None = None) -> int:
|
|
684
|
+
_force_utf8()
|
|
685
|
+
parser = build_parser()
|
|
686
|
+
args = parser.parse_args(argv)
|
|
687
|
+
if not getattr(args, "command", None):
|
|
688
|
+
parser.print_help()
|
|
689
|
+
return 0
|
|
690
|
+
try:
|
|
691
|
+
return int(args.func(args) or 0)
|
|
692
|
+
except SplitAgentError as exc:
|
|
693
|
+
console.print(f"[{t.RED}]error:[/] {exc}")
|
|
694
|
+
return 1
|
|
695
|
+
except KeyboardInterrupt:
|
|
696
|
+
console.print()
|
|
697
|
+
return 130
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
if __name__ == "__main__": # pragma: no cover
|
|
701
|
+
sys.exit(main())
|