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
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())