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,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,9 @@
1
+ """Reporting: CVSS scoring and report rendering.
2
+
3
+ Keep this package initialiser light so ``splitagent.report.cvss`` can be
4
+ imported by tools without pulling in the whole rendering stack.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = ["cvss"]
@@ -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
+ }