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,987 @@
1
+ Metadata-Version: 2.4
2
+ Name: splitagent
3
+ Version: 0.0.3
4
+ Summary: Autonomous dual-team (Purple Team) security framework powered by configurable LLM APIs.
5
+ Author: SplitAgent Contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/NahuelGomez-Dev/SplitAgent
8
+ Project-URL: Repository, https://github.com/NahuelGomez-Dev/SplitAgent
9
+ Project-URL: Issues, https://github.com/NahuelGomez-Dev/SplitAgent/issues
10
+ Project-URL: Changelog, https://github.com/NahuelGomez-Dev/SplitAgent/blob/main/CHANGELOG.md
11
+ Keywords: security,pentest,agents,purple-team,llm,automation
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Information Technology
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Security
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: rich>=13.7
22
+ Requires-Dist: textual>=0.60
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: PyYAML>=6.0
25
+ Requires-Dist: cryptography>=42.0
26
+ Requires-Dist: pywebview>=5.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
+ Requires-Dist: ruff>=0.6; extra == "dev"
31
+ Requires-Dist: pre-commit>=3.7; extra == "dev"
32
+ Provides-Extra: http2
33
+ Requires-Dist: h2>=4.1; extra == "http2"
34
+ Dynamic: license-file
35
+
36
+ # SplitAgent
37
+
38
+ [![CI](https://github.com/NahuelGomez-Dev/SplitAgent/actions/workflows/ci.yml/badge.svg)](https://github.com/NahuelGomez-Dev/SplitAgent/actions/workflows/ci.yml)
39
+ [![PyPI](https://img.shields.io/pypi/v/splitagent.svg)](https://pypi.org/project/splitagent/)
40
+ [![Python](https://img.shields.io/pypi/pyversions/splitagent.svg)](https://pypi.org/project/splitagent/)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/NahuelGomez-Dev/SplitAgent/blob/main/LICENSE)
42
+
43
+ **Autonomous dual-team (Purple Team) security framework.**
44
+
45
+ SplitAgent automates penetration testing and defensive hardening with two
46
+ teams of AI agents that operate against the same target, in real time:
47
+
48
+ - **Red Agent** — reconnaissance, attack-surface mapping and controlled,
49
+ non-destructive exploitation with evidence.
50
+ - **Blue Agent** — log triage, detection and concrete mitigations (firewall
51
+ rules, hardening config and code patches), plus verification.
52
+
53
+ Everything is driven by a **central orchestrator** that runs Red → Blue rounds,
54
+ keeps an **encrypted shared context** and compiles a professional report with
55
+ CVSS v3.1 scoring.
56
+
57
+ > The model is **never bundled**: SplitAgent talks to *any* model over an HTTP
58
+ > API (OpenAI-compatible or Anthropic). You choose the provider and paste the
59
+ > API key from inside the program — no local AI required.
60
+
61
+ ---
62
+
63
+ ## Highlights
64
+
65
+ - **Dual-agent Purple Team loop** — attack, mitigate, verify, repeat.
66
+ - **Any model, any provider** — OpenAI, OpenRouter, Anthropic, Groq, DeepSeek,
67
+ Together, Mistral, xAI, vLLM, LM Studio, Ollama (OpenAI mode) or a custom
68
+ endpoint.
69
+ - **In-app configuration** — `splitagent config setup` or press `c` in the TUI.
70
+ Keys are stored with `0600` permissions in the global config directory.
71
+ - **Encrypted sessions** — findings and evidence are encrypted at rest with a
72
+ Fernet key (`~/.splitagent/key.bin`).
73
+ - **Ephemeral Docker sandbox** — spin up a disposable vulnerable target
74
+ (`juice-shop`, `dvwa`, `bwapp`, `webgoat`) on an isolated network.
75
+ - **Native desktop application** — a real window (pywebview + WebView2) whose
76
+ interface is built on OpenCode's *Desktop v2* design language: near-black
77
+ canvas, sidebar, central stream, review panel and composer. A Rich streaming
78
+ CLI and a Textual TUI are included too.
79
+ - **Reports** — Markdown, standalone HTML and JSON, with CVSS v3.1 vectors,
80
+ full traceability and ready-to-apply patches.
81
+
82
+ ---
83
+
84
+ ## Architecture
85
+
86
+ ```
87
+ +-------------------------------+
88
+ | Core Engine |
89
+ | rounds · shared context |
90
+ | event bus · sandbox control |
91
+ +---------------+---------------+
92
+ |
93
+ +----------------------+----------------------+
94
+ | |
95
+ +---------v----------+ +----------v---------+
96
+ | RED AGENT | encrypted context | BLUE AGENT |
97
+ | recon · probe |<--------------------->| triage · patch |
98
+ | record findings | | firewall · verify |
99
+ +---------+----------+ +----------+---------+
100
+ | |
101
+ +----------------------+----------------------+
102
+ |
103
+ +---------------v---------------+
104
+ | Target / Docker sandbox |
105
+ +-------------------------------+
106
+ ```
107
+
108
+ The engine emits events (`agent.text`, `agent.tool_call`, `finding`,
109
+ `mitigation`, `round.end`, …) that drive both the CLI stream renderer and the
110
+ Textual TUI.
111
+
112
+ ---
113
+
114
+ ## Installation
115
+
116
+ Requires **Python 3.10+**. Docker is optional (only for the sandbox).
117
+
118
+ ```bash
119
+ pip install splitagent
120
+ ```
121
+
122
+ From source:
123
+
124
+ ```bash
125
+ git clone https://github.com/NahuelGomez-Dev/SplitAgent
126
+ cd SplitAgent
127
+ python -m venv .venv
128
+ . .venv/bin/activate # Windows: .\.venv\Scripts\Activate.ps1
129
+ pip install -e ".[dev]"
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Quick start
135
+
136
+ ```bash
137
+ # 1. Configure the model API (once). Stored in ~/.splitagent/config.yaml
138
+ splitagent config setup
139
+
140
+ # 2. Create a project file (splitagent.yaml) for your target
141
+ splitagent init --preset juice-shop -y
142
+
143
+ # 3. Launch the desktop app (or `run --tui` / plain `run` for the terminal)
144
+ splitagent desktop
145
+
146
+ # 4. Reports land in ./reports
147
+ ```
148
+
149
+ ### Setup wizard
150
+
151
+ `splitagent config setup` lists the supported providers, lets you override the
152
+ base URL, model, temperature and API key, and validates the credentials with a
153
+ live round-trip before saving. You can also skip the wizard and use environment
154
+ variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `SPLITAGENT_API_KEY`, …).
155
+
156
+ Non-interactive setup:
157
+
158
+ ```bash
159
+ splitagent config preset openrouter
160
+ splitagent config set api_key sk-or-...
161
+ splitagent config set model anthropic/claude-3.5-sonnet
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Commands
167
+
168
+ | Command | Description |
169
+ | --- | --- |
170
+ | `splitagent init` | Create `splitagent.yaml` (interactive or with flags). |
171
+ | `splitagent config setup` | Configure the model API and test it. |
172
+ | `splitagent config show` | Show the global config (key redacted). |
173
+ | `splitagent config set <key> <value>` | Update a single LLM setting. |
174
+ | `splitagent config preset <provider>` | Apply a provider preset. |
175
+ | `splitagent desktop` | Launch the native desktop application. |
176
+ | `splitagent toolbox` | Manage the isolated Docker execution environment. |
177
+ | `splitagent run` | Run the full audit in the terminal. |
178
+ | `splitagent tui` | Launch the interactive Textual interface. |
179
+ | `splitagent report --session <id>` | Re-render reports from a saved session. |
180
+ | `splitagent sandbox up\|down\|status\|logs` | Manage the Docker sandbox. |
181
+
182
+ ### Useful `run` flags
183
+
184
+ ```
185
+ --url URL Override the target URL
186
+ --rounds N Number of Red/Blue cycles
187
+ --max-steps N Max tool-calling steps per agent turn
188
+ --no-sandbox Do not use Docker
189
+ --no-report Skip report generation
190
+ --allow-network Disable scope enforcement (lab use only)
191
+ --tui Launch the interactive interface
192
+ ```
193
+
194
+ ---
195
+
196
+ ## Desktop application
197
+
198
+ ```bash
199
+ splitagent desktop # or: splitagent run --desktop
200
+ ```
201
+
202
+ Two work modes, switched from the titlebar:
203
+
204
+ - **Audit** — the autonomous Red vs Blue run.
205
+ - **Copilot** — a conversational assistant that helps you *do* the pentest:
206
+ plan the engagement, explain a vulnerability, inspect the target with the
207
+ full toolset, draft payloads, firewall rules or patches, and interpret
208
+ findings. It is a **single continuous conversation** — ask a follow-up and it
209
+ keeps the whole thread and its context.
210
+
211
+ ### One cockpit
212
+
213
+ There is a single interface. The panels adapt to the work:
214
+
215
+ - **Resizable columns** — drag the dividers between the sidebar, the stream and
216
+ the review panel; hide either side with the titlebar buttons or `Ctrl+B`
217
+ (sidebar) / `Ctrl+J` (panel). The centre expands to fill the space.
218
+ - **Dashboard** — after a run, the review panel opens on a summary: overall
219
+ risk, severity distribution, top findings, round timeline and the resilience
220
+ score, all from live session state.
221
+ - **One activity line** — a single `Thinking` / `Exploring` line stays pinned at
222
+ the bottom of the stream and flips verb as the agent works, instead of
223
+ stacking. The full reasoning is one click away.
224
+ - **Response timer** — a small live counter next to the agent's name while it
225
+ answers.
226
+
227
+ The **guided setup** asks a few questions before running (target kind →
228
+ address and scope → sandbox or existing target → optional credentials →
229
+ depth) so you do not have to know the config format.
230
+
231
+ **Models & Providers settings** (`Ctrl+,`) copy OpenCode's own menus:
232
+
233
+ - **Models** — the full catalogue, searchable, grouped by provider with
234
+ collapsible sections and a per-provider/per-model switch, plus "Custom
235
+ model" to add an id the catalogue does not list.
236
+ - **Providers** — **Connected** (base URL, model count, enable switch,
237
+ Refresh, Edit, Disconnect) and **Popular** with a Connect button. Connecting
238
+ asks for the base URL and API key, then **fetches the provider's `/models`
239
+ catalogue automatically** and can make it the active provider.
240
+ - **General** — temperature, max tokens, config-file shortcut and a connection
241
+ test.
242
+
243
+ The **model picker** in the composer is also a faithful copy of OpenCode's:
244
+ a searchable popover grouped by provider with a "Manage models…" entry that
245
+ opens the settings above.
246
+
247
+ A frameless native window (pywebview + the system WebView2 runtime) rendering
248
+ an interface modelled on OpenCode's Desktop v2 design language:
249
+
250
+ ```
251
+ ┌──────────────────────────────────────────────────────────────────────┐
252
+ │ ◇ SplitAgent Audit session ⌘K ⚙ ─ ▢ ✕ │
253
+ ├───────────────┬──────────────────────────────────┬───────────────────┤
254
+ │ Engagement │ ▸ Round 1 · offence │ Findings 3 │
255
+ │ web localhost │ RED analysis / tool calls … │ Mitigations 2 │
256
+ │ scope … │ ▸ Round 1 · defence │ Report │
257
+ │ Sessions … │ BLUE triage / patches … │ Activity │
258
+ │ │ ┌────────────────────────────┐ │ │
259
+ │ model chip │ │ objective… Run audit │ │ Export report │
260
+ ├───────────────┴──────────────────────────────────┴───────────────────┤
261
+ │ provider/model · round 1/3 · defence · 42 events · resilience ▓▓▓░ 78 │
262
+ └──────────────────────────────────────────────────────────────────────┘
263
+ ```
264
+
265
+ - **Quiet, OpenCode-style stream** — nothing floods the timeline while an agent
266
+ works:
267
+
268
+ ```
269
+ Thinking checking the login form for reflected input… ›
270
+ Exploring · 3 scans, 1 request ›
271
+ ✓ port_scan {"host":"127.0.0.1"} ›
272
+ ✓ http_request {"url":"…/login"} ›
273
+ ! test_xss {"parameter":"q"} ›
274
+ ```
275
+
276
+ Reasoning collapses to **one shimmering line** that becomes `Thought` when
277
+ done, and switches verb dynamically: `Thinking · considering next steps…`
278
+ while the model deliberates, `Exploring · running tools…` while tools run.
279
+ Every action lives in a **collapsible group** (the OpenCode `BasicTool`
280
+ pattern): a single header with verb + counts, and inside it one compact row
281
+ per call — status, title, args — with its **own chevron** that opens the full
282
+ arguments and output. Tool cards are never appended to the timeline directly,
283
+ so long runs stay short. The **Trace** tab always holds the complete replay.
284
+
285
+ - **Task dock** (`SessionTodoDock` port) — a collapsible bar above the composer
286
+ reading `2 of 5 todos completed` with the active task as a preview, a chevron
287
+ that rotates 180°, and a checklist where the in-progress item pulses
288
+ (`pulse-scale 1.2s ease-in-out infinite`), completed items strike through
289
+ with an animated line and cancelled items dim. It appears automatically when
290
+ an agent calls `todowrite`.
291
+ - **Live stream** — Red and Blue messages stream in with tool calls that
292
+ expand to show the exact arguments and output, plus inline severity cards and
293
+ context-checkpoint cards.
294
+ - **Review panel** — Dashboard, Findings, Mitigations, the generated Report,
295
+ the full Trace and an Activity log, all updating in real time.
296
+ - **Composer** — set the objective, pick the model, toggle the Docker sandbox
297
+ and rounds, then **Run audit** (`Ctrl+Enter`). The **Guided** button opens
298
+ the wizard.
299
+ - **Authenticated testing** — optional credentials (username/password, bearer
300
+ token, cookies, custom headers) are injected into the agents' context and
301
+ applied as default headers by the web tools.
302
+ - **In-app model configuration** — the Models/Providers settings manage every
303
+ provider and its models, and *Test connection* validates credentials before
304
+ saving. Nothing is pre-baked: any OpenAI-compatible or Anthropic endpoint
305
+ works, and connected providers are stored in the global config so both the
306
+ Audit and the Copilot modes share them.
307
+ - **Command palette** — `Ctrl+K` for run/stop, settings, export and sandbox
308
+ presets.
309
+ - Sessions are encrypted and can be reopened from the sidebar; reports open
310
+ directly from the review panel.
311
+
312
+ > Requirements: Windows 10/11 with the WebView2 runtime (bundled with Edge),
313
+ > macOS or Linux with GTK/Qt WebKit. On Windows, `pip install pywebview`
314
+ > pulls `pythonnet` automatically.
315
+
316
+ ---
317
+
318
+ ## Configuration
319
+
320
+ ### Global — `~/.splitagent/config.yaml`
321
+
322
+ ```yaml
323
+ llm:
324
+ provider: openai # any provider preset or "custom"
325
+ protocol: openai # openai | anthropic
326
+ base_url: https://api.openai.com/v1
327
+ api_key: sk-...
328
+ model: gpt-4o-mini
329
+ temperature: 0.2
330
+ max_tokens: 4096
331
+ timeout: 120
332
+ stream: true
333
+ authorized: true # you accepted the responsible-use notice
334
+ ```
335
+
336
+ ### Project — `splitagent.yaml`
337
+
338
+ ```yaml
339
+ project:
340
+ name: my-audit
341
+ target:
342
+ kind: web # web | api | network | repo
343
+ url: http://localhost:3000
344
+ hosts: []
345
+ ports: [80, 443, 3000]
346
+ scope: [localhost]
347
+ out_of_scope: []
348
+ run:
349
+ rounds: 3
350
+ max_steps: 12
351
+ safe_mode: true
352
+ allow_network: false
353
+ sandbox:
354
+ enabled: true
355
+ image: bkimminich/juice-shop:latest
356
+ network: splitagent-net
357
+ port_map: { "3000": 3000 }
358
+ auth: # optional, for authenticated testing
359
+ username: ""
360
+ password: ""
361
+ token: ""
362
+ cookies: ""
363
+ headers: {}
364
+ workspace: # the agents' own directory
365
+ path: "" # empty -> ./splitagent-workspace
366
+ allow_install: true
367
+ allow_external_tools: true
368
+ instructions: ""
369
+ run:
370
+ execution: # isolated Docker environment
371
+ mode: auto # auto | toolbox | local
372
+ edition: standard # standard | kali
373
+ auto_start: true
374
+ agents:
375
+ red: { enabled: true, temperature: 0.3 }
376
+ blue: { enabled: true, temperature: 0.2 }
377
+ report:
378
+ formats: [markdown, html, json]
379
+ output_dir: reports
380
+ include_patches: true
381
+ include_evidence: true
382
+ ```
383
+
384
+ ---
385
+
386
+ ## Context & token management
387
+
388
+ Ported from OpenCode's session model (`session/overflow.ts`,
389
+ `session/compaction.ts`, `tool/truncate.ts`):
390
+
391
+ | Concept | Where | Behaviour |
392
+ | --- | --- | --- |
393
+ | Usable budget | `core/context_manager.py:usable` | `context_window − reserved`, reserving room for the reply (buffer capped at 20 000). |
394
+ | Overflow | `is_overflow` | Compares the last turn's usage against that budget. |
395
+ | **Prompt caching** | `llm/client.py:_with_cache_points` | System prompt + the newest messages are marked as cache breakpoints, exactly as OpenCode's `applyCaching` does. Anthropic/Bedrock/OpenRouter get `cache_control: {type: "ephemeral"}`; OpenAI-compatible gateways cache automatically on a stable prefix. Measured at **79.8%** cache hit rate. |
396
+ | **Stable prefix** | `agents/prompts.py` | The system prompt is **byte-identical for the whole session**. Round counter, findings, task list, notes and tool inventory are appended to the newest user turn by `volatile_context()` instead of living in the system prompt — otherwise every request invalidates the cache. |
397
+ | Per-request projection | `optimize` | Runs before **every** request: drops the reasoning of settled steps, blanks superseded state snapshots, prunes old tool output. This is what keeps the billed prompt small even on 1M-token models. |
398
+ | Reasoning stripping | `strip_reasoning` | Reasoning is scaffolding — once a step produced tool calls it has been acted on. Only the last turn keeps it (OpenCode does the same per step). |
399
+ | Snapshot superseding | `supersede_stateful_results` | `workspace_info`, `read_shared_context`, `list_findings`… return full current state; only the newest copy is meaningful. |
400
+ | Pruning | `prune` | Walks backwards keeping the newest tool result, protects ~40 000 tokens of recent tool output, then blanks older results to `"[Old tool result content cleared]"`. Savings under 20 000 tokens are discarded. |
401
+ | Compaction | `compact` | Keeps a tail of recent turns within ~25 % of the budget (clamped to 2 000–15 000) and replaces the head with a structured checkpoint message. |
402
+ | Tool bounding | `prune_tool_output` | Any tool result entering the conversation is capped at 2 000 chars. |
403
+
404
+ Each model carries a context/output limit (`config.model_spec`, overridable per
405
+ provider in the settings) that feeds the policy. Everything is configurable in
406
+ `splitagent.yaml`:
407
+
408
+ ```yaml
409
+ run:
410
+ compaction:
411
+ auto: true
412
+ prune: true
413
+ reserved: null # null = min(20k, max_output)
414
+ preserve_recent_tokens: null # null = clamp(25% of budget, 2k..15k)
415
+ tail_turns: null # limit how many turns are kept
416
+ ```
417
+
418
+ ### Why the prefix must stay stable
419
+
420
+ Caching only works if the prefix is byte-identical between requests. The
421
+ runtime enforces that split:
422
+
423
+ ```
424
+ system -> RED_SYSTEM + engagement + scope + workspace paths (frozen)
425
+ history -> previous turns
426
+ user (last) -> the task + "=== CURRENT STATE ===" + findings, todo list,
427
+ round counter, notes (fresh every step)
428
+ ```
429
+
430
+ So findings can be recorded mid-run without invalidating a single cached
431
+ token. Disable it if you need to compare against a cold prompt:
432
+
433
+ ```yaml
434
+ # global config
435
+ llm:
436
+ prompt_cache: true
437
+ cache_system_messages: 2 # how many leading system messages to mark
438
+ cache_tail_messages: 2 # how many trailing messages to mark
439
+ ```
440
+
441
+ Measured on a real run (OpenCode Go / DeepSeek V4.1 Flash):
442
+
443
+ ```text
444
+ step 1: prompt= 4363 cached= 0
445
+ step 2: prompt= 4871 cached= 4736
446
+ step 3: prompt= 5170 cached= 4992
447
+ step 4: prompt= 5325 cached= 5120
448
+ step 5: prompt= 5623 cached= 5376
449
+ cache hit rate: 79.8%
450
+ ```
451
+
452
+ ### Full trace & replay
453
+
454
+ Nothing is lost when context is trimmed. Every reasoning step, tool call with
455
+ its exact arguments, the full tool output, token usage and each context event
456
+ are recorded:
457
+
458
+ - **Trace tab** — a live, colour-coded replay panel; "Dump JSON" writes the
459
+ whole trace to `~/.splitagent/traces/`.
460
+ - **Session file** — encrypted checkpoints and per-agent traces travel with the
461
+ session, so a finished audit can be replayed end to end.
462
+ - **Status bar** — a live context meter (`ctx 42%`) that turns amber then
463
+ orange as the window fills.
464
+
465
+ ---
466
+
467
+ ## Tools
468
+
469
+ **Shared** — `todowrite`, `record_finding`, `list_findings`, `get_finding`,
470
+ `record_mitigation`, `read_shared_context`.
471
+
472
+ **Workspace** — `workspace_info`, `workspace_write`, `workspace_read`,
473
+ `workspace_list`, `install_tool`, `run_tool`.
474
+
475
+ `todowrite` is a faithful port of OpenCode's task tool: the same description
476
+ (proactive when 3+ steps, one `in_progress` at a time, `pending` /
477
+ `in_progress` / `completed` / `cancelled`, `high` / `medium` / `low`) and the
478
+ same `{content, status, priority}` item shape. The list is stored in the
479
+ session and surfaced in the UI as a collapsible **task dock**.
480
+
481
+ ---
482
+
483
+ ## Real servers, not just labs
484
+
485
+ SplitAgent audits **any reachable host**: a production web app, an API, a
486
+ public IP, an internal range. The sandbox is a convenience for practising, not
487
+ a requirement.
488
+
489
+ ```bash
490
+ splitagent init --url https://your-server.example.com -y
491
+ splitagent run
492
+ ```
493
+
494
+ Pointing at a real server used to be a trap: `init` left the sandbox enabled,
495
+ so the run would spin up a Juice Shop container and audit *that* instead of
496
+ your server. It now detects the difference and configures itself:
497
+
498
+ | Target | Sandbox | Network |
499
+ | --- | --- | --- |
500
+ | `example.com`, `api.example.com` | **off** | allowed |
501
+ | `93.184.216.34` (public IP) | **off** | allowed |
502
+ | `localhost`, `127.0.0.1` | on | scope-bound |
503
+ | `192.168.x.x`, `10.x.x.x` | on | scope-bound |
504
+
505
+ The engine enforces the same rule at run time, so even a hand-edited config
506
+ cannot make it audit a container instead of the host you asked for. Verified
507
+ against a live internet target:
508
+
509
+ ```text
510
+ target=scanme.nmap.org
511
+ external detected : True
512
+ docker 'up' launched : False
513
+ ```
514
+
515
+ A full agent audit of `scanme.nmap.org` (a real, internet-reachable host):
516
+
517
+ ```text
518
+ tools used : dns_lookup, port_scan, run_tool, check_tool,
519
+ record_finding, workspace_write, todo…
520
+ findings : Outdated Apache httpd 2.4.7 (Ubuntu) CVSS 6.5
521
+ Outdated OpenSSH 6.6.1p1 (Ubuntu) CVSS 5.3
522
+ Service version banner disclosure CVSS 5.3
523
+ ```
524
+
525
+ ### Scope enforcement
526
+
527
+ `target.scope` is the authorisation list. Any tool that reaches a host outside
528
+ it raises `ScopeError` and the run continues with the rest:
529
+
530
+ ```yaml
531
+ target:
532
+ url: https://app.example.com
533
+ scope: [app.example.com] # only these hosts may be touched
534
+ out_of_scope: [admin.example.com]
535
+ run:
536
+ allow_network: true # required for non-local targets
537
+ ```
538
+
539
+ Set `run.allow_network: false` to hard-confine a run to `target.scope`, which
540
+ is what a lab or a segmented engagement wants.
541
+
542
+ ---
543
+
544
+ ## Isolated toolbox (recommended)
545
+
546
+ The agents install and run their security tooling **inside a disposable Docker
547
+ container**, so your computer is never modified. This is the default in `auto`
548
+ mode and it is what makes a run reproducible: same tools, same versions, every
549
+ time.
550
+
551
+ ```bash
552
+ splitagent toolbox status # docker? daemon? image? container?
553
+ splitagent toolbox install # build the image and start it
554
+ splitagent toolbox up|down|reset # lifecycle
555
+ splitagent toolbox shell # prints the docker exec command
556
+ ```
557
+
558
+ ### One-time consent, then silent
559
+
560
+ On first launch the app checks for Docker and, if the image is not built yet,
561
+ asks **once**:
562
+
563
+ ```
564
+ ┌ Set up the isolated environment ────────────────────────┐
565
+ │ Docker installed │
566
+ │ Engine running │
567
+ │ Image not built │
568
+ │ Workspace …\splitagent-workspace │
569
+ │ │
570
+ │ Edition [ Standard — Debian + recon tools (~450 MB) ▾ ] │
571
+ │ │
572
+ │ [Continue without it] [Later] [Install environment]│
573
+ └───────────────────────────────────────────────────────────┘
574
+ ```
575
+
576
+ After that it never asks again: if the daemon is stopped SplitAgent **starts
577
+ Docker Desktop itself** and brings the container up in the background. You
578
+ never have to touch Docker again.
579
+
580
+ ### Editions
581
+
582
+ | Edition | Base | Size | Contents |
583
+ | --- | --- | --- | --- |
584
+ | `standard` *(default)* | Debian bookworm-slim | ~450 MB | nmap, masscan, nikto (pip), sqlmap, whatweb, wafw00f, gobuster, ffuf, dirb, nuclei, subfinder, httpx, dnsx, curl, git, python3 + venv |
585
+ | `kali` | kalilinux/kali-rolling | ~2.5 GB | the above via `kali-linux-headless`, plus the wider Kali toolset |
586
+
587
+ Go-based tools are compiled against a modern Go toolchain at build time (the
588
+ one in Debian's repos is too old), then the toolchain is discarded.
589
+
590
+ ### How execution is routed
591
+
592
+ ```yaml
593
+ run:
594
+ execution:
595
+ mode: auto # auto | toolbox | local
596
+ edition: standard # standard | kali
597
+ auto_start: true # start Docker Desktop silently when needed
598
+ network_mode: bridge # bridge | host
599
+ allow_install: true
600
+ cpus: "" # e.g. "2"
601
+ memory: "" # e.g. "2g"
602
+ ```
603
+
604
+ | Mode | Behaviour |
605
+ | --- | --- |
606
+ | `auto` *(default)* | Use the toolbox when Docker works, otherwise fall back to local. |
607
+ | `toolbox` | Require the toolbox; the run fails with a clear message if Docker is missing. |
608
+ | `local` | Legacy: install and run on the host. |
609
+
610
+ `install_tool`, `run_tool` and `check_tool` become backend-aware: in toolbox
611
+ mode they run `docker exec` against the container, with the workspace mounted
612
+ at `/workspace`. The agent's tool output reports `"backend": "toolbox"`, and
613
+ `workspace_info` tells the model it is sandboxed and can install freely.
614
+
615
+ ```text
616
+ run_tool nmap --version -> backend toolbox
617
+ Nmap version 7.93 ( x86_64-pc-linux-gnu )
618
+ ```
619
+
620
+ The container is on `splitagent-net`, the same isolated network as the target
621
+ sandbox, and gets `NET_RAW`/`NET_ADMIN` (needed by `nmap -sS`). Without
622
+ `network_mode: host` it cannot reach your LAN, which keeps a mis-scoped scan
623
+ contained.
624
+
625
+ ### Security properties
626
+
627
+ - The host is **never modified**: no winget, no `npm -g`, no system packages.
628
+ - Only the workspace directory is bind-mounted; no Docker socket, no home.
629
+ - The container is disposable — `toolbox reset` recreates it from scratch
630
+ while keeping everything under `/workspace`.
631
+ - With no Docker at all, SplitAgent degrades to local mode and the native
632
+ Python tools keep working.
633
+
634
+ ---
635
+
636
+ ## Agent workspace
637
+
638
+ The agents get their own persistent directory — a place to install tooling,
639
+ keep reconnaissance output, write notes and carry context between runs (the
640
+ same idea as OpenCode rooting an agent in a working directory and reading
641
+ `AGENTS.md` from it).
642
+
643
+ ```
644
+ splitagent-workspace/
645
+ AGENTS.md operator instructions (highest priority in the system prompt)
646
+ README.md
647
+ tools/ virtualenvs, installed packages, cloned repos, tools/bin
648
+ recon/ raw scans, crawls, HTTP captures
649
+ loot/ downloaded artefacts and evidence
650
+ notes/ the agent's own structured context (plan.md, findings.md, …)
651
+ sessions/ per-run logs and traces
652
+ cache/
653
+ ```
654
+
655
+ Configured under `workspace` in `splitagent.yaml` (or step 4 of the guided
656
+ setup):
657
+
658
+ ```yaml
659
+ workspace:
660
+ path: "" # empty -> <project>/splitagent-workspace
661
+ allow_install: true # let the agents install tooling
662
+ allow_external_tools: true # let them run commands
663
+ max_install_seconds: 900
664
+ instructions: "" # inline operator guidance
665
+ instruction_files: [] # extra files to load into the prompt
666
+ ```
667
+
668
+ - **Check before installing** — `check_tool` resolves a binary across `PATH`,
669
+ the workspace `tools/bin` and Go's bin, and returns install suggestions when
670
+ missing. `workspace_info` reports which common security tools are already
671
+ present (`nmap`, `nuclei`, `ffuf`, `gobuster`, `sqlmap`, `nikto`,
672
+ `subfinder`, `httpx`, `masscan`, `whatweb`).
673
+ - **Install tooling** — `install_tool` supports pip (into an isolated
674
+ `tools/venv`), pipx, npm, go, cargo, apt, brew, **winget** and `git clone`.
675
+ Go installs are redirected to `tools/bin` via `GOBIN` so they are immediately
676
+ runnable. Known tools carry preferred `(manager, package)` pairs, e.g.
677
+ `nmap` → winget `Insecure.Nmap`, `ffuf` → `go install
678
+ github.com/ffuf/ffuf/v2@latest`.
679
+ - **Run tooling** — `run_tool` executes a command with the workspace as cwd; the
680
+ binary is resolved across `PATH`, `tools/bin` (including Go installs) and the
681
+ pip venv, and `SPLITAGENT_WORKSPACE`, `SPLITAGENT_TOOLS`,
682
+ `SPLITAGENT_RECON` and `SPLITAGENT_NOTES` are exported. So `nmap -sV`,
683
+ `nuclei`, `ffuf` and friends run straight from the workspace.
684
+
685
+ ```text
686
+ run_tool nmap -Pn -sT -p 80,443 127.0.0.1 -> Nmap scan report, 80/tcp open
687
+ ```
688
+ - **Instructions** — `AGENTS.md` plus anything in `instructions` /
689
+ `instruction_files` is injected into every agent's system prompt under
690
+ *OPERATOR INSTRUCTIONS (highest priority)*.
691
+ - The desktop sidebar shows a **Workspace** card with the file count; click it
692
+ to open the directory in the file manager.
693
+
694
+ ### Speed
695
+
696
+ Three changes, each measured against the real target:
697
+
698
+ | Change | Where | Measured |
699
+ | --- | --- | --- |
700
+ | **Parallel tool calls** | `agents/base.py:_execute_tools` | **19.2x** — 5 independent probes went from 3.32 s to 0.17 s |
701
+ | **Shared connection pool** | `tools/http_pool.py` | **2.07x** — 10 HTTP requests from 208 ms to 101 ms (no repeat TCP/TLS/DNS) |
702
+ | **Prompt caching** | `llm/client.py` | **62-80% cache hit rate** on real runs |
703
+
704
+ Tools declare `parallel_safe`. Read-only probes (`audit_security_headers`,
705
+ `test_cors`, `probe_paths`, `dns_lookup`…) run concurrently bounded by
706
+ `run.tool_concurrency`; anything that mutates state, installs or shells out
707
+ (`run_tool`, `install_tool`) keeps the ordered sequential path. Results are
708
+ always reassembled in the model's original call order, so the transcript is
709
+ deterministic regardless of completion order.
710
+
711
+ Live end-to-end run, same task and model:
712
+
713
+ ```text
714
+ wall clock : 81.5s for 8 steps (10.2s/step)
715
+ tool calls : 24 (3.0/step)
716
+ prompt tokens : 74,387
717
+ cached tokens : 46,720 (62.8% hit rate)
718
+ optimizer saved : 1,318 tokens
719
+ wrapped up : True | hit limit: False
720
+ ```
721
+
722
+ ```yaml
723
+ run:
724
+ tool_concurrency: 4 # bounded parallelism, polite to the target
725
+ ```
726
+
727
+ ### Findings are deduplicated
728
+
729
+ The Red Agent re-tests the same surface every round, so the raw output filled
730
+ with near-identical entries. Two findings are merged when they share an
731
+ endpoint **and** either the same category (other than the default `general`) or
732
+ strongly overlapping titles:
733
+
734
+ ```
735
+ endpoint normalised + category matches -> merge
736
+ endpoint normalised + decisive token matches -> merge
737
+ endpoint normalised + >50% identity overlap -> merge
738
+ ```
739
+
740
+ The merge keeps the highest CVSS score and appends the new evidence, so nothing
741
+ is lost. Precision matters as much as recall here: `Missing CSP header` and
742
+ `Missing HSTS header`, or `SQL injection` and `XSS` on the same URL, stay
743
+ separate entries.
744
+
745
+ Measured on a real Metasploitable 2 run: **16 raw findings became 7 distinct
746
+ ones**, with all 7 genuinely different.
747
+
748
+ ### Versioned service detection
749
+
750
+ The single biggest efficacy factor. `nmap -sV` returns `vsftpd 2.3.4` or
751
+ `Metasploitable root shell`, which map directly to CVEs. The Red Agent's prompt
752
+ now makes `check_tool` -> `run_tool <scanner>` the first move on a network
753
+ target, before any HTTP probing:
754
+
755
+ ```text
756
+ run_tool nmap -Pn -sV -p <ports> <host>
757
+ run_tool nmap --script vuln -p <ports> <host>
758
+ run_tool nikto -h http://<host>
759
+ run_tool nuclei -u http://<host> -severity critical,high
760
+ ```
761
+
762
+ ### Validated, not guessed
763
+
764
+ A version banner is a lead; a pentest *proves* the flaw. The Red Agent has
765
+ validators that trigger the vulnerability, observe a benign side effect and
766
+ clean up:
767
+
768
+ | Validator | Proves | How |
769
+ | --- | --- | --- |
770
+ | `validate_vsftpd_backdoor` | CVE-2011-2523 | Sends the `:)` trigger and checks whether TCP/6200 opens. Runs nothing on the shell. |
771
+ | `validate_root_shell` | Unauthenticated shell | Reads the prompt (`root@host:/#`), disconnects. |
772
+ | `validate_samba_usermap` | CVE-2007-2447 | Sends the username payload, or asks `nmap` when a toolbox is available. |
773
+ | `validate_mysql_blank_password` | Exposed database | Parses the handshake version, returns the exact confirming command. |
774
+ | `validate_unrealircd_backdoor` | CVE-2010-2075 | Sends the `AB` token to the IRC daemon. |
775
+ | `validate_vnc_no_auth` | Exposed VNC | Reads the RFB handshake; no session is opened. |
776
+ | `validate_nfs_export` | World-readable NFS | Runs `showmount -e` for the real export list. |
777
+ | `validate_proftpd` | ProFTPD (incl. 2121) | Matches the banner to the product, so a vsftpd port is not mislabelled. |
778
+ | `validate_open_shell_port` | Any unauthenticated shell | Banner check with no command execution. |
779
+
780
+ Every one reports `validated: true/false` with raw evidence, and refuses to
781
+ claim success when it cannot observe the effect. Verified against a live
782
+ Metasploitable 2:
783
+
784
+ ```text
785
+ 1524 root shell -> validated: True 'root@metasploitable:/#'
786
+ vsftpd backdoor -> validated: True trigger opened TCP/6200 (root shell)
787
+ proftpd 2121 -> validated: True 'ProFTPD 1.3.1 Server (Debian)'
788
+ proftpd on 21 -> validated: False 'runs a different FTP daemon (vsFTPd)'
789
+ nfs export -> validated: False 'no exports: NFS is exposing nothing'
790
+ ```
791
+
792
+ Two rules the validators follow:
793
+
794
+ - **A missing tool is not a finding.** If `showmount` is unavailable the result
795
+ says so and explicitly instructs the model not to record it, instead of
796
+ parsing the error as a world-readable export.
797
+ - **The product must match.** A vsftpd banner is never reported as a ProFTPD
798
+ finding.
799
+
800
+ Findings carry `confidence: high` only when a validator confirmed them.
801
+
802
+ ### Sweep every port
803
+
804
+ A curated port list is how a backdoor on 6200, an IRC trojan on 6667 or an NFS
805
+ export on 2049 stays hidden. `port_scan` therefore supports a full sweep:
806
+
807
+ ```text
808
+ port_scan {"host": "<target>", "full": true}
809
+ ```
810
+
811
+ All 65535 ports in about 45 seconds, then validated one by one.
812
+
813
+ ### Resilience
814
+
815
+ Long engagements used to die on a single provider hiccup. Now:
816
+
817
+ - **Retries with exponential backoff** on transient failures (429, 5xx,
818
+ timeouts, resets). Permanent errors (401, 400, model not found) fail fast so
819
+ the real cause surfaces. A stream that already produced content is never
820
+ retried blindly.
821
+ - **Wall-clock deadline** (`run.max_duration_minutes`, default 90) so an
822
+ engagement cannot hang forever; it stops cleanly with everything persisted.
823
+
824
+ ```yaml
825
+ run:
826
+ max_duration_minutes: 90
827
+
828
+ # global config
829
+ llm:
830
+ max_retries: 3
831
+ retry_initial_delay: 1.0
832
+ retry_max_delay: 30.0
833
+ ```
834
+
835
+ ### Never run out of steps
836
+
837
+ Production runs used to end with `reached the step limit without a final
838
+ answer`: the agent burned its whole budget exploring and returned nothing.
839
+ The runtime now reserves the last `wrap_up_at` steps (default 3) and injects a
840
+ mandatory wrap-up instruction:
841
+
842
+ ```
843
+ You are almost out of steps (3 left of 20).
844
+ STOP exploring. Do this now, in this order:
845
+ 1. Persist every weakness you have already confirmed with record_finding…
846
+ 2. Save your working state with workspace_write to notes/<scope>-state.md…
847
+ 3. Reply with a concise markdown summary…
848
+ ```
849
+
850
+ If even that turn produces no text, `_fallback_summary()` builds one from the
851
+ persisted state (findings, tool calls, remaining task list) instead of a bare
852
+ error line. The `agent.wrap_up` event drives the UI phase indicator.
853
+
854
+ Configure it under `agents`:
855
+
856
+ ```yaml
857
+ agents:
858
+ red:
859
+ wrap_up_at: 3 # steps reserved for persisting + summarising (0 = off)
860
+ ```
861
+
862
+ ### Plan-before-you-scan
863
+
864
+ The Red Agent's prompt mandates a planning phase **before launching anything**:
865
+
866
+ 1. `workspace_info` — what tooling and package managers exist.
867
+ 2. `read_shared_context` + `list_findings` — what is already known.
868
+ 3. Write `notes/<topic>.md`, then register the plan with `todowrite`, covering:
869
+ - which mapping technique fits the target (passive vs active, web vs network
870
+ vs API) and why;
871
+ - which tools answer the question with the fewest requests (reuse before
872
+ installing);
873
+ - **how to avoid being blocked** — realistic User-Agent and headers,
874
+ throttling and jitter, low thread counts, limited port ranges,
875
+ retry-with-backoff, rate limits;
876
+ - the fallback when a control blocks you (WAF, 403/429, connection resets):
877
+ change technique or slow down rather than brute-forcing through.
878
+
879
+ **Red** — `dns_lookup`, `port_scan`, `http_request`, `audit_security_headers`,
880
+ `probe_paths`, `crawl`, `test_sql_injection`, `test_xss`,
881
+ `test_path_traversal`, `test_open_redirect`, `test_command_injection`,
882
+ `test_cors`, `test_http_methods`, `test_directory_listing`.
883
+
884
+ **Blue** — `analyze_logs`, `generate_firewall_rule`, `harden_headers`,
885
+ `suggest_patch`, `verify_control`.
886
+
887
+ All probes are non-destructive: they inject benign markers and inspect the
888
+ response. No tool deletes data, writes files on the target or opens a shell.
889
+
890
+ ---
891
+
892
+ ## Reports
893
+
894
+ Each session produces:
895
+
896
+ - **Markdown** — portable, versionable.
897
+ - **HTML** — a standalone, **printable A4** document with a cover, document
898
+ control and the full penetration-test structure.
899
+ - **JSON** — machine-readable, including metrics and the full session state.
900
+
901
+ The report layout is **owned by code, not by the model**, so two sessions with
902
+ the same data produce the same document, byte for byte. The AI only fills
903
+ structured fields; it never composes the document. Every report follows the same
904
+ section order:
905
+
906
+ | # | Section |
907
+ | --- | --- |
908
+ | — | Document control (version, classification, scope, model, dates) |
909
+ | 1 | Executive summary (overall risk, severity counts, top findings, resilience) |
910
+ | 2 | Scope and rules of engagement |
911
+ | 3 | Methodology and standards (OWASP WSTG/ASVS, PTES, NIST SP 800-115, ATT&CK) |
912
+ | 4 | Severity model (CVSS v3.1 bands) |
913
+ | 5 | Findings summary table |
914
+ | 6 | Detailed findings (one repeatable block per finding) |
915
+ | 7 | Remediation roadmap (priority + target SLA) |
916
+ | 8 | Retest and status tracking |
917
+ | 9 | Appendices (model usage, notes, round timeline) |
918
+ | 10 | Limitations and disclaimer |
919
+
920
+ Each finding block carries CVSS v3.1 score **and vector**, **CWE**, **OWASP**
921
+ category, affected asset, description, business **impact**, evidence, steps to
922
+ **reproduce**, remediation and status. Fields the model did not provide render as
923
+ *"Not provided"* rather than being guessed or left blank.
924
+
925
+ The **Dashboard** tab is the at-a-glance counterpart inside the app: overall
926
+ risk, severity distribution, top findings, round timeline and resilience,
927
+ rendered live from the session.
928
+
929
+ ### The resilience score tells the truth
930
+
931
+ The score counts **only findings whose mitigation was re-tested** by
932
+ `verify_control` and confirmed to hold:
933
+
934
+ | State | Score |
935
+ | --- | --- |
936
+ | Finding with no mitigation | 0 |
937
+ | Mitigation written but never applied | **0** |
938
+ | `verify_control` on an unpatched target | **0**, `still_exploitable: true` |
939
+ | Fix applied and confirmed by re-test | counted |
940
+
941
+ Proposing a firewall rule changes nothing on the target, so scoring proposals
942
+ as fixes reports a confident number that is untrue — which is worse than
943
+ reporting nothing, because the operator stops looking. When nothing has been
944
+ verified the report says so in as many words:
945
+
946
+ > **Nothing is verified yet.** Every mitigation below is a proposal. The score
947
+ > counts only issues re-tested after a control was applied, so it will not move
948
+ > until the fixes are actually deployed and the audit is re-run.
949
+
950
+ A lab target with no operator applying patches therefore scores **0**, which is
951
+ correct: every hole is still open.
952
+
953
+ ---
954
+
955
+ ## Responsible use
956
+
957
+ SplitAgent is for **authorised security testing only**. You must have explicit
958
+ permission to test the target. The framework enforces the configured `scope`
959
+ and refuses out-of-scope hosts; safe mode is on by default. The authors accept
960
+ no liability for misuse.
961
+
962
+ ---
963
+
964
+ ## Development
965
+
966
+ ```bash
967
+ pip install -e ".[dev]"
968
+ pre-commit install # lint, format and a secret scan on every commit
969
+ pytest
970
+ ruff check splitagent tests
971
+ ruff format splitagent tests
972
+ ```
973
+
974
+ CI runs lint, the full test suite on Linux/Windows/macOS across Python
975
+ 3.10-3.12, and builds the distribution to confirm the web assets ship in the
976
+ wheel. A pre-commit hook refuses to commit anything that looks like a real
977
+ provider key.
978
+
979
+ Tests cover CVSS scoring, configuration, encrypted context, tools (with a local
980
+ HTTP server), the full engine loop and the desktop pipeline (against a mock LLM
981
+ API) and the TUI.
982
+
983
+ ---
984
+
985
+ ## License
986
+
987
+ MIT © SplitAgent Contributors