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/llm/types.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Message and event types shared by the LLM clients and the agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ToolSpec:
|
|
12
|
+
"""A tool exposed to the model, in JSON-Schema form."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
parameters: dict[str, Any]
|
|
17
|
+
|
|
18
|
+
def to_openai(self) -> dict[str, Any]:
|
|
19
|
+
return {
|
|
20
|
+
"type": "function",
|
|
21
|
+
"function": {
|
|
22
|
+
"name": self.name,
|
|
23
|
+
"description": self.description,
|
|
24
|
+
"parameters": self.parameters,
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def to_anthropic(self) -> dict[str, Any]:
|
|
29
|
+
return {
|
|
30
|
+
"name": self.name,
|
|
31
|
+
"description": self.description,
|
|
32
|
+
"input_schema": self.parameters,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def estimate_tokens(text: str) -> int:
|
|
37
|
+
"""Character-based token estimate (OpenCode uses 4 chars per token)."""
|
|
38
|
+
return max(0, round(len(text or "") / 4))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class ToolCall:
|
|
43
|
+
"""A tool invocation requested by the model."""
|
|
44
|
+
|
|
45
|
+
id: str
|
|
46
|
+
name: str
|
|
47
|
+
arguments: str = "{}"
|
|
48
|
+
|
|
49
|
+
def parsed_arguments(self) -> dict[str, Any]:
|
|
50
|
+
if not self.arguments or not self.arguments.strip():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(self.arguments)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
# Some models stream partial or unwrapped JSON; try to salvage it.
|
|
56
|
+
try:
|
|
57
|
+
value = json.loads(self.arguments.strip().strip("`"))
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
return {"_raw": self.arguments}
|
|
60
|
+
if isinstance(value, dict):
|
|
61
|
+
return value
|
|
62
|
+
return {"_value": value}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ChatMessage:
|
|
67
|
+
"""A single conversation turn, provider agnostic."""
|
|
68
|
+
|
|
69
|
+
role: str # system | user | assistant | tool
|
|
70
|
+
content: str = ""
|
|
71
|
+
reasoning: str = ""
|
|
72
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
73
|
+
tool_call_id: str | None = None
|
|
74
|
+
name: str | None = None
|
|
75
|
+
compacted: bool = False
|
|
76
|
+
pinned: bool = False
|
|
77
|
+
# Mark this message as a prompt-cache checkpoint (prefix up to and
|
|
78
|
+
# including it can be reused by the provider).
|
|
79
|
+
cache: bool = False
|
|
80
|
+
|
|
81
|
+
def token_estimate(self) -> int:
|
|
82
|
+
total = estimate_tokens(self.content) + estimate_tokens(self.reasoning)
|
|
83
|
+
for call in self.tool_calls:
|
|
84
|
+
total += estimate_tokens(call.name) + estimate_tokens(call.arguments)
|
|
85
|
+
return total
|
|
86
|
+
|
|
87
|
+
def serialize(self) -> str:
|
|
88
|
+
parts: list[str] = []
|
|
89
|
+
if self.content:
|
|
90
|
+
parts.append(f"{self.role}: {self.content}")
|
|
91
|
+
if self.reasoning:
|
|
92
|
+
parts.append(f"{self.role} reasoning: {self.reasoning}")
|
|
93
|
+
for call in self.tool_calls:
|
|
94
|
+
parts.append(f"tool_call {call.name}({call.arguments})")
|
|
95
|
+
return "\n".join(parts)
|
|
96
|
+
|
|
97
|
+
def to_openai(self, cache: bool = False) -> dict[str, Any]:
|
|
98
|
+
if self.role == "tool":
|
|
99
|
+
return {
|
|
100
|
+
"role": "tool",
|
|
101
|
+
"tool_call_id": self.tool_call_id or "",
|
|
102
|
+
"content": self.content,
|
|
103
|
+
}
|
|
104
|
+
message: dict[str, Any] = {"role": self.role, "content": self.content}
|
|
105
|
+
# OpenAI-compatible providers do not take an explicit marker for plain
|
|
106
|
+
# string content; caching is automatic on a stable prefix. When the
|
|
107
|
+
# content is a block list (Anthropic-style) we can mark it though.
|
|
108
|
+
if cache and isinstance(message.get("content"), list):
|
|
109
|
+
for block in message["content"]:
|
|
110
|
+
if block.get("type") == "text":
|
|
111
|
+
block["cache_control"] = {"type": "ephemeral"}
|
|
112
|
+
# DeepSeek-style reasoning models require the reasoning echoed back.
|
|
113
|
+
if self.reasoning:
|
|
114
|
+
message["reasoning_content"] = self.reasoning
|
|
115
|
+
if self.tool_calls:
|
|
116
|
+
message["tool_calls"] = [
|
|
117
|
+
{
|
|
118
|
+
"id": call.id,
|
|
119
|
+
"type": "function",
|
|
120
|
+
"function": {"name": call.name, "arguments": call.arguments},
|
|
121
|
+
}
|
|
122
|
+
for call in self.tool_calls
|
|
123
|
+
]
|
|
124
|
+
if not self.content:
|
|
125
|
+
message["content"] = None
|
|
126
|
+
return message
|
|
127
|
+
|
|
128
|
+
def to_anthropic(self, cache: bool = False) -> dict[str, Any]:
|
|
129
|
+
marker = {"cache_control": {"type": "ephemeral"}} if cache else {}
|
|
130
|
+
if self.role == "tool":
|
|
131
|
+
block: dict[str, Any] = {
|
|
132
|
+
"type": "tool_result",
|
|
133
|
+
"tool_use_id": self.tool_call_id or "",
|
|
134
|
+
"content": self.content,
|
|
135
|
+
}
|
|
136
|
+
block.update(marker)
|
|
137
|
+
return {"role": "user", "content": [block]}
|
|
138
|
+
if self.role == "assistant" and self.tool_calls:
|
|
139
|
+
blocks: list[dict[str, Any]] = []
|
|
140
|
+
if self.content:
|
|
141
|
+
blocks.append({"type": "text", "text": self.content})
|
|
142
|
+
for call in self.tool_calls:
|
|
143
|
+
blocks.append(
|
|
144
|
+
{
|
|
145
|
+
"type": "tool_use",
|
|
146
|
+
"id": call.id,
|
|
147
|
+
"name": call.name,
|
|
148
|
+
"input": call.parsed_arguments(),
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
# A cache checkpoint goes on the last block of the message.
|
|
152
|
+
if marker and blocks:
|
|
153
|
+
blocks[-1] = {**blocks[-1], **marker}
|
|
154
|
+
return {"role": "assistant", "content": blocks}
|
|
155
|
+
if isinstance(self.content, str) and marker:
|
|
156
|
+
return {
|
|
157
|
+
"role": self.role,
|
|
158
|
+
"content": [{"type": "text", "text": self.content, **marker}],
|
|
159
|
+
}
|
|
160
|
+
return {"role": self.role, "content": self.content}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class LLMEvent:
|
|
165
|
+
"""An incremental event produced while streaming a completion."""
|
|
166
|
+
|
|
167
|
+
type: str # text | reasoning | tool_call | done | usage | retry | error
|
|
168
|
+
text: str = ""
|
|
169
|
+
tool_call: ToolCall | None = None
|
|
170
|
+
usage: dict[str, Any] | None = None
|
|
171
|
+
error: str | None = None
|
|
172
|
+
data: dict[str, Any] | None = None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""CVSS v3.1 base-score calculation.
|
|
2
|
+
|
|
3
|
+
Implements the official specification so findings recorded by the Red Agent
|
|
4
|
+
carry a defensible numeric score and severity label.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
AV = {"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}
|
|
14
|
+
AC = {"L": 0.77, "H": 0.44}
|
|
15
|
+
PR_UNCHANGED = {"N": 0.85, "L": 0.62, "H": 0.27}
|
|
16
|
+
PR_CHANGED = {"N": 0.85, "L": 0.68, "H": 0.5}
|
|
17
|
+
UI = {"N": 0.85, "R": 0.62}
|
|
18
|
+
CIA = {"H": 0.56, "L": 0.22, "N": 0.0}
|
|
19
|
+
|
|
20
|
+
VECTOR_RE = re.compile(r"CVSS:3\.[01]/(.+)", re.IGNORECASE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _roundup(value: float) -> float:
|
|
24
|
+
integer = round(value * 100000)
|
|
25
|
+
if integer % 10000 == 0:
|
|
26
|
+
return integer / 100000.0
|
|
27
|
+
return (math.floor(integer / 10000.0) + 1) / 10.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_metrics(vector: str) -> dict[str, str]:
|
|
31
|
+
match = VECTOR_RE.search(vector.strip())
|
|
32
|
+
body = match.group(1) if match else vector.strip()
|
|
33
|
+
metrics: dict[str, str] = {}
|
|
34
|
+
for part in body.split("/"):
|
|
35
|
+
if ":" in part:
|
|
36
|
+
key, value = part.split(":", 1)
|
|
37
|
+
metrics[key.strip().upper()] = value.strip().upper()
|
|
38
|
+
return metrics
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_vector(vector: str) -> float | None:
|
|
42
|
+
"""Return the CVSS v3.1 base score for ``vector`` or ``None`` if invalid."""
|
|
43
|
+
if not vector:
|
|
44
|
+
return None
|
|
45
|
+
metrics = parse_metrics(vector)
|
|
46
|
+
try:
|
|
47
|
+
scope = metrics["S"]
|
|
48
|
+
av = AV[metrics["AV"]]
|
|
49
|
+
ac = AC[metrics["AC"]]
|
|
50
|
+
ui = UI[metrics["UI"]]
|
|
51
|
+
pr = (PR_CHANGED if scope == "C" else PR_UNCHANGED)[metrics["PR"]]
|
|
52
|
+
impact_conf = CIA[metrics["C"]]
|
|
53
|
+
impact_int = CIA[metrics["I"]]
|
|
54
|
+
impact_avail = CIA[metrics["A"]]
|
|
55
|
+
except KeyError:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
iss = 1 - ((1 - impact_conf) * (1 - impact_int) * (1 - impact_avail))
|
|
59
|
+
if scope == "C":
|
|
60
|
+
impact = 7.52 * (iss - 0.029) - 3.25 * ((iss - 0.02) ** 15)
|
|
61
|
+
else:
|
|
62
|
+
impact = 6.42 * iss
|
|
63
|
+
|
|
64
|
+
if impact <= 0:
|
|
65
|
+
return 0.0
|
|
66
|
+
|
|
67
|
+
exploitability = 8.22 * av * ac * pr * ui
|
|
68
|
+
if scope == "C":
|
|
69
|
+
base = min(1.08 * (impact + exploitability), 10.0)
|
|
70
|
+
else:
|
|
71
|
+
base = min(impact + exploitability, 10.0)
|
|
72
|
+
return _roundup(base)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def severity_from_score(score: float) -> str:
|
|
76
|
+
if score <= 0:
|
|
77
|
+
return "info"
|
|
78
|
+
if score < 4.0:
|
|
79
|
+
return "low"
|
|
80
|
+
if score < 7.0:
|
|
81
|
+
return "medium"
|
|
82
|
+
if score < 9.0:
|
|
83
|
+
return "high"
|
|
84
|
+
return "critical"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def describe(vector: str) -> dict[str, Any]:
|
|
88
|
+
score = parse_vector(vector)
|
|
89
|
+
return {
|
|
90
|
+
"vector": vector,
|
|
91
|
+
"score": score,
|
|
92
|
+
"severity": severity_from_score(score) if score is not None else "unknown",
|
|
93
|
+
}
|