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/ui/app.py
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Textual TUI for SplitAgent, styled after the OpenCode interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from textual import work
|
|
9
|
+
from textual.app import App, ComposeResult
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import (
|
|
14
|
+
Button,
|
|
15
|
+
DataTable,
|
|
16
|
+
Footer,
|
|
17
|
+
Input,
|
|
18
|
+
Label,
|
|
19
|
+
Markdown,
|
|
20
|
+
Select,
|
|
21
|
+
Static,
|
|
22
|
+
TabbedContent,
|
|
23
|
+
TabPane,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from splitagent.config import (
|
|
27
|
+
PROVIDER_PRESETS,
|
|
28
|
+
GlobalConfig,
|
|
29
|
+
ProjectConfig,
|
|
30
|
+
save_global_config,
|
|
31
|
+
)
|
|
32
|
+
from splitagent.core.bus import Event, EventBus
|
|
33
|
+
from splitagent.core.context import SharedContext
|
|
34
|
+
from splitagent.core.engine import Engine
|
|
35
|
+
from splitagent.report.generator import build_markdown
|
|
36
|
+
from splitagent.ui import theme as t
|
|
37
|
+
|
|
38
|
+
MAX_BLOCKS = 500
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigScreen(ModalScreen[GlobalConfig | None]):
|
|
42
|
+
"""Modal to configure the model API from inside the program."""
|
|
43
|
+
|
|
44
|
+
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: GlobalConfig) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self.config = config
|
|
49
|
+
|
|
50
|
+
def compose(self) -> ComposeResult:
|
|
51
|
+
provider_options = [(name, name) for name in PROVIDER_PRESETS]
|
|
52
|
+
with Vertical(id="config-dialog"):
|
|
53
|
+
yield Label("Model API configuration", id="config-title")
|
|
54
|
+
yield Label("Provider", classes="field-label")
|
|
55
|
+
yield Select(
|
|
56
|
+
provider_options,
|
|
57
|
+
value=self.config.llm.provider,
|
|
58
|
+
allow_blank=False,
|
|
59
|
+
id="provider",
|
|
60
|
+
)
|
|
61
|
+
yield Label("Base URL", classes="field-label")
|
|
62
|
+
yield Input(value=self.config.llm.base_url, id="base_url")
|
|
63
|
+
yield Label("API key", classes="field-label")
|
|
64
|
+
yield Input(
|
|
65
|
+
value=self.config.llm.api_key,
|
|
66
|
+
password=True,
|
|
67
|
+
placeholder="sk-...",
|
|
68
|
+
id="api_key",
|
|
69
|
+
)
|
|
70
|
+
yield Label("Model", classes="field-label")
|
|
71
|
+
yield Input(value=self.config.llm.model, id="model")
|
|
72
|
+
yield Label("Temperature", classes="field-label")
|
|
73
|
+
yield Input(value=str(self.config.llm.temperature), id="temperature")
|
|
74
|
+
yield Static("", id="config-status")
|
|
75
|
+
with Horizontal(id="config-buttons"):
|
|
76
|
+
yield Button("Test", id="test", variant="default")
|
|
77
|
+
yield Button("Save", id="save", variant="primary")
|
|
78
|
+
yield Button("Cancel", id="cancel")
|
|
79
|
+
|
|
80
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
81
|
+
if event.select.id != "provider" or not event.value:
|
|
82
|
+
return
|
|
83
|
+
preset = PROVIDER_PRESETS.get(str(event.value))
|
|
84
|
+
if not preset:
|
|
85
|
+
return
|
|
86
|
+
self.query_one("#base_url", Input).value = preset["base_url"]
|
|
87
|
+
self.query_one("#model", Input).value = preset["model"]
|
|
88
|
+
|
|
89
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
90
|
+
if event.button.id == "cancel":
|
|
91
|
+
self.dismiss(None)
|
|
92
|
+
return
|
|
93
|
+
updated = self._collect()
|
|
94
|
+
if event.button.id == "save":
|
|
95
|
+
self.dismiss(updated)
|
|
96
|
+
return
|
|
97
|
+
if event.button.id == "test":
|
|
98
|
+
status = self.query_one("#config-status", Static)
|
|
99
|
+
status.update("Testing connection...")
|
|
100
|
+
from splitagent.llm.client import LLMClient
|
|
101
|
+
|
|
102
|
+
async with LLMClient(updated.llm) as client:
|
|
103
|
+
ok, message = await client.test_connection()
|
|
104
|
+
status.update(f"OK: {message}" if ok else f"Failed: {message}")
|
|
105
|
+
|
|
106
|
+
def _collect(self) -> GlobalConfig:
|
|
107
|
+
cfg = GlobalConfig(
|
|
108
|
+
llm=type(self.config.llm)(**vars(self.config.llm)),
|
|
109
|
+
ui=self.config.ui,
|
|
110
|
+
sandbox=self.config.sandbox,
|
|
111
|
+
authorized=self.config.authorized,
|
|
112
|
+
)
|
|
113
|
+
cfg.llm.provider = str(self.query_one("#provider", Select).value or "custom")
|
|
114
|
+
cfg.llm.base_url = self.query_one("#base_url", Input).value.strip()
|
|
115
|
+
cfg.llm.api_key = self.query_one("#api_key", Input).value.strip()
|
|
116
|
+
cfg.llm.model = self.query_one("#model", Input).value.strip()
|
|
117
|
+
try:
|
|
118
|
+
cfg.llm.temperature = float(self.query_one("#temperature", Input).value or "0.2")
|
|
119
|
+
except ValueError:
|
|
120
|
+
cfg.llm.temperature = 0.2
|
|
121
|
+
preset = PROVIDER_PRESETS.get(cfg.llm.provider)
|
|
122
|
+
if preset:
|
|
123
|
+
cfg.llm.protocol = preset.get("protocol", "openai")
|
|
124
|
+
return cfg
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SplitAgentApp(App[None]):
|
|
128
|
+
"""The SplitAgent cockpit."""
|
|
129
|
+
|
|
130
|
+
TITLE = "SplitAgent"
|
|
131
|
+
SUB_TITLE = "autonomous dual-team security"
|
|
132
|
+
CSS = f"""
|
|
133
|
+
Screen {{ background: {t.BG}; color: {t.TEXT}; }}
|
|
134
|
+
#topbar {{
|
|
135
|
+
height: 3; padding: 0 1; background: {t.PANEL};
|
|
136
|
+
border-bottom: solid {t.BORDER};
|
|
137
|
+
}}
|
|
138
|
+
#topbar-title {{ color: {t.PRIMARY}; text-style: bold; }}
|
|
139
|
+
#topbar-meta {{ color: {t.MUTED}; }}
|
|
140
|
+
#run-button {{ dock: right; margin: 0 1; min-width: 14; }}
|
|
141
|
+
#body {{ height: 1fr; }}
|
|
142
|
+
.agent-pane {{ width: 1fr; border: round {t.BORDER}; }}
|
|
143
|
+
.agent-pane-title {{ padding: 0 1; background: {t.ELEMENT}; color: {t.TEXT}; }}
|
|
144
|
+
#red-pane {{ border: round {t.RED}; }}
|
|
145
|
+
#blue-pane {{ border: round {t.SECONDARY}; }}
|
|
146
|
+
VerticalScroll {{ background: {t.BG}; }}
|
|
147
|
+
#findings-table, #mitigations-table {{ background: {t.PANEL}; }}
|
|
148
|
+
#report-view {{ background: {t.BG}; padding: 1 2; }}
|
|
149
|
+
#config-dialog {{
|
|
150
|
+
width: 70; height: auto; max-height: 90%;
|
|
151
|
+
background: {t.PANEL}; border: thick {t.PRIMARY}; padding: 1 2;
|
|
152
|
+
align: center middle;
|
|
153
|
+
}}
|
|
154
|
+
#config-title {{ color: {t.PRIMARY}; text-style: bold; padding-bottom: 1; }}
|
|
155
|
+
.field-label {{ color: {t.MUTED}; padding-top: 1; }}
|
|
156
|
+
#config-status {{ color: {t.CYAN}; padding-top: 1; min-height: 2; }}
|
|
157
|
+
#config-buttons {{ height: 3; align-horizontal: right; padding-top: 1; }}
|
|
158
|
+
#config-buttons Button {{ margin-left: 1; }}
|
|
159
|
+
TabbedContent {{ background: {t.BG}; }}
|
|
160
|
+
Tabs {{ background: {t.PANEL}; }}
|
|
161
|
+
Tab {{ color: {t.MUTED}; }}
|
|
162
|
+
Tab.-active {{ color: {t.PRIMARY}; text-style: bold; }}
|
|
163
|
+
Footer {{ background: {t.PANEL}; color: {t.MUTED}; }}
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
BINDINGS = [
|
|
167
|
+
Binding("q", "quit", "Quit"),
|
|
168
|
+
Binding("r", "run_audit", "Run"),
|
|
169
|
+
Binding("c", "configure", "Configure"),
|
|
170
|
+
Binding("e", "export", "Export"),
|
|
171
|
+
Binding("s", "save_session", "Save"),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def __init__(self, global_config: GlobalConfig, project: ProjectConfig) -> None:
|
|
175
|
+
super().__init__()
|
|
176
|
+
self.global_config = global_config
|
|
177
|
+
self.project = project
|
|
178
|
+
self.bus = EventBus()
|
|
179
|
+
self.bus.subscribe(self._on_event)
|
|
180
|
+
self.engine: Engine | None = None
|
|
181
|
+
self.buffers: dict[str, list[str]] = {"red": [], "blue": []}
|
|
182
|
+
self.findings: list[dict[str, Any]] = []
|
|
183
|
+
self.mitigations: list[dict[str, Any]] = []
|
|
184
|
+
self.logs: list[str] = []
|
|
185
|
+
self.round = 0
|
|
186
|
+
self.total_rounds = project.run.rounds
|
|
187
|
+
self.phase = "idle"
|
|
188
|
+
self.resilience = 0.0
|
|
189
|
+
self.running = False
|
|
190
|
+
self._session: SharedContext | None = None
|
|
191
|
+
|
|
192
|
+
# -- layout ------------------------------------------------------------ #
|
|
193
|
+
def compose(self) -> ComposeResult:
|
|
194
|
+
with Horizontal(id="topbar"):
|
|
195
|
+
yield Static("SPLITAGENT", id="topbar-title")
|
|
196
|
+
yield Static("", id="topbar-meta")
|
|
197
|
+
yield Button("> Run audit", id="run-button", variant="primary")
|
|
198
|
+
with TabbedContent(id="body"):
|
|
199
|
+
with TabPane("Stream", id="tab-stream"), Horizontal():
|
|
200
|
+
with Vertical(id="red-pane", classes="agent-pane"):
|
|
201
|
+
yield Static("RED AGENT", classes="agent-pane-title")
|
|
202
|
+
with VerticalScroll(id="red-scroll"):
|
|
203
|
+
yield Static("", id="red-log")
|
|
204
|
+
with Vertical(id="blue-pane", classes="agent-pane"):
|
|
205
|
+
yield Static("BLUE AGENT", classes="agent-pane-title")
|
|
206
|
+
with VerticalScroll(id="blue-scroll"):
|
|
207
|
+
yield Static("", id="blue-log")
|
|
208
|
+
with TabPane("Findings", id="tab-findings"):
|
|
209
|
+
yield DataTable(id="findings-table", zebra_stripes=True)
|
|
210
|
+
with TabPane("Mitigations", id="tab-mitigations"):
|
|
211
|
+
yield DataTable(id="mitigations-table", zebra_stripes=True)
|
|
212
|
+
with TabPane("Report", id="tab-report"), VerticalScroll():
|
|
213
|
+
yield Markdown("_Run an audit to generate the report._", id="report-view")
|
|
214
|
+
with TabPane("Activity", id="tab-activity"), VerticalScroll():
|
|
215
|
+
yield Static("", id="activity-log")
|
|
216
|
+
yield Footer()
|
|
217
|
+
|
|
218
|
+
def on_mount(self) -> None:
|
|
219
|
+
findings = self.query_one("#findings-table", DataTable)
|
|
220
|
+
findings.add_columns("ID", "Severity", "CVSS", "Title", "Status")
|
|
221
|
+
mitigations = self.query_one("#mitigations-table", DataTable)
|
|
222
|
+
mitigations.add_columns("ID", "Kind", "Finding", "Title", "Status")
|
|
223
|
+
self._refresh_topbar()
|
|
224
|
+
if not self.global_config.configured:
|
|
225
|
+
self._prompt_config(first_run=True)
|
|
226
|
+
|
|
227
|
+
# -- top bar ----------------------------------------------------------- #
|
|
228
|
+
def _refresh_topbar(self) -> None:
|
|
229
|
+
meta = (
|
|
230
|
+
f" target {self.project.target.url or 'n/a'} "
|
|
231
|
+
f"model {self.global_config.llm.provider}/{self.global_config.llm.model} "
|
|
232
|
+
f"round {self.round}/{self.total_rounds} "
|
|
233
|
+
f"phase {self.phase} resilience {self.resilience}/100"
|
|
234
|
+
)
|
|
235
|
+
self.query_one("#topbar-meta", Static).update(meta)
|
|
236
|
+
|
|
237
|
+
# -- actions ----------------------------------------------------------- #
|
|
238
|
+
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
239
|
+
if event.button.id == "run-button":
|
|
240
|
+
self.action_run_audit()
|
|
241
|
+
|
|
242
|
+
def action_run_audit(self) -> None:
|
|
243
|
+
if self.running:
|
|
244
|
+
self._log_line("A run is already in progress.")
|
|
245
|
+
return
|
|
246
|
+
if not self.global_config.configured:
|
|
247
|
+
self._prompt_config(first_run=True)
|
|
248
|
+
return
|
|
249
|
+
self.running = True
|
|
250
|
+
self._log_line("Starting audit...")
|
|
251
|
+
self._run_engine()
|
|
252
|
+
|
|
253
|
+
def action_configure(self) -> None:
|
|
254
|
+
self._prompt_config()
|
|
255
|
+
|
|
256
|
+
def _prompt_config(self, first_run: bool = False) -> None:
|
|
257
|
+
def _apply(result: GlobalConfig | None) -> None:
|
|
258
|
+
if result is None:
|
|
259
|
+
return
|
|
260
|
+
self.global_config = result
|
|
261
|
+
path = save_global_config(result)
|
|
262
|
+
self._log_line(f"Configuration saved to {path}")
|
|
263
|
+
self._refresh_topbar()
|
|
264
|
+
|
|
265
|
+
self.push_screen(ConfigScreen(self.global_config), _apply)
|
|
266
|
+
|
|
267
|
+
def action_save_session(self) -> None:
|
|
268
|
+
if self._session is not None:
|
|
269
|
+
path = self._session.save()
|
|
270
|
+
self._log_line(f"Session saved to {path}")
|
|
271
|
+
else:
|
|
272
|
+
self._log_line("Nothing to save yet.")
|
|
273
|
+
|
|
274
|
+
def action_export(self) -> None:
|
|
275
|
+
if self._session is None:
|
|
276
|
+
self._log_line("Run an audit first.")
|
|
277
|
+
return
|
|
278
|
+
from pathlib import Path
|
|
279
|
+
|
|
280
|
+
from splitagent.report.generator import write_reports
|
|
281
|
+
|
|
282
|
+
paths = write_reports(
|
|
283
|
+
self._session.state, self.project.report, Path(self.project.report.output_dir)
|
|
284
|
+
)
|
|
285
|
+
for path in paths:
|
|
286
|
+
self._log_line(f"Report written: {path}")
|
|
287
|
+
|
|
288
|
+
# -- engine worker ----------------------------------------------------- #
|
|
289
|
+
@work(exclusive=True)
|
|
290
|
+
async def _run_engine(self) -> None:
|
|
291
|
+
try:
|
|
292
|
+
self.engine = Engine(self.global_config, self.project, bus=self.bus)
|
|
293
|
+
self._session = await self.engine.run()
|
|
294
|
+
self._log_line("Audit complete.")
|
|
295
|
+
markdown = build_markdown(self._session.state, self.project.report)
|
|
296
|
+
self.query_one("#report-view", Markdown).update(markdown)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
self._log_line(f"Run failed: {type(exc).__name__}: {exc}")
|
|
299
|
+
finally:
|
|
300
|
+
self.running = False
|
|
301
|
+
self.phase = "done"
|
|
302
|
+
self._refresh_topbar()
|
|
303
|
+
|
|
304
|
+
# -- event bus --------------------------------------------------------- #
|
|
305
|
+
def _on_event(self, event: Event) -> None:
|
|
306
|
+
etype = event.type
|
|
307
|
+
if etype == "session.start":
|
|
308
|
+
self.project.target.url = self.project.target.url or event.data.get("target", "")
|
|
309
|
+
self.total_rounds = event.data.get("rounds", self.total_rounds)
|
|
310
|
+
self.phase = "starting"
|
|
311
|
+
elif etype == "round.start":
|
|
312
|
+
self.round = event.data.get("round", self.round)
|
|
313
|
+
self.phase = "round"
|
|
314
|
+
elif etype == "phase.start":
|
|
315
|
+
self.phase = event.data.get("phase", self.phase)
|
|
316
|
+
self._append(event.agent, f"\n-- {event.agent.upper()} - {self.phase} --\n")
|
|
317
|
+
elif etype == "agent.text":
|
|
318
|
+
self._append(event.agent, event.data.get("text", ""))
|
|
319
|
+
elif etype == "agent.tool_call":
|
|
320
|
+
args = json.dumps(event.data.get("arguments", {}), ensure_ascii=False)
|
|
321
|
+
self._append(event.agent, f"\n* {event.data.get('tool')}({args[:180]})\n")
|
|
322
|
+
elif etype == "agent.tool_result":
|
|
323
|
+
output = str(event.data.get("output", "")).replace("\n", " ")
|
|
324
|
+
self._append(event.agent, f" ~ {output[:200]}\n")
|
|
325
|
+
elif etype == "finding":
|
|
326
|
+
self.findings.append(event.data)
|
|
327
|
+
self._add_finding_row(event.data)
|
|
328
|
+
elif etype == "mitigation":
|
|
329
|
+
self.mitigations.append(event.data)
|
|
330
|
+
self._add_mitigation_row(event.data)
|
|
331
|
+
elif etype == "round.end":
|
|
332
|
+
self.resilience = float(event.data.get("resilience", self.resilience))
|
|
333
|
+
elif etype == "log":
|
|
334
|
+
self._log_line(event.data.get("text", ""))
|
|
335
|
+
elif etype == "error":
|
|
336
|
+
self._log_line(f"error: {event.data.get('text', '')}")
|
|
337
|
+
elif etype == "session.end":
|
|
338
|
+
self.phase = "done"
|
|
339
|
+
self.resilience = float(event.data.get("resilience", self.resilience))
|
|
340
|
+
self._refresh_topbar()
|
|
341
|
+
|
|
342
|
+
# -- widget helpers ---------------------------------------------------- #
|
|
343
|
+
def _append(self, agent: str, text: str) -> None:
|
|
344
|
+
buffer = self.buffers.setdefault(agent, [])
|
|
345
|
+
buffer.append(text)
|
|
346
|
+
if len(buffer) > MAX_BLOCKS:
|
|
347
|
+
del buffer[: len(buffer) - MAX_BLOCKS]
|
|
348
|
+
widget_id = "#red-log" if agent == "red" else "#blue-log"
|
|
349
|
+
scroll_id = "#red-scroll" if agent == "red" else "#blue-scroll"
|
|
350
|
+
try:
|
|
351
|
+
self.query_one(widget_id, Static).update("".join(buffer))
|
|
352
|
+
self.query_one(scroll_id, VerticalScroll).scroll_end(animate=False)
|
|
353
|
+
except Exception: # pragma: no cover - widget not mounted yet
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
def _log_line(self, message: str) -> None:
|
|
357
|
+
self.logs.append(message)
|
|
358
|
+
try:
|
|
359
|
+
self.query_one("#activity-log", Static).update("\n".join(self.logs[-300:]))
|
|
360
|
+
except Exception: # pragma: no cover
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
def _add_finding_row(self, data: dict[str, Any]) -> None:
|
|
364
|
+
try:
|
|
365
|
+
table = self.query_one("#findings-table", DataTable)
|
|
366
|
+
table.add_row(
|
|
367
|
+
data.get("id", ""),
|
|
368
|
+
data.get("severity", "").upper(),
|
|
369
|
+
f"{data.get('cvss_score', 0):.1f}",
|
|
370
|
+
data.get("title", ""),
|
|
371
|
+
"open",
|
|
372
|
+
key=data.get("id"),
|
|
373
|
+
)
|
|
374
|
+
except Exception: # pragma: no cover
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
def _add_mitigation_row(self, data: dict[str, Any]) -> None:
|
|
378
|
+
try:
|
|
379
|
+
table = self.query_one("#mitigations-table", DataTable)
|
|
380
|
+
table.add_row(
|
|
381
|
+
data.get("id", ""),
|
|
382
|
+
data.get("kind", ""),
|
|
383
|
+
data.get("finding_id", ""),
|
|
384
|
+
data.get("title", ""),
|
|
385
|
+
"proposed",
|
|
386
|
+
key=data.get("id"),
|
|
387
|
+
)
|
|
388
|
+
except Exception: # pragma: no cover
|
|
389
|
+
pass
|
splitagent/ui/stream.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Live streaming renderer for the CLI (Rich).
|
|
2
|
+
|
|
3
|
+
Renders a two-column Red/Blue console with a status header and a findings
|
|
4
|
+
footer, updating as events arrive from the engine's event bus.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console, Group
|
|
13
|
+
from rich.layout import Layout
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from splitagent.core.bus import Event
|
|
20
|
+
from splitagent.ui import theme as t
|
|
21
|
+
|
|
22
|
+
MAX_LINES = 600
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StreamRenderer:
|
|
26
|
+
"""Subscribes to the bus and paints the audit in real time."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, console: Console | None = None, show_thinking: bool = True) -> None:
|
|
29
|
+
self.console = console or Console()
|
|
30
|
+
self.show_thinking = show_thinking
|
|
31
|
+
self.buffers: dict[str, list[tuple[str, str]]] = {"red": [], "blue": []}
|
|
32
|
+
self.findings: list[dict[str, Any]] = []
|
|
33
|
+
self.mitigations: list[dict[str, Any]] = []
|
|
34
|
+
self.logs: list[str] = []
|
|
35
|
+
self.round = 0
|
|
36
|
+
self.total_rounds = 0
|
|
37
|
+
self.phase = "idle"
|
|
38
|
+
self.target = ""
|
|
39
|
+
self.model = ""
|
|
40
|
+
self.provider = ""
|
|
41
|
+
self.resilience = 0.0
|
|
42
|
+
self._live: Live | None = None
|
|
43
|
+
self._printed_logs = 0
|
|
44
|
+
|
|
45
|
+
# -- wiring ------------------------------------------------------------ #
|
|
46
|
+
def attach(self, bus) -> Any:
|
|
47
|
+
return bus.subscribe(self.handle)
|
|
48
|
+
|
|
49
|
+
# -- lifecycle --------------------------------------------------------- #
|
|
50
|
+
def start(self) -> None:
|
|
51
|
+
if self.console.is_terminal:
|
|
52
|
+
self._live = Live(
|
|
53
|
+
self.render(),
|
|
54
|
+
console=self.console,
|
|
55
|
+
refresh_per_second=12,
|
|
56
|
+
screen=False,
|
|
57
|
+
transient=False,
|
|
58
|
+
)
|
|
59
|
+
self._live.start()
|
|
60
|
+
else:
|
|
61
|
+
self.console.print(
|
|
62
|
+
Text(t.BANNER, style=t.PRIMARY),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
if self._live is not None:
|
|
67
|
+
self._live.update(self.render())
|
|
68
|
+
self._live.stop()
|
|
69
|
+
self._live = None
|
|
70
|
+
|
|
71
|
+
def _refresh(self) -> None:
|
|
72
|
+
if self._live is not None:
|
|
73
|
+
self._live.update(self.render())
|
|
74
|
+
else:
|
|
75
|
+
self._print_compact()
|
|
76
|
+
|
|
77
|
+
def _print_compact(self) -> None:
|
|
78
|
+
# Non-interactive output: emit each new log line exactly once.
|
|
79
|
+
while self._printed_logs < len(self.logs):
|
|
80
|
+
self.console.print(self.logs[self._printed_logs])
|
|
81
|
+
self._printed_logs += 1
|
|
82
|
+
|
|
83
|
+
# -- event handling ---------------------------------------------------- #
|
|
84
|
+
def handle(self, event: Event) -> None:
|
|
85
|
+
etype = event.type
|
|
86
|
+
if etype == "session.start":
|
|
87
|
+
self.target = event.data.get("target", "")
|
|
88
|
+
self.model = event.data.get("model", "")
|
|
89
|
+
self.provider = event.data.get("provider", "")
|
|
90
|
+
self.total_rounds = event.data.get("rounds", 0)
|
|
91
|
+
self.phase = "starting"
|
|
92
|
+
elif etype == "round.start":
|
|
93
|
+
self.round = event.data.get("round", self.round)
|
|
94
|
+
self.phase = "round"
|
|
95
|
+
for agent in ("red", "blue"):
|
|
96
|
+
self._push(agent, "sep", f"-- Round {self.round}/{self.total_rounds} --")
|
|
97
|
+
elif etype == "phase.start":
|
|
98
|
+
self.phase = event.data.get("phase", self.phase)
|
|
99
|
+
color = t.agent_color(event.agent)
|
|
100
|
+
self._push(event.agent, "sep", f"> {event.agent.upper()} phase: {self.phase}")
|
|
101
|
+
self.logs.append(f"[{color}]{event.agent}[/] {self.phase} (round {self.round})")
|
|
102
|
+
elif etype == "agent.text":
|
|
103
|
+
self._push(event.agent, "text", event.data.get("text", ""))
|
|
104
|
+
elif etype == "agent.tool_call":
|
|
105
|
+
tool = event.data.get("tool", "?")
|
|
106
|
+
args = event.data.get("arguments", {})
|
|
107
|
+
preview = json.dumps(args, ensure_ascii=False)
|
|
108
|
+
if len(preview) > 160:
|
|
109
|
+
preview = preview[:160] + "..."
|
|
110
|
+
self._push(event.agent, "tool", f"\n* {tool}({preview})")
|
|
111
|
+
self.logs.append(f"[{t.MUTED}]{event.agent} -> {tool}[/]")
|
|
112
|
+
elif etype == "agent.tool_result":
|
|
113
|
+
output = str(event.data.get("output", "")).replace("\n", " ")
|
|
114
|
+
self._push(event.agent, "result", f" ~ {output[:220]}")
|
|
115
|
+
elif etype == "finding":
|
|
116
|
+
self.findings.append(event.data)
|
|
117
|
+
self.logs.append(
|
|
118
|
+
f"[{t.severity_color(event.data.get('severity', 'info'))}]"
|
|
119
|
+
f"finding {event.data.get('id')}: {event.data.get('title')}[/]"
|
|
120
|
+
)
|
|
121
|
+
elif etype == "mitigation":
|
|
122
|
+
self.mitigations.append(event.data)
|
|
123
|
+
self.logs.append(
|
|
124
|
+
f"[{t.GREEN}]mitigation {event.data.get('id')} ({event.data.get('kind')})[/]"
|
|
125
|
+
)
|
|
126
|
+
elif etype == "round.end":
|
|
127
|
+
self.resilience = float(event.data.get("resilience", self.resilience))
|
|
128
|
+
elif etype == "log":
|
|
129
|
+
self.logs.append(
|
|
130
|
+
f"[{t.status_color(event.data.get('level', 'info'))}]"
|
|
131
|
+
f"{event.data.get('text', '')}[/]"
|
|
132
|
+
)
|
|
133
|
+
elif etype == "error":
|
|
134
|
+
self.logs.append(f"[{t.RED}]error: {event.data.get('text', '')}[/]")
|
|
135
|
+
elif etype == "session.end":
|
|
136
|
+
self.phase = "done"
|
|
137
|
+
self.resilience = float(event.data.get("resilience", self.resilience))
|
|
138
|
+
self.logs.append(f"[{t.GREEN}]session complete - resilience {self.resilience}/100[/]")
|
|
139
|
+
self._refresh()
|
|
140
|
+
|
|
141
|
+
def _push(self, agent: str, kind: str, text: str) -> None:
|
|
142
|
+
buffer = self.buffers.setdefault(agent, [])
|
|
143
|
+
buffer.append((kind, text))
|
|
144
|
+
if len(buffer) > MAX_LINES:
|
|
145
|
+
del buffer[: len(buffer) - MAX_LINES]
|
|
146
|
+
|
|
147
|
+
# -- rendering --------------------------------------------------------- #
|
|
148
|
+
def render(self) -> Layout:
|
|
149
|
+
layout = Layout()
|
|
150
|
+
layout.split_column(
|
|
151
|
+
Layout(name="header", size=3),
|
|
152
|
+
Layout(name="body", ratio=1),
|
|
153
|
+
Layout(name="footer", size=7),
|
|
154
|
+
)
|
|
155
|
+
layout["header"].update(self._header())
|
|
156
|
+
body = Table.grid(expand=True)
|
|
157
|
+
body.add_column(ratio=1)
|
|
158
|
+
body.add_column(ratio=1)
|
|
159
|
+
body.add_row(
|
|
160
|
+
Panel(
|
|
161
|
+
self._agent_text("red"),
|
|
162
|
+
title=f"[{t.RED}]RED AGENT[/]",
|
|
163
|
+
border_style=t.RED if self.phase == "offense" else t.BORDER,
|
|
164
|
+
padding=(0, 1),
|
|
165
|
+
),
|
|
166
|
+
Panel(
|
|
167
|
+
self._agent_text("blue"),
|
|
168
|
+
title=f"[{t.SECONDARY}]BLUE AGENT[/]",
|
|
169
|
+
border_style=t.SECONDARY if self.phase == "defense" else t.BORDER,
|
|
170
|
+
padding=(0, 1),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
layout["body"].update(body)
|
|
174
|
+
layout["footer"].update(self._footer())
|
|
175
|
+
return layout
|
|
176
|
+
|
|
177
|
+
def _header(self) -> Panel:
|
|
178
|
+
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
179
|
+
for finding in self.findings:
|
|
180
|
+
counts[finding.get("severity", "info")] = (
|
|
181
|
+
counts.get(finding.get("severity", "info"), 0) + 1
|
|
182
|
+
)
|
|
183
|
+
line = Text()
|
|
184
|
+
line.append("SPLITAGENT ", style=f"bold {t.PRIMARY}")
|
|
185
|
+
line.append("- ", style=t.MUTED)
|
|
186
|
+
line.append(self.target or "no target", style=t.TEXT)
|
|
187
|
+
line.append(" - ", style=t.MUTED)
|
|
188
|
+
line.append(self.provider or "?", style=t.SECONDARY)
|
|
189
|
+
line.append("/", style=t.MUTED)
|
|
190
|
+
line.append(self.model or "?", style=t.SECONDARY)
|
|
191
|
+
line.append(" - ", style=t.MUTED)
|
|
192
|
+
line.append(f"round {self.round}/{self.total_rounds}", style=t.TEXT)
|
|
193
|
+
line.append(" - ", style=t.MUTED)
|
|
194
|
+
line.append(f"phase {self.phase}", style=t.ACCENT)
|
|
195
|
+
line.append(" - ", style=t.MUTED)
|
|
196
|
+
line.append(f"resilience {self.resilience}/100", style=t.GREEN)
|
|
197
|
+
return Panel(line, border_style=t.BORDER, padding=(0, 1))
|
|
198
|
+
|
|
199
|
+
def _agent_text(self, agent: str) -> Text:
|
|
200
|
+
text = Text()
|
|
201
|
+
for kind, payload in self.buffers.get(agent, [])[-MAX_LINES:]:
|
|
202
|
+
if kind == "text":
|
|
203
|
+
text.append(payload, style=t.TEXT)
|
|
204
|
+
elif kind == "tool":
|
|
205
|
+
text.append(payload + "\n", style=f"bold {t.CYAN}")
|
|
206
|
+
elif kind == "result":
|
|
207
|
+
text.append(payload + "\n", style=t.MUTED)
|
|
208
|
+
elif kind == "sep":
|
|
209
|
+
text.append("\n" + payload + "\n", style=f"bold {t.ACCENT}")
|
|
210
|
+
if not text.plain:
|
|
211
|
+
text.append("waiting...", style=t.MUTED)
|
|
212
|
+
return text
|
|
213
|
+
|
|
214
|
+
def _footer(self) -> Panel:
|
|
215
|
+
table = Table.grid(expand=True)
|
|
216
|
+
table.add_column(ratio=1)
|
|
217
|
+
table.add_column(ratio=2)
|
|
218
|
+
counts = Text()
|
|
219
|
+
for name in ("critical", "high", "medium", "low", "info"):
|
|
220
|
+
value = sum(1 for f in self.findings if f.get("severity") == name)
|
|
221
|
+
counts.append(f"{name[:4].upper()} {value} ", style=t.severity_color(name))
|
|
222
|
+
counts.append(f" mitigations {len(self.mitigations)}", style=t.GREEN)
|
|
223
|
+
recent = Text()
|
|
224
|
+
for line in self.logs[-5:]:
|
|
225
|
+
recent.append(line + "\n")
|
|
226
|
+
if not recent.plain:
|
|
227
|
+
recent.append("waiting for events...", style=t.MUTED)
|
|
228
|
+
table.add_row(counts, recent)
|
|
229
|
+
return Panel(
|
|
230
|
+
Group(counts, recent),
|
|
231
|
+
title=f"[{t.MUTED}]STATUS[/]",
|
|
232
|
+
border_style=t.BORDER,
|
|
233
|
+
padding=(0, 1),
|
|
234
|
+
)
|
splitagent/ui/theme.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""OpenCode-inspired colour system.
|
|
2
|
+
|
|
3
|
+
The palette mirrors the default ``opencode`` theme (packages/tui/src/theme)
|
|
4
|
+
so the SplitAgent TUI feels native to the same visual language: a near-black
|
|
5
|
+
canvas, warm sand accent and a restrained, low-contrast grayscale.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
# Core steps
|
|
11
|
+
BG = "#0a0a0a"
|
|
12
|
+
PANEL = "#141414"
|
|
13
|
+
ELEMENT = "#1e1e1e"
|
|
14
|
+
STEP4 = "#282828"
|
|
15
|
+
STEP5 = "#323232"
|
|
16
|
+
BORDER_SUBTLE = "#3c3c3c"
|
|
17
|
+
BORDER = "#484848"
|
|
18
|
+
BORDER_ACTIVE = "#606060"
|
|
19
|
+
PRIMARY = "#fab283"
|
|
20
|
+
PRIMARY_SOFT = "#ffc09f"
|
|
21
|
+
MUTED = "#808080"
|
|
22
|
+
TEXT = "#eeeeee"
|
|
23
|
+
|
|
24
|
+
# Semantic
|
|
25
|
+
SECONDARY = "#5c9cf5"
|
|
26
|
+
ACCENT = "#9d7cd8"
|
|
27
|
+
RED = "#e06c75"
|
|
28
|
+
ORANGE = "#f5a742"
|
|
29
|
+
GREEN = "#7fd88f"
|
|
30
|
+
CYAN = "#56b6c2"
|
|
31
|
+
YELLOW = "#e5c07b"
|
|
32
|
+
|
|
33
|
+
SEVERITY_COLORS = {
|
|
34
|
+
"critical": RED,
|
|
35
|
+
"high": ORANGE,
|
|
36
|
+
"medium": YELLOW,
|
|
37
|
+
"low": GREEN,
|
|
38
|
+
"info": CYAN,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
AGENT_COLORS = {
|
|
42
|
+
"red": RED,
|
|
43
|
+
"blue": SECONDARY,
|
|
44
|
+
"core": PRIMARY,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
STATUS_COLORS = {
|
|
48
|
+
"info": CYAN,
|
|
49
|
+
"warning": ORANGE,
|
|
50
|
+
"error": RED,
|
|
51
|
+
"success": GREEN,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
BANNER = r"""
|
|
55
|
+
___ _ _ _ _ _
|
|
56
|
+
/ __|_ __ (_) |_| | | |__ _ _ _ __| |_
|
|
57
|
+
\__ \ '_ \ | | _| |__| / _` | ' \/ _` _|
|
|
58
|
+
|___/ .__/ |_|\__|____|_\__,_|_||_\__,_\__|
|
|
59
|
+
|_| autonomous dual-team security
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def severity_color(severity: str) -> str:
|
|
64
|
+
return SEVERITY_COLORS.get(severity.lower(), MUTED)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def agent_color(agent: str) -> str:
|
|
68
|
+
return AGENT_COLORS.get(agent, MUTED)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def status_color(level: str) -> str:
|
|
72
|
+
return STATUS_COLORS.get(level, MUTED)
|