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,424 @@
1
+ """The central orchestrator: coordinates the Red/Blue rounds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from splitagent.config import GlobalConfig, ProjectConfig
10
+ from splitagent.core.bus import Event, EventBus
11
+ from splitagent.core.context import SharedContext
12
+ from splitagent.core.sandbox import DockerSandbox, SandboxStatus
13
+ from splitagent.errors import ConfigError
14
+ from splitagent.llm.client import LLMClient
15
+
16
+
17
+ class Engine:
18
+ """Drives a full purple-team audit session."""
19
+
20
+ def __init__(
21
+ self,
22
+ global_config: GlobalConfig,
23
+ project: ProjectConfig,
24
+ bus: EventBus | None = None,
25
+ context: SharedContext | None = None,
26
+ ) -> None:
27
+ self.global_config = global_config
28
+ self.project = project
29
+ self.bus = bus or EventBus()
30
+ self.sandbox = DockerSandbox(project.run.sandbox, session_id="run")
31
+ self._sandbox_status: SandboxStatus | None = None
32
+ self.workspace: Any = None
33
+ self.toolbox: Any = None
34
+ self.toolbox_status: Any = None
35
+ self._toolbox_error = ""
36
+ target = project.target.url or ", ".join(project.target.effective_hosts())
37
+ self.context = context or SharedContext.create(
38
+ target=target or "unspecified",
39
+ target_kind=project.target.kind,
40
+ scope=project.target.scope or project.target.effective_hosts(),
41
+ name=project.name,
42
+ model=global_config.llm.model,
43
+ provider=global_config.llm.provider,
44
+ bus=self.bus,
45
+ )
46
+
47
+ # -- helpers ----------------------------------------------------------- #
48
+ def _require_llm(self) -> None:
49
+ from splitagent.config import KEYLESS_PROVIDERS
50
+
51
+ if (
52
+ not self.global_config.llm.resolved_api_key()
53
+ and self.global_config.llm.provider not in KEYLESS_PROVIDERS
54
+ ):
55
+ raise ConfigError(
56
+ "No API key configured. Run `splitagent config setup` to choose a "
57
+ "provider and model, or set the provider API key environment variable."
58
+ )
59
+ if not self.global_config.llm.model or not self.global_config.llm.base_url:
60
+ raise ConfigError("LLM model/base_url missing. Run `splitagent config setup`.")
61
+
62
+ def _configure_context(self) -> None:
63
+ """Point the shared context at the active model and the workspace."""
64
+ from splitagent.agents.red import build_policy
65
+ from splitagent.core.workspace import workspace_for
66
+
67
+ self.context.policy = build_policy(self.project, self.global_config.llm)
68
+ workspace = workspace_for(self.project, base=Path.cwd())
69
+ workspace.ensure()
70
+ self.workspace = workspace
71
+ self.context.workspace = workspace
72
+
73
+ def _start_toolbox(self) -> None:
74
+ """Bring up the isolated toolbox when the mode calls for it."""
75
+ from splitagent.core.toolbox import Toolbox, resolve_mode
76
+
77
+ config = self.project.run.execution
78
+ toolbox = Toolbox(config, self.workspace.root if self.workspace else None)
79
+ status = toolbox.detect()
80
+ self.toolbox_status = status
81
+ effective = resolve_mode(config, status)
82
+ if effective != "toolbox":
83
+ if config.mode == "toolbox":
84
+ raise ConfigError(
85
+ "Toolbox mode is enabled but Docker is not available. Install "
86
+ "Docker or switch run.execution.mode to 'auto'."
87
+ )
88
+ self.toolbox = None
89
+ return
90
+ if not status.running:
91
+ result = toolbox.up()
92
+ if not result.get("ok"):
93
+ self.toolbox = None
94
+ self._toolbox_error = result.get("error", "unknown error")
95
+ if config.mode == "toolbox":
96
+ raise ConfigError(f"Could not start the toolbox: {result.get('error')}")
97
+ return
98
+ self.toolbox = toolbox
99
+
100
+ def _target_is_external(self) -> bool:
101
+ """True when the engagement points at a real, non-local server.
102
+
103
+ The sandbox exists to *provide* a disposable target. When the operator
104
+ has already given one - a domain, a public IP, a reachable host - the
105
+ sandbox would substitute the wrong thing: it would spin up Juice Shop
106
+ and audit that instead of the server they asked about.
107
+ """
108
+ host = (self.project.target.effective_hosts() or [""])[0].lower()
109
+ if not host:
110
+ return False
111
+ if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
112
+ return False
113
+ try:
114
+ import ipaddress
115
+
116
+ address = ipaddress.ip_address(host)
117
+ return not (address.is_loopback or address.is_private or address.is_link_local)
118
+ except ValueError:
119
+ pass
120
+ # A hostname that is not a loopback alias is an external target.
121
+ return not host.endswith(".local")
122
+
123
+ async def _start_sandbox(self) -> None:
124
+ if not self.project.run.sandbox.enabled:
125
+ await self.bus.emit(Event(type="log", agent="core", data={"text": "Sandbox disabled."}))
126
+ return
127
+ if self._target_is_external():
128
+ # Auditing a real server: never start the lab container.
129
+ self._sandbox_status = None
130
+ await self.bus.emit(
131
+ Event(
132
+ type="log",
133
+ agent="core",
134
+ data={
135
+ "text": (
136
+ f"Target {self.project.target.effective_hosts()[0]} is a real "
137
+ "server, so no sandbox is started. Disable run.sandbox to "
138
+ "silence this."
139
+ ),
140
+ "level": "info",
141
+ },
142
+ )
143
+ )
144
+ return
145
+ status = await self.sandbox.up()
146
+ self._sandbox_status = status
147
+ if status.running and status.url and not self.project.target.url:
148
+ self.project.target.url = status.url
149
+ # The context was built before the sandbox existed; keep the target
150
+ # the session records in sync with the URL we actually test.
151
+ self.context.state.target = status.url
152
+ await self.bus.emit(
153
+ Event(
154
+ type="log",
155
+ agent="core",
156
+ data={
157
+ "text": f"Sandbox: {status.message}"
158
+ + (f" -> {status.url}" if status.url else ""),
159
+ "level": "info" if status.available else "warning",
160
+ },
161
+ )
162
+ )
163
+
164
+ async def _stop_sandbox(self) -> None:
165
+ if self._sandbox_status and self._sandbox_status.running:
166
+ await self.sandbox.down()
167
+ await self.bus.emit(Event(type="log", agent="core", data={"text": "Sandbox removed."}))
168
+
169
+ def _agent_settings(self) -> dict[str, Any]:
170
+ return {
171
+ "sandbox": self.sandbox if self.project.run.sandbox.enabled else None,
172
+ "auth_headers": self.project.auth.as_headers(),
173
+ "workspace": self.context.workspace,
174
+ "project": self.project,
175
+ "toolbox": self.toolbox,
176
+ }
177
+
178
+ # -- main entry point -------------------------------------------------- #
179
+ async def run(self) -> SharedContext:
180
+ self._require_llm()
181
+ self._configure_context()
182
+ await self.bus.emit(
183
+ Event(
184
+ type="workspace",
185
+ agent="core",
186
+ data={
187
+ "root": str(self.workspace.root),
188
+ "installers": __import__(
189
+ "splitagent.core.workspace", fromlist=["available_installers"]
190
+ ).available_installers(),
191
+ "allow_install": self.project.workspace.allow_install,
192
+ "allow_external_tools": self.project.workspace.allow_external_tools,
193
+ "file_counts": self.workspace.stats(),
194
+ },
195
+ )
196
+ )
197
+ await self.bus.emit(
198
+ Event(
199
+ type="context.policy",
200
+ agent="core",
201
+ data={
202
+ "context_limit": self.context.policy.context_limit,
203
+ "output_token_max": self.context.policy.output_token_max,
204
+ "usable": __import__(
205
+ "splitagent.core.context_manager", fromlist=["usable"]
206
+ ).usable(self.context.policy),
207
+ "prune": self.context.policy.prune,
208
+ "auto_compact": self.context.policy.auto,
209
+ },
210
+ )
211
+ )
212
+ await self.bus.emit(
213
+ Event(
214
+ type="session.start",
215
+ agent="core",
216
+ data={
217
+ "session": self.context.state.id,
218
+ "target": self.context.state.target,
219
+ "model": self.global_config.llm.model,
220
+ "provider": self.global_config.llm.provider,
221
+ "rounds": self.project.run.rounds,
222
+ },
223
+ )
224
+ )
225
+
226
+ try:
227
+ self._start_toolbox()
228
+ await self.bus.emit(
229
+ Event(
230
+ type="toolbox",
231
+ agent="core",
232
+ data={
233
+ "mode": self.project.run.execution.mode,
234
+ "edition": self.project.run.execution.edition,
235
+ "active": self.toolbox is not None,
236
+ "error": self._toolbox_error,
237
+ "status": self.toolbox_status.to_dict() if self.toolbox_status else {},
238
+ },
239
+ )
240
+ )
241
+ await self._start_sandbox()
242
+ async with LLMClient(self.global_config.llm) as client:
243
+ await self._run_with_deadline(client)
244
+ finally:
245
+ from splitagent.tools.http_pool import aclose_all
246
+
247
+ await aclose_all()
248
+ await self._stop_sandbox()
249
+ self.context.save()
250
+ await self.bus.emit(
251
+ Event(
252
+ type="session.end",
253
+ agent="core",
254
+ data=self.context.summary_dict(),
255
+ )
256
+ )
257
+ return self.context
258
+
259
+ async def _run_with_deadline(self, client: LLMClient) -> None:
260
+ """Run the rounds under a wall-clock ceiling.
261
+
262
+ An open-ended engagement can hang for hours on a slow target or a
263
+ provider that never answers. Hitting the deadline stops cleanly with
264
+ everything already persisted, instead of being killed by the operator.
265
+ """
266
+ minutes = max(0, int(self.project.run.max_duration_minutes))
267
+ if minutes <= 0:
268
+ await self._run_rounds(client)
269
+ return
270
+ try:
271
+ await asyncio.wait_for(self._run_rounds(client), timeout=minutes * 60)
272
+ except asyncio.TimeoutError:
273
+ await self.bus.emit(
274
+ Event(
275
+ type="error",
276
+ agent="core",
277
+ data={
278
+ "text": (
279
+ f"Run stopped: the {minutes}-minute limit was reached. "
280
+ "Everything confirmed so far is saved - raise "
281
+ "run.max_duration_minutes to go further."
282
+ )
283
+ },
284
+ )
285
+ )
286
+ self.context.state.notes.append(f"Stopped at the {minutes}-minute deadline.")
287
+
288
+ async def _run_rounds(self, client: LLMClient) -> None:
289
+ total = max(1, self.project.run.rounds)
290
+ previous_blue_summary = ""
291
+ for index in range(1, total + 1):
292
+ round_ = self.context.begin_round(index)
293
+ await self.bus.emit(
294
+ Event(type="round.start", agent="core", data={"round": index, "total": total})
295
+ )
296
+
297
+ if self.project.agents.red.enabled:
298
+ red_summary = await self._run_red(client, index, total, previous_blue_summary)
299
+ round_.red_summary = red_summary
300
+ round_.finding_ids = [f.id for f in self.context.state.findings if f.round == index]
301
+ else:
302
+ red_summary = "(red agent disabled)"
303
+
304
+ if self.project.agents.blue.enabled:
305
+ blue_summary = await self._run_blue(client, index, total, red_summary)
306
+ round_.blue_summary = blue_summary
307
+ round_.mitigation_ids = [
308
+ m.id for m in self.context.state.mitigations if m.round == index
309
+ ]
310
+ else:
311
+ blue_summary = "(blue agent disabled)"
312
+
313
+ previous_blue_summary = blue_summary
314
+ self.context.end_round(index, red_summary, blue_summary)
315
+ self.context.save()
316
+ await self.bus.emit(
317
+ Event(
318
+ type="round.end",
319
+ agent="core",
320
+ data={
321
+ "round": index,
322
+ "findings": len(self.context.state.findings),
323
+ "mitigations": len(self.context.state.mitigations),
324
+ "resilience": self.context.state.resilience_score(),
325
+ },
326
+ )
327
+ )
328
+
329
+ self.context.state.ended_at = _now()
330
+
331
+ async def _run_red(self, client: LLMClient, index: int, total: int, previous_blue: str) -> str:
332
+ from splitagent.agents.red import RedAgent
333
+
334
+ agent = RedAgent(
335
+ client=client,
336
+ context=self.context,
337
+ project=self.project,
338
+ bus=self.bus,
339
+ round_index=index,
340
+ total_rounds=total,
341
+ settings=self._agent_settings(),
342
+ )
343
+ await self.bus.emit(
344
+ Event(type="phase.start", agent="red", data={"round": index, "phase": "offense"})
345
+ )
346
+ task = (
347
+ f"Round {index}/{total}. Perform reconnaissance and controlled "
348
+ "exploitation against the target, then persist every confirmed "
349
+ "finding with evidence."
350
+ )
351
+ if previous_blue:
352
+ task += (
353
+ "\n\nDefences applied by the Blue Agent in the previous round "
354
+ "(test whether they hold):\n" + previous_blue
355
+ )
356
+ result = await agent.run(task)
357
+ self._accumulate_usage(result.usage)
358
+ await self.context.add_trace("red", result.trace)
359
+ await self.bus.emit(
360
+ Event(
361
+ type="phase.end",
362
+ agent="red",
363
+ data={
364
+ "round": index,
365
+ "phase": "offense",
366
+ "summary": result.text,
367
+ "tool_calls": result.tool_calls,
368
+ "pruned": result.pruned,
369
+ "compactions": result.compactions,
370
+ },
371
+ )
372
+ )
373
+ return result.text
374
+
375
+ async def _run_blue(self, client: LLMClient, index: int, total: int, red_summary: str) -> str:
376
+ from splitagent.agents.blue import BlueAgent
377
+
378
+ agent = BlueAgent(
379
+ client=client,
380
+ context=self.context,
381
+ project=self.project,
382
+ bus=self.bus,
383
+ round_index=index,
384
+ total_rounds=total,
385
+ settings=self._agent_settings(),
386
+ )
387
+ await self.bus.emit(
388
+ Event(type="phase.start", agent="blue", data={"round": index, "phase": "defense"})
389
+ )
390
+ task = (
391
+ f"Round {index}/{total}. The Red Agent just reported:\n\n"
392
+ f"{red_summary}\n\n"
393
+ "Triage telemetry, produce countermeasures for the open findings and "
394
+ "verify them where possible."
395
+ )
396
+ result = await agent.run(task)
397
+ self._accumulate_usage(result.usage)
398
+ await self.context.add_trace("blue", result.trace)
399
+ await self.bus.emit(
400
+ Event(
401
+ type="phase.end",
402
+ agent="blue",
403
+ data={
404
+ "round": index,
405
+ "phase": "defense",
406
+ "summary": result.text,
407
+ "tool_calls": result.tool_calls,
408
+ "pruned": result.pruned,
409
+ "compactions": result.compactions,
410
+ },
411
+ )
412
+ )
413
+ return result.text
414
+
415
+ def _accumulate_usage(self, usage: dict[str, Any]) -> None:
416
+ for key, value in usage.items():
417
+ if isinstance(value, int):
418
+ self.context.state.usage[key] = self.context.state.usage.get(key, 0) + value
419
+
420
+
421
+ def _now() -> str:
422
+ from datetime import datetime, timezone
423
+
424
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")