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/config.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
"""Configuration handling for SplitAgent.
|
|
2
|
+
|
|
3
|
+
Two layers of configuration exist:
|
|
4
|
+
|
|
5
|
+
* **Global** (``~/.splitagent/config.yaml`` on POSIX, ``%APPDATA%/splitagent``
|
|
6
|
+
on Windows) stores the LLM provider/API credentials together with UI and
|
|
7
|
+
default sandbox preferences. This is what the user edits from inside the
|
|
8
|
+
program with ``splitagent config``.
|
|
9
|
+
* **Project** (``splitagent.yaml`` in the working directory) describes the
|
|
10
|
+
target, the number of Red/Blue rounds and the report preferences.
|
|
11
|
+
|
|
12
|
+
Only the global layer holds secrets. Project files are safe to commit.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from splitagent.errors import ConfigError
|
|
25
|
+
|
|
26
|
+
APP_NAME = "splitagent"
|
|
27
|
+
GLOBAL_ENV = "SPLITAGENT_HOME"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------------------- #
|
|
31
|
+
# Paths
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
def config_home() -> Path:
|
|
34
|
+
"""Return the directory holding global SplitAgent state."""
|
|
35
|
+
override = os.environ.get(GLOBAL_ENV)
|
|
36
|
+
if override:
|
|
37
|
+
return Path(override).expanduser()
|
|
38
|
+
if os.name == "nt":
|
|
39
|
+
base = os.environ.get("APPDATA") or str(Path.home())
|
|
40
|
+
return Path(base) / APP_NAME
|
|
41
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
42
|
+
return Path(base) / APP_NAME
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def global_config_path() -> Path:
|
|
46
|
+
return config_home() / "config.yaml"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def global_key_path() -> Path:
|
|
50
|
+
return config_home() / "key.bin"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sessions_dir() -> Path:
|
|
54
|
+
return config_home() / "sessions"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ensure_home() -> Path:
|
|
58
|
+
home = config_home()
|
|
59
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
sessions_dir().mkdir(parents=True, exist_ok=True)
|
|
61
|
+
return home
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def project_config_path(start: Path | None = None) -> Path:
|
|
65
|
+
start = (start or Path.cwd()).resolve()
|
|
66
|
+
for candidate_dir in [start, *start.parents]:
|
|
67
|
+
candidate = candidate_dir / "splitagent.yaml"
|
|
68
|
+
if candidate.exists():
|
|
69
|
+
return candidate
|
|
70
|
+
candidate = candidate_dir / "splitagent.yml"
|
|
71
|
+
if candidate.exists():
|
|
72
|
+
return candidate
|
|
73
|
+
return start / "splitagent.yaml"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
# Provider presets
|
|
78
|
+
# --------------------------------------------------------------------------- #
|
|
79
|
+
PROVIDER_PRESETS: dict[str, dict[str, str]] = {
|
|
80
|
+
"openai": {
|
|
81
|
+
"protocol": "openai",
|
|
82
|
+
"base_url": "https://api.openai.com/v1",
|
|
83
|
+
"model": "gpt-4o-mini",
|
|
84
|
+
},
|
|
85
|
+
"opencode-go": {
|
|
86
|
+
"protocol": "openai",
|
|
87
|
+
"base_url": "https://opencode.ai/zen/go/v1",
|
|
88
|
+
"model": "deepseek-v4.1-flash",
|
|
89
|
+
},
|
|
90
|
+
"opencode": {
|
|
91
|
+
"protocol": "openai",
|
|
92
|
+
"base_url": "https://opencode.ai/zen/v1",
|
|
93
|
+
"model": "deepseek-v4-flash",
|
|
94
|
+
},
|
|
95
|
+
"openrouter": {
|
|
96
|
+
"protocol": "openai",
|
|
97
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
98
|
+
"model": "anthropic/claude-3.5-sonnet",
|
|
99
|
+
},
|
|
100
|
+
"anthropic": {
|
|
101
|
+
"protocol": "anthropic",
|
|
102
|
+
"base_url": "https://api.anthropic.com/v1",
|
|
103
|
+
"model": "claude-3-5-sonnet-latest",
|
|
104
|
+
},
|
|
105
|
+
"groq": {
|
|
106
|
+
"protocol": "openai",
|
|
107
|
+
"base_url": "https://api.groq.com/openai/v1",
|
|
108
|
+
"model": "llama-3.3-70b-versatile",
|
|
109
|
+
},
|
|
110
|
+
"deepseek": {
|
|
111
|
+
"protocol": "openai",
|
|
112
|
+
"base_url": "https://api.deepseek.com/v1",
|
|
113
|
+
"model": "deepseek-chat",
|
|
114
|
+
},
|
|
115
|
+
"together": {
|
|
116
|
+
"protocol": "openai",
|
|
117
|
+
"base_url": "https://api.together.xyz/v1",
|
|
118
|
+
"model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
|
119
|
+
},
|
|
120
|
+
"mistral": {
|
|
121
|
+
"protocol": "openai",
|
|
122
|
+
"base_url": "https://api.mistral.ai/v1",
|
|
123
|
+
"model": "mistral-large-latest",
|
|
124
|
+
},
|
|
125
|
+
"xai": {
|
|
126
|
+
"protocol": "openai",
|
|
127
|
+
"base_url": "https://api.x.ai/v1",
|
|
128
|
+
"model": "grok-2-latest",
|
|
129
|
+
},
|
|
130
|
+
"ollama": {
|
|
131
|
+
"protocol": "openai",
|
|
132
|
+
"base_url": "http://localhost:11434/v1",
|
|
133
|
+
"model": "llama3.1",
|
|
134
|
+
},
|
|
135
|
+
"lmstudio": {
|
|
136
|
+
"protocol": "openai",
|
|
137
|
+
"base_url": "http://localhost:1234/v1",
|
|
138
|
+
"model": "local-model",
|
|
139
|
+
},
|
|
140
|
+
"vllm": {
|
|
141
|
+
"protocol": "openai",
|
|
142
|
+
"base_url": "http://localhost:8000/v1",
|
|
143
|
+
"model": "local-model",
|
|
144
|
+
},
|
|
145
|
+
"custom": {
|
|
146
|
+
"protocol": "openai",
|
|
147
|
+
"base_url": "http://localhost:8000/v1",
|
|
148
|
+
"model": "custom-model",
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# Local / self-hosted providers that legitimately run without an API key.
|
|
153
|
+
KEYLESS_PROVIDERS = frozenset({"ollama", "lmstudio", "vllm", "custom"})
|
|
154
|
+
|
|
155
|
+
API_KEY_ENV = {
|
|
156
|
+
"openai": ("OPENAI_API_KEY",),
|
|
157
|
+
"opencode-go": ("OPENCODE_API_KEY", "OPENCODE_GO_API_KEY"),
|
|
158
|
+
"opencode": ("OPENCODE_API_KEY",),
|
|
159
|
+
"openrouter": ("OPENROUTER_API_KEY",),
|
|
160
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
161
|
+
"groq": ("GROQ_API_KEY",),
|
|
162
|
+
"deepseek": ("DEEPSEEK_API_KEY",),
|
|
163
|
+
"together": ("TOGETHER_API_KEY",),
|
|
164
|
+
"mistral": ("MISTRAL_API_KEY",),
|
|
165
|
+
"xai": ("XAI_API_KEY",),
|
|
166
|
+
"custom": ("SPLITAGENT_API_KEY",),
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
# Dataclasses
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
@dataclass
|
|
174
|
+
class LLMSettings:
|
|
175
|
+
"""Credentials and behaviour for the model API used by both agents."""
|
|
176
|
+
|
|
177
|
+
provider: str = "openai"
|
|
178
|
+
protocol: str = "openai" # openai | anthropic
|
|
179
|
+
base_url: str = "https://api.openai.com/v1"
|
|
180
|
+
api_key: str = ""
|
|
181
|
+
model: str = "gpt-4o-mini"
|
|
182
|
+
temperature: float = 0.2
|
|
183
|
+
max_tokens: int = 4096
|
|
184
|
+
timeout: int = 120
|
|
185
|
+
stream: bool = True
|
|
186
|
+
# Transient-failure handling. 0 retries disables the retry loop.
|
|
187
|
+
max_retries: int = 3
|
|
188
|
+
retry_initial_delay: float = 1.0
|
|
189
|
+
retry_max_delay: float = 30.0
|
|
190
|
+
session_id: str = ""
|
|
191
|
+
user_agent: str = ""
|
|
192
|
+
# Prompt caching: reuse the stable prefix instead of paying for it every
|
|
193
|
+
# turn. Anthropic/Bedrock/OpenRouter take an explicit marker; OpenAI and
|
|
194
|
+
# OpenAI-compatible gateways cache automatically when the prefix is stable.
|
|
195
|
+
prompt_cache: bool = True
|
|
196
|
+
cache_system_messages: int = 2
|
|
197
|
+
cache_tail_messages: int = 2
|
|
198
|
+
extra_headers: dict[str, str] = field(default_factory=dict)
|
|
199
|
+
|
|
200
|
+
def resolved_api_key(self) -> str:
|
|
201
|
+
if self.api_key:
|
|
202
|
+
return self.api_key
|
|
203
|
+
for env_name in (
|
|
204
|
+
*API_KEY_ENV.get(self.provider, ()),
|
|
205
|
+
"SPLITAGENT_API_KEY",
|
|
206
|
+
"LLM_API_KEY",
|
|
207
|
+
):
|
|
208
|
+
value = os.environ.get(env_name)
|
|
209
|
+
if value:
|
|
210
|
+
return value
|
|
211
|
+
return ""
|
|
212
|
+
|
|
213
|
+
def redacted(self) -> dict[str, Any]:
|
|
214
|
+
data = asdict(self)
|
|
215
|
+
if data.get("api_key"):
|
|
216
|
+
data["api_key"] = "***"
|
|
217
|
+
return data
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@dataclass
|
|
221
|
+
class ModelSpec:
|
|
222
|
+
"""Context/output limits for a model, used by the context manager."""
|
|
223
|
+
|
|
224
|
+
context: int = 128_000
|
|
225
|
+
output: int = 32_000
|
|
226
|
+
|
|
227
|
+
def to_dict(self) -> dict[str, int]:
|
|
228
|
+
return {"context": self.context, "output": self.output}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Known limits; anything else falls back to a conservative default.
|
|
232
|
+
KNOWN_MODEL_LIMITS: dict[str, dict[str, int]] = {
|
|
233
|
+
"deepseek-v4.1-flash": {"context": 1_000_000, "output": 384_000},
|
|
234
|
+
"deepseek-v4-pro": {"context": 1_000_000, "output": 384_000},
|
|
235
|
+
"deepseek-v4-flash": {"context": 1_000_000, "output": 384_000},
|
|
236
|
+
"deepseek-v4-flash-vision-exp": {"context": 1_000_000, "output": 384_000},
|
|
237
|
+
"deepseek-chat": {"context": 128_000, "output": 8_192},
|
|
238
|
+
"gpt-4o": {"context": 128_000, "output": 16_384},
|
|
239
|
+
"gpt-4o-mini": {"context": 128_000, "output": 16_384},
|
|
240
|
+
"claude-3-5-sonnet-latest": {"context": 200_000, "output": 8_192},
|
|
241
|
+
"llama3.1": {"context": 128_000, "output": 8_192},
|
|
242
|
+
"llama-3.3-70b-versatile": {"context": 128_000, "output": 32_768},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def model_spec(model_id: str) -> ModelSpec:
|
|
247
|
+
limits = KNOWN_MODEL_LIMITS.get(model_id)
|
|
248
|
+
if limits:
|
|
249
|
+
return ModelSpec(context=limits["context"], output=limits["output"])
|
|
250
|
+
lowered = model_id.lower()
|
|
251
|
+
if "flash" in lowered or "mini" in lowered:
|
|
252
|
+
return ModelSpec(context=128_000, output=16_384)
|
|
253
|
+
if "pro" in lowered or "sonnet" in lowered:
|
|
254
|
+
return ModelSpec(context=200_000, output=32_000)
|
|
255
|
+
return ModelSpec()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass
|
|
259
|
+
class ProviderConfig:
|
|
260
|
+
"""A configured (connected) provider and its model visibility."""
|
|
261
|
+
|
|
262
|
+
id: str = ""
|
|
263
|
+
base_url: str = ""
|
|
264
|
+
protocol: str = "openai"
|
|
265
|
+
api_key: str = ""
|
|
266
|
+
enabled: bool = True
|
|
267
|
+
models: dict[str, str] = field(default_factory=dict) # model id -> display name
|
|
268
|
+
hidden: list[str] = field(default_factory=list) # model ids toggled off
|
|
269
|
+
limits: dict[str, dict[str, int]] = field(default_factory=dict) # model -> spec
|
|
270
|
+
|
|
271
|
+
def display_name(self, model_id: str) -> str:
|
|
272
|
+
return self.models.get(model_id, model_id)
|
|
273
|
+
|
|
274
|
+
def spec(self, model_id: str) -> ModelSpec:
|
|
275
|
+
"""Context/output limits for a model served by this provider."""
|
|
276
|
+
limits = self.limits.get(model_id)
|
|
277
|
+
if limits:
|
|
278
|
+
return ModelSpec(
|
|
279
|
+
context=int(limits.get("context", 128_000)),
|
|
280
|
+
output=int(limits.get("output", 32_000)),
|
|
281
|
+
)
|
|
282
|
+
return model_spec(model_id)
|
|
283
|
+
|
|
284
|
+
def visible(self, model_id: str) -> bool:
|
|
285
|
+
return self.enabled and model_id not in self.hidden
|
|
286
|
+
|
|
287
|
+
def redacted(self) -> dict[str, Any]:
|
|
288
|
+
data = asdict(self)
|
|
289
|
+
if data.get("api_key"):
|
|
290
|
+
data["api_key"] = "***"
|
|
291
|
+
return data
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@dataclass
|
|
295
|
+
class UISettings:
|
|
296
|
+
theme: str = "opencode"
|
|
297
|
+
show_thinking: bool = True
|
|
298
|
+
refresh_per_second: int = 12
|
|
299
|
+
# Counter shown on the dashboard. It no longer drives an interface
|
|
300
|
+
# heuristic: there is a single interface.
|
|
301
|
+
audits_completed: int = 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class SandboxDefaults:
|
|
306
|
+
engine: str = "docker"
|
|
307
|
+
image: str = "bkimminich/juice-shop:latest"
|
|
308
|
+
network: str = "splitagent-net"
|
|
309
|
+
auto_remove: bool = True
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass
|
|
313
|
+
class GlobalConfig:
|
|
314
|
+
llm: LLMSettings = field(default_factory=LLMSettings)
|
|
315
|
+
ui: UISettings = field(default_factory=UISettings)
|
|
316
|
+
sandbox: SandboxDefaults = field(default_factory=SandboxDefaults)
|
|
317
|
+
providers: dict[str, ProviderConfig] = field(default_factory=dict)
|
|
318
|
+
authorized: bool = False # the user accepted the responsible-use notice
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def configured(self) -> bool:
|
|
322
|
+
has_key = bool(self.llm.resolved_api_key()) or self.llm.provider in KEYLESS_PROVIDERS
|
|
323
|
+
return bool(self.llm.model and self.llm.base_url and has_key)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class TargetConfig:
|
|
328
|
+
kind: str = "web"
|
|
329
|
+
url: str = ""
|
|
330
|
+
hosts: list[str] = field(default_factory=list)
|
|
331
|
+
ports: list[int] = field(default_factory=list)
|
|
332
|
+
scope: list[str] = field(default_factory=list)
|
|
333
|
+
out_of_scope: list[str] = field(default_factory=list)
|
|
334
|
+
|
|
335
|
+
def effective_hosts(self) -> list[str]:
|
|
336
|
+
hosts = list(self.hosts)
|
|
337
|
+
if self.url:
|
|
338
|
+
try:
|
|
339
|
+
from urllib.parse import urlparse
|
|
340
|
+
|
|
341
|
+
host = urlparse(self.url).hostname
|
|
342
|
+
if host and host not in hosts:
|
|
343
|
+
hosts.append(host)
|
|
344
|
+
except ValueError:
|
|
345
|
+
pass
|
|
346
|
+
return hosts
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass
|
|
350
|
+
class SandboxConfig:
|
|
351
|
+
enabled: bool = True
|
|
352
|
+
engine: str = "docker"
|
|
353
|
+
image: str = "bkimminich/juice-shop:latest"
|
|
354
|
+
network: str = "splitagent-net"
|
|
355
|
+
port_map: dict[str, int] = field(default_factory=dict)
|
|
356
|
+
auto_remove: bool = True
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass
|
|
360
|
+
class AuthConfig:
|
|
361
|
+
"""Optional credentials handed to the agents for authenticated testing."""
|
|
362
|
+
|
|
363
|
+
username: str = ""
|
|
364
|
+
password: str = ""
|
|
365
|
+
token: str = ""
|
|
366
|
+
cookies: str = ""
|
|
367
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
368
|
+
|
|
369
|
+
def as_headers(self) -> dict[str, str]:
|
|
370
|
+
headers = dict(self.headers or {})
|
|
371
|
+
if self.token and "Authorization" not in headers:
|
|
372
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
373
|
+
if self.cookies:
|
|
374
|
+
headers["Cookie"] = self.cookies
|
|
375
|
+
return headers
|
|
376
|
+
|
|
377
|
+
def describe(self) -> str:
|
|
378
|
+
parts: list[str] = []
|
|
379
|
+
if self.username:
|
|
380
|
+
parts.append(f"username={self.username}")
|
|
381
|
+
parts.append("password=(provided)")
|
|
382
|
+
if self.token:
|
|
383
|
+
parts.append("bearer token (provided)")
|
|
384
|
+
if self.cookies:
|
|
385
|
+
parts.append("cookies (provided)")
|
|
386
|
+
if self.headers:
|
|
387
|
+
parts.append("custom headers: " + ", ".join(sorted(self.headers)))
|
|
388
|
+
return ", ".join(parts) if parts else "(none)"
|
|
389
|
+
|
|
390
|
+
def redacted(self) -> dict[str, Any]:
|
|
391
|
+
"""A copy safe to hand to the renderer: secrets are masked."""
|
|
392
|
+
data = asdict(self)
|
|
393
|
+
for key in ("password", "token", "cookies"):
|
|
394
|
+
if data.get(key):
|
|
395
|
+
data[key] = "***"
|
|
396
|
+
data["headers"] = dict.fromkeys(self.headers or {}, "***")
|
|
397
|
+
data["has_password"] = bool(self.password)
|
|
398
|
+
data["has_token"] = bool(self.token)
|
|
399
|
+
data["has_cookies"] = bool(self.cookies)
|
|
400
|
+
return data
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@dataclass
|
|
404
|
+
class ExecutionConfig:
|
|
405
|
+
"""Where the agents install and run their tooling.
|
|
406
|
+
|
|
407
|
+
``toolbox`` runs everything inside a disposable Docker container that has
|
|
408
|
+
the security toolset preinstalled, so the host is never modified. ``local``
|
|
409
|
+
runs on the host (the legacy behaviour). ``auto`` prefers the toolbox and
|
|
410
|
+
falls back to local when Docker is unavailable.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
mode: str = "auto" # auto | toolbox | local
|
|
414
|
+
edition: str = "standard" # standard | kali
|
|
415
|
+
image: str = "splitagent-toolbox:latest"
|
|
416
|
+
container: str = "splitagent-toolbox"
|
|
417
|
+
network: str = "splitagent-net"
|
|
418
|
+
network_mode: str = "bridge" # bridge | host
|
|
419
|
+
auto_start: bool = True # start Docker Desktop / the container silently
|
|
420
|
+
installed: bool = False # the operator approved building the image once
|
|
421
|
+
allow_install: bool = True # allow pip/apt/go installs inside the toolbox
|
|
422
|
+
cpus: str = ""
|
|
423
|
+
memory: str = ""
|
|
424
|
+
keep_alive_minutes: int = 120
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@dataclass
|
|
428
|
+
class CompactionConfig:
|
|
429
|
+
"""Context-window management, mirroring OpenCode's ``compaction`` block."""
|
|
430
|
+
|
|
431
|
+
auto: bool = True
|
|
432
|
+
prune: bool = True
|
|
433
|
+
# Per-request reduction: drop settled reasoning, blank superseded snapshots.
|
|
434
|
+
optimize: bool = True
|
|
435
|
+
# Reasoning turns to keep in full; older ones are thought, not context.
|
|
436
|
+
keep_reasoning_steps: int = 1
|
|
437
|
+
reserved: int | None = None
|
|
438
|
+
preserve_recent_tokens: int | None = None
|
|
439
|
+
tail_turns: int | None = None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@dataclass
|
|
443
|
+
class RunConfig:
|
|
444
|
+
rounds: int = 3
|
|
445
|
+
max_steps: int = 12
|
|
446
|
+
safe_mode: bool = True
|
|
447
|
+
allow_network: bool = False
|
|
448
|
+
# Independent tool calls inside one step run concurrently, bounded by this.
|
|
449
|
+
tool_concurrency: int = 4
|
|
450
|
+
# Hard ceiling for the whole session. 0 disables the deadline.
|
|
451
|
+
max_duration_minutes: int = 90
|
|
452
|
+
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
|
|
453
|
+
compaction: CompactionConfig = field(default_factory=CompactionConfig)
|
|
454
|
+
execution: ExecutionConfig = field(default_factory=ExecutionConfig)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@dataclass
|
|
458
|
+
class AgentSettings:
|
|
459
|
+
enabled: bool = True
|
|
460
|
+
temperature: float | None = None
|
|
461
|
+
# Steps reserved at the end of a turn for persisting findings and
|
|
462
|
+
# summarising. 0 disables the forced wrap-up.
|
|
463
|
+
wrap_up_at: int = 3
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@dataclass
|
|
467
|
+
class AgentsConfig:
|
|
468
|
+
red: AgentSettings = field(default_factory=AgentSettings)
|
|
469
|
+
blue: AgentSettings = field(default_factory=AgentSettings)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@dataclass
|
|
473
|
+
class ReportSettings:
|
|
474
|
+
formats: list[str] = field(default_factory=lambda: ["markdown", "html", "json"])
|
|
475
|
+
output_dir: str = "reports"
|
|
476
|
+
include_patches: bool = True
|
|
477
|
+
include_evidence: bool = True
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class WorkspaceConfig:
|
|
482
|
+
"""The agent's own working directory: tools, notes and recon data."""
|
|
483
|
+
|
|
484
|
+
path: str = "" # empty -> <project>/splitagent-workspace
|
|
485
|
+
allow_install: bool = True # let the agents install tooling
|
|
486
|
+
allow_external_tools: bool = True # allow tools outside the workspace
|
|
487
|
+
max_install_seconds: int = 900
|
|
488
|
+
instructions: str = "" # inline operator guidance
|
|
489
|
+
instruction_files: list[str] = field(default_factory=list)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@dataclass
|
|
493
|
+
class ProjectConfig:
|
|
494
|
+
name: str = "splitagent"
|
|
495
|
+
target: TargetConfig = field(default_factory=TargetConfig)
|
|
496
|
+
run: RunConfig = field(default_factory=RunConfig)
|
|
497
|
+
auth: AuthConfig = field(default_factory=AuthConfig)
|
|
498
|
+
workspace: WorkspaceConfig = field(default_factory=WorkspaceConfig)
|
|
499
|
+
agents: AgentsConfig = field(default_factory=AgentsConfig)
|
|
500
|
+
report: ReportSettings = field(default_factory=ReportSettings)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# --------------------------------------------------------------------------- #
|
|
504
|
+
# (De)serialisation helpers
|
|
505
|
+
# --------------------------------------------------------------------------- #
|
|
506
|
+
def _dataclass_from_dict(cls: type, data: dict[str, Any]) -> Any:
|
|
507
|
+
if not isinstance(data, dict):
|
|
508
|
+
return cls()
|
|
509
|
+
kwargs: dict[str, Any] = {}
|
|
510
|
+
known = {f.name: f for f in fields(cls)}
|
|
511
|
+
for name, spec in known.items():
|
|
512
|
+
if name not in data:
|
|
513
|
+
continue
|
|
514
|
+
value = data[name]
|
|
515
|
+
type_name = getattr(spec.type, "__name__", str(spec.type))
|
|
516
|
+
if type_name == "LLMSettings":
|
|
517
|
+
kwargs[name] = _dataclass_from_dict(LLMSettings, value)
|
|
518
|
+
elif type_name == "UISettings":
|
|
519
|
+
kwargs[name] = _dataclass_from_dict(UISettings, value)
|
|
520
|
+
elif type_name == "SandboxDefaults":
|
|
521
|
+
kwargs[name] = _dataclass_from_dict(SandboxDefaults, value)
|
|
522
|
+
elif type_name == "TargetConfig":
|
|
523
|
+
kwargs[name] = _dataclass_from_dict(TargetConfig, value)
|
|
524
|
+
elif type_name == "RunConfig":
|
|
525
|
+
kwargs[name] = _dataclass_from_dict(RunConfig, value)
|
|
526
|
+
elif type_name == "AuthConfig":
|
|
527
|
+
kwargs[name] = _dataclass_from_dict(AuthConfig, value)
|
|
528
|
+
elif type_name == "WorkspaceConfig":
|
|
529
|
+
kwargs[name] = _dataclass_from_dict(WorkspaceConfig, value)
|
|
530
|
+
elif type_name == "SandboxConfig":
|
|
531
|
+
kwargs[name] = _dataclass_from_dict(SandboxConfig, value)
|
|
532
|
+
elif type_name == "CompactionConfig":
|
|
533
|
+
kwargs[name] = _dataclass_from_dict(CompactionConfig, value)
|
|
534
|
+
elif type_name == "ExecutionConfig":
|
|
535
|
+
kwargs[name] = _dataclass_from_dict(ExecutionConfig, value)
|
|
536
|
+
elif type_name == "AgentsConfig":
|
|
537
|
+
kwargs[name] = _dataclass_from_dict(AgentsConfig, value)
|
|
538
|
+
elif type_name == "AgentSettings":
|
|
539
|
+
kwargs[name] = _dataclass_from_dict(AgentSettings, value)
|
|
540
|
+
elif type_name == "ReportSettings":
|
|
541
|
+
kwargs[name] = _dataclass_from_dict(ReportSettings, value)
|
|
542
|
+
else:
|
|
543
|
+
kwargs[name] = value
|
|
544
|
+
return cls(**kwargs)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
548
|
+
if not path.exists():
|
|
549
|
+
return {}
|
|
550
|
+
try:
|
|
551
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
552
|
+
except yaml.YAMLError as exc: # pragma: no cover - passthrough message
|
|
553
|
+
raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
|
|
554
|
+
if not isinstance(raw, dict):
|
|
555
|
+
raise ConfigError(f"Top level of {path} must be a mapping")
|
|
556
|
+
return raw
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# --------------------------------------------------------------------------- #
|
|
560
|
+
# Global config
|
|
561
|
+
# --------------------------------------------------------------------------- #
|
|
562
|
+
def load_global_config(path: Path | None = None) -> GlobalConfig:
|
|
563
|
+
path = path or global_config_path()
|
|
564
|
+
data = _load_yaml(path)
|
|
565
|
+
cfg = _dataclass_from_dict(GlobalConfig, data)
|
|
566
|
+
if cfg.llm.provider in PROVIDER_PRESETS and not data.get("llm", {}).get("base_url"):
|
|
567
|
+
preset = PROVIDER_PRESETS[cfg.llm.provider]
|
|
568
|
+
cfg.llm.base_url = preset["base_url"]
|
|
569
|
+
cfg.llm.protocol = preset.get("protocol", "openai")
|
|
570
|
+
|
|
571
|
+
raw_providers = data.get("providers")
|
|
572
|
+
cfg.providers = {}
|
|
573
|
+
if isinstance(raw_providers, dict):
|
|
574
|
+
for provider_id, payload in raw_providers.items():
|
|
575
|
+
if not isinstance(payload, dict):
|
|
576
|
+
continue
|
|
577
|
+
provider = _dataclass_from_dict(ProviderConfig, payload)
|
|
578
|
+
provider.id = provider.id or str(provider_id)
|
|
579
|
+
cfg.providers[provider.id] = provider
|
|
580
|
+
|
|
581
|
+
# Migrate a legacy single-key config into the providers map.
|
|
582
|
+
if not cfg.providers and cfg.llm.api_key:
|
|
583
|
+
preset = PROVIDER_PRESETS.get(cfg.llm.provider, {})
|
|
584
|
+
cfg.providers[cfg.llm.provider] = ProviderConfig(
|
|
585
|
+
id=cfg.llm.provider,
|
|
586
|
+
base_url=cfg.llm.base_url,
|
|
587
|
+
protocol=cfg.llm.protocol or preset.get("protocol", "openai"),
|
|
588
|
+
api_key=cfg.llm.api_key,
|
|
589
|
+
enabled=True,
|
|
590
|
+
)
|
|
591
|
+
return cfg
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def ensure_provider(config: GlobalConfig, provider_id: str) -> ProviderConfig:
|
|
595
|
+
"""Return the provider config, creating a preset-based one if missing."""
|
|
596
|
+
if provider_id not in config.providers:
|
|
597
|
+
preset = PROVIDER_PRESETS.get(provider_id, {})
|
|
598
|
+
config.providers[provider_id] = ProviderConfig(
|
|
599
|
+
id=provider_id,
|
|
600
|
+
base_url=preset.get("base_url", config.llm.base_url),
|
|
601
|
+
protocol=preset.get("protocol", "openai"),
|
|
602
|
+
)
|
|
603
|
+
return config.providers[provider_id]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def provider_visible(config: GlobalConfig, provider_id: str, model_id: str) -> bool:
|
|
607
|
+
provider = config.providers.get(provider_id)
|
|
608
|
+
if provider is None:
|
|
609
|
+
return False
|
|
610
|
+
return provider.visible(model_id)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def settings_for_provider(config: GlobalConfig, provider_id: str, model: str) -> LLMSettings:
|
|
614
|
+
"""Build LLM settings for a specific provider/model selection."""
|
|
615
|
+
settings = LLMSettings(**vars(config.llm))
|
|
616
|
+
provider = config.providers.get(provider_id)
|
|
617
|
+
preset = PROVIDER_PRESETS.get(provider_id, {})
|
|
618
|
+
settings.provider = provider_id
|
|
619
|
+
settings.model = model or settings.model
|
|
620
|
+
if provider is not None:
|
|
621
|
+
settings.base_url = provider.base_url or settings.base_url
|
|
622
|
+
settings.protocol = provider.protocol or preset.get("protocol", "openai")
|
|
623
|
+
settings.api_key = provider.api_key
|
|
624
|
+
else:
|
|
625
|
+
settings.base_url = preset.get("base_url", settings.base_url)
|
|
626
|
+
settings.protocol = preset.get("protocol", "openai")
|
|
627
|
+
return settings
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def save_global_config(cfg: GlobalConfig, path: Path | None = None) -> Path:
|
|
631
|
+
path = path or global_config_path()
|
|
632
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
633
|
+
data = asdict(cfg)
|
|
634
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
635
|
+
try:
|
|
636
|
+
os.chmod(path, 0o600)
|
|
637
|
+
except OSError: # pragma: no cover - best effort on Windows
|
|
638
|
+
pass
|
|
639
|
+
return path
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# --------------------------------------------------------------------------- #
|
|
643
|
+
# Project config
|
|
644
|
+
# --------------------------------------------------------------------------- #
|
|
645
|
+
def load_project_config(path: Path | None = None) -> ProjectConfig:
|
|
646
|
+
path = path or project_config_path()
|
|
647
|
+
data = _load_yaml(path)
|
|
648
|
+
raw_project = data.get("project")
|
|
649
|
+
project_block: dict[str, Any] = raw_project if isinstance(raw_project, dict) else {}
|
|
650
|
+
kwargs: dict[str, Any] = {}
|
|
651
|
+
if project_block.get("name"):
|
|
652
|
+
kwargs["name"] = project_block["name"]
|
|
653
|
+
if "target" in data:
|
|
654
|
+
kwargs["target"] = _dataclass_from_dict(TargetConfig, data["target"])
|
|
655
|
+
if "run" in data:
|
|
656
|
+
kwargs["run"] = _dataclass_from_dict(RunConfig, data["run"])
|
|
657
|
+
if "auth" in data:
|
|
658
|
+
kwargs["auth"] = _dataclass_from_dict(AuthConfig, data["auth"])
|
|
659
|
+
if "workspace" in data:
|
|
660
|
+
kwargs["workspace"] = _dataclass_from_dict(WorkspaceConfig, data["workspace"])
|
|
661
|
+
if "agents" in data:
|
|
662
|
+
kwargs["agents"] = _dataclass_from_dict(AgentsConfig, data["agents"])
|
|
663
|
+
if "report" in data:
|
|
664
|
+
kwargs["report"] = _dataclass_from_dict(ReportSettings, data["report"])
|
|
665
|
+
return ProjectConfig(**kwargs)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def save_project_config(cfg: ProjectConfig, path: Path | None = None) -> Path:
|
|
669
|
+
path = path or Path.cwd() / "splitagent.yaml"
|
|
670
|
+
data = {
|
|
671
|
+
"project": {"name": cfg.name},
|
|
672
|
+
"target": asdict(cfg.target),
|
|
673
|
+
"run": asdict(cfg.run),
|
|
674
|
+
"auth": asdict(cfg.auth),
|
|
675
|
+
"workspace": asdict(cfg.workspace),
|
|
676
|
+
"agents": asdict(cfg.agents),
|
|
677
|
+
"report": asdict(cfg.report),
|
|
678
|
+
}
|
|
679
|
+
path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
680
|
+
return path
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def default_project_config() -> ProjectConfig:
|
|
684
|
+
return ProjectConfig()
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def apply_provider_preset(llm: LLMSettings, provider: str) -> LLMSettings:
|
|
688
|
+
"""Update the LLM settings in place from a provider preset."""
|
|
689
|
+
preset = PROVIDER_PRESETS.get(provider)
|
|
690
|
+
if not preset:
|
|
691
|
+
raise ConfigError(f"Unknown provider '{provider}'. Known: {', '.join(PROVIDER_PRESETS)}")
|
|
692
|
+
llm.provider = provider
|
|
693
|
+
llm.protocol = preset.get("protocol", "openai")
|
|
694
|
+
llm.base_url = preset["base_url"]
|
|
695
|
+
if not llm.model or llm.model == LLMSettings().model:
|
|
696
|
+
llm.model = preset["model"]
|
|
697
|
+
return llm
|