strix-agent 0.1.19__py3-none-any.whl → 0.3.1__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.
Potentially problematic release.
This version of strix-agent might be problematic. Click here for more details.
- strix/agents/StrixAgent/strix_agent.py +49 -40
- strix/agents/StrixAgent/system_prompt.jinja +15 -0
- strix/agents/base_agent.py +71 -11
- strix/agents/state.py +5 -1
- strix/interface/cli.py +171 -0
- strix/interface/main.py +482 -0
- strix/{cli → interface}/tool_components/scan_info_renderer.py +17 -12
- strix/{cli/app.py → interface/tui.py} +15 -16
- strix/interface/utils.py +435 -0
- strix/runtime/docker_runtime.py +28 -7
- strix/runtime/runtime.py +4 -1
- strix/telemetry/__init__.py +4 -0
- strix/{cli → telemetry}/tracer.py +21 -9
- strix/tools/agents_graph/agents_graph_actions.py +13 -9
- strix/tools/executor.py +1 -1
- strix/tools/finish/finish_actions.py +1 -1
- strix/tools/reporting/reporting_actions.py +1 -1
- {strix_agent-0.1.19.dist-info → strix_agent-0.3.1.dist-info}/METADATA +45 -4
- {strix_agent-0.1.19.dist-info → strix_agent-0.3.1.dist-info}/RECORD +39 -36
- strix_agent-0.3.1.dist-info/entry_points.txt +3 -0
- strix/cli/main.py +0 -703
- strix_agent-0.1.19.dist-info/entry_points.txt +0 -3
- /strix/{cli → interface}/__init__.py +0 -0
- /strix/{cli/assets/cli.tcss → interface/assets/tui_styles.tcss} +0 -0
- /strix/{cli → interface}/tool_components/__init__.py +0 -0
- /strix/{cli → interface}/tool_components/agents_graph_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/base_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/browser_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/file_edit_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/finish_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/notes_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/proxy_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/python_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/registry.py +0 -0
- /strix/{cli → interface}/tool_components/reporting_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/terminal_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/thinking_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/user_message_renderer.py +0 -0
- /strix/{cli → interface}/tool_components/web_search_renderer.py +0 -0
- {strix_agent-0.1.19.dist-info → strix_agent-0.3.1.dist-info}/LICENSE +0 -0
- {strix_agent-0.1.19.dist-info → strix_agent-0.3.1.dist-info}/WHEEL +0 -0
strix/interface/utils.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import secrets
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import docker
|
|
12
|
+
from docker.errors import DockerException, ImageNotFound
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Token formatting utilities
|
|
19
|
+
def format_token_count(count: float) -> str:
|
|
20
|
+
count = int(count)
|
|
21
|
+
if count >= 1_000_000:
|
|
22
|
+
return f"{count / 1_000_000:.1f}M"
|
|
23
|
+
if count >= 1_000:
|
|
24
|
+
return f"{count / 1_000:.1f}K"
|
|
25
|
+
return str(count)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Display utilities
|
|
29
|
+
def get_severity_color(severity: str) -> str:
|
|
30
|
+
severity_colors = {
|
|
31
|
+
"critical": "#dc2626",
|
|
32
|
+
"high": "#ea580c",
|
|
33
|
+
"medium": "#d97706",
|
|
34
|
+
"low": "#65a30d",
|
|
35
|
+
"info": "#0284c7",
|
|
36
|
+
}
|
|
37
|
+
return severity_colors.get(severity, "#6b7280")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_stats_text(tracer: Any) -> Text:
|
|
41
|
+
stats_text = Text()
|
|
42
|
+
if not tracer:
|
|
43
|
+
return stats_text
|
|
44
|
+
|
|
45
|
+
vuln_count = len(tracer.vulnerability_reports)
|
|
46
|
+
tool_count = tracer.get_real_tool_count()
|
|
47
|
+
agent_count = len(tracer.agents)
|
|
48
|
+
|
|
49
|
+
if vuln_count > 0:
|
|
50
|
+
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
|
|
51
|
+
for report in tracer.vulnerability_reports:
|
|
52
|
+
severity = report.get("severity", "").lower()
|
|
53
|
+
if severity in severity_counts:
|
|
54
|
+
severity_counts[severity] += 1
|
|
55
|
+
|
|
56
|
+
stats_text.append("🔍 Vulnerabilities Found: ", style="bold red")
|
|
57
|
+
|
|
58
|
+
severity_parts = []
|
|
59
|
+
for severity in ["critical", "high", "medium", "low", "info"]:
|
|
60
|
+
count = severity_counts[severity]
|
|
61
|
+
if count > 0:
|
|
62
|
+
severity_color = get_severity_color(severity)
|
|
63
|
+
severity_text = Text()
|
|
64
|
+
severity_text.append(f"{severity.upper()}: ", style=severity_color)
|
|
65
|
+
severity_text.append(str(count), style=f"bold {severity_color}")
|
|
66
|
+
severity_parts.append(severity_text)
|
|
67
|
+
|
|
68
|
+
for i, part in enumerate(severity_parts):
|
|
69
|
+
stats_text.append(part)
|
|
70
|
+
if i < len(severity_parts) - 1:
|
|
71
|
+
stats_text.append(" | ", style="dim white")
|
|
72
|
+
|
|
73
|
+
stats_text.append(" (Total: ", style="dim white")
|
|
74
|
+
stats_text.append(str(vuln_count), style="bold yellow")
|
|
75
|
+
stats_text.append(")", style="dim white")
|
|
76
|
+
stats_text.append("\n")
|
|
77
|
+
else:
|
|
78
|
+
stats_text.append("🔍 Vulnerabilities Found: ", style="bold green")
|
|
79
|
+
stats_text.append("0", style="bold white")
|
|
80
|
+
stats_text.append(" (No exploitable vulnerabilities detected)", style="dim green")
|
|
81
|
+
stats_text.append("\n")
|
|
82
|
+
|
|
83
|
+
stats_text.append("🤖 Agents Used: ", style="bold cyan")
|
|
84
|
+
stats_text.append(str(agent_count), style="bold white")
|
|
85
|
+
stats_text.append(" • ", style="dim white")
|
|
86
|
+
stats_text.append("🛠️ Tools Called: ", style="bold cyan")
|
|
87
|
+
stats_text.append(str(tool_count), style="bold white")
|
|
88
|
+
|
|
89
|
+
return stats_text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_llm_stats_text(tracer: Any) -> Text:
|
|
93
|
+
llm_stats_text = Text()
|
|
94
|
+
if not tracer:
|
|
95
|
+
return llm_stats_text
|
|
96
|
+
|
|
97
|
+
llm_stats = tracer.get_total_llm_stats()
|
|
98
|
+
total_stats = llm_stats["total"]
|
|
99
|
+
|
|
100
|
+
if total_stats["requests"] > 0:
|
|
101
|
+
llm_stats_text.append("📥 Input Tokens: ", style="bold cyan")
|
|
102
|
+
llm_stats_text.append(format_token_count(total_stats["input_tokens"]), style="bold white")
|
|
103
|
+
|
|
104
|
+
if total_stats["cached_tokens"] > 0:
|
|
105
|
+
llm_stats_text.append(" • ", style="dim white")
|
|
106
|
+
llm_stats_text.append("⚡ Cached: ", style="bold green")
|
|
107
|
+
llm_stats_text.append(
|
|
108
|
+
format_token_count(total_stats["cached_tokens"]), style="bold green"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
llm_stats_text.append(" • ", style="dim white")
|
|
112
|
+
llm_stats_text.append("📤 Output Tokens: ", style="bold cyan")
|
|
113
|
+
llm_stats_text.append(format_token_count(total_stats["output_tokens"]), style="bold white")
|
|
114
|
+
|
|
115
|
+
if total_stats["cost"] > 0:
|
|
116
|
+
llm_stats_text.append(" • ", style="dim white")
|
|
117
|
+
llm_stats_text.append("💰 Total Cost: $", style="bold cyan")
|
|
118
|
+
llm_stats_text.append(f"{total_stats['cost']:.4f}", style="bold yellow")
|
|
119
|
+
|
|
120
|
+
return llm_stats_text
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Name generation utilities
|
|
124
|
+
def generate_run_name() -> str:
|
|
125
|
+
# fmt: off
|
|
126
|
+
adjectives = [
|
|
127
|
+
"stealthy", "sneaky", "crafty", "elite", "phantom", "shadow", "silent",
|
|
128
|
+
"rogue", "covert", "ninja", "ghost", "cyber", "digital", "binary",
|
|
129
|
+
"encrypted", "obfuscated", "masked", "cloaked", "invisible", "anonymous"
|
|
130
|
+
]
|
|
131
|
+
nouns = [
|
|
132
|
+
"exploit", "payload", "backdoor", "rootkit", "keylogger", "botnet", "trojan",
|
|
133
|
+
"worm", "virus", "packet", "buffer", "shell", "daemon", "spider", "crawler",
|
|
134
|
+
"scanner", "sniffer", "honeypot", "firewall", "breach"
|
|
135
|
+
]
|
|
136
|
+
# fmt: on
|
|
137
|
+
adj = secrets.choice(adjectives)
|
|
138
|
+
noun = secrets.choice(nouns)
|
|
139
|
+
number = secrets.randbelow(900) + 100
|
|
140
|
+
return f"{adj}-{noun}-{number}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Target processing utilities
|
|
144
|
+
def infer_target_type(target: str) -> tuple[str, dict[str, str]]:
|
|
145
|
+
if not target or not isinstance(target, str):
|
|
146
|
+
raise ValueError("Target must be a non-empty string")
|
|
147
|
+
|
|
148
|
+
target = target.strip()
|
|
149
|
+
|
|
150
|
+
lower_target = target.lower()
|
|
151
|
+
bare_repo_prefixes = (
|
|
152
|
+
"github.com/",
|
|
153
|
+
"www.github.com/",
|
|
154
|
+
"gitlab.com/",
|
|
155
|
+
"www.gitlab.com/",
|
|
156
|
+
"bitbucket.org/",
|
|
157
|
+
"www.bitbucket.org/",
|
|
158
|
+
)
|
|
159
|
+
if any(lower_target.startswith(p) for p in bare_repo_prefixes):
|
|
160
|
+
return "repository", {"target_repo": f"https://{target}"}
|
|
161
|
+
|
|
162
|
+
parsed = urlparse(target)
|
|
163
|
+
if parsed.scheme in ("http", "https"):
|
|
164
|
+
if any(
|
|
165
|
+
host in parsed.netloc.lower() for host in ["github.com", "gitlab.com", "bitbucket.org"]
|
|
166
|
+
):
|
|
167
|
+
return "repository", {"target_repo": target}
|
|
168
|
+
return "web_application", {"target_url": target}
|
|
169
|
+
|
|
170
|
+
path = Path(target).expanduser()
|
|
171
|
+
try:
|
|
172
|
+
if path.exists():
|
|
173
|
+
if path.is_dir():
|
|
174
|
+
resolved = path.resolve()
|
|
175
|
+
return "local_code", {"target_path": str(resolved)}
|
|
176
|
+
raise ValueError(f"Path exists but is not a directory: {target}")
|
|
177
|
+
except (OSError, RuntimeError) as e:
|
|
178
|
+
raise ValueError(f"Invalid path: {target} - {e!s}") from e
|
|
179
|
+
|
|
180
|
+
if target.startswith("git@") or target.endswith(".git"):
|
|
181
|
+
return "repository", {"target_repo": target}
|
|
182
|
+
|
|
183
|
+
if "." in target and "/" not in target and not target.startswith("."):
|
|
184
|
+
parts = target.split(".")
|
|
185
|
+
if len(parts) >= 2 and all(p and p.strip() for p in parts):
|
|
186
|
+
return "web_application", {"target_url": f"https://{target}"}
|
|
187
|
+
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Invalid target: {target}\n"
|
|
190
|
+
"Target must be one of:\n"
|
|
191
|
+
"- A valid URL (http:// or https://)\n"
|
|
192
|
+
"- A Git repository URL (https://github.com/... or git@github.com:...)\n"
|
|
193
|
+
"- A local directory path\n"
|
|
194
|
+
"- A domain name (e.g., example.com)"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def sanitize_name(name: str) -> str:
|
|
199
|
+
sanitized = re.sub(r"[^A-Za-z0-9._-]", "-", name.strip())
|
|
200
|
+
return sanitized or "target"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def derive_repo_base_name(repo_url: str) -> str:
|
|
204
|
+
if repo_url.endswith("/"):
|
|
205
|
+
repo_url = repo_url[:-1]
|
|
206
|
+
|
|
207
|
+
if ":" in repo_url and repo_url.startswith("git@"):
|
|
208
|
+
path_part = repo_url.split(":", 1)[1]
|
|
209
|
+
else:
|
|
210
|
+
path_part = urlparse(repo_url).path or repo_url
|
|
211
|
+
|
|
212
|
+
candidate = path_part.split("/")[-1]
|
|
213
|
+
if candidate.endswith(".git"):
|
|
214
|
+
candidate = candidate[:-4]
|
|
215
|
+
|
|
216
|
+
return sanitize_name(candidate or "repository")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def derive_local_base_name(path_str: str) -> str:
|
|
220
|
+
try:
|
|
221
|
+
base = Path(path_str).resolve().name
|
|
222
|
+
except (OSError, RuntimeError):
|
|
223
|
+
base = Path(path_str).name
|
|
224
|
+
return sanitize_name(base or "workspace")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def assign_workspace_subdirs(targets_info: list[dict[str, Any]]) -> None:
|
|
228
|
+
name_counts: dict[str, int] = {}
|
|
229
|
+
|
|
230
|
+
for target in targets_info:
|
|
231
|
+
target_type = target["type"]
|
|
232
|
+
details = target["details"]
|
|
233
|
+
|
|
234
|
+
base_name: str | None = None
|
|
235
|
+
if target_type == "repository":
|
|
236
|
+
base_name = derive_repo_base_name(details["target_repo"])
|
|
237
|
+
elif target_type == "local_code":
|
|
238
|
+
base_name = derive_local_base_name(details.get("target_path", "local"))
|
|
239
|
+
|
|
240
|
+
if base_name is None:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
count = name_counts.get(base_name, 0) + 1
|
|
244
|
+
name_counts[base_name] = count
|
|
245
|
+
|
|
246
|
+
workspace_subdir = base_name if count == 1 else f"{base_name}-{count}"
|
|
247
|
+
|
|
248
|
+
details["workspace_subdir"] = workspace_subdir
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def collect_local_sources(targets_info: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
252
|
+
local_sources: list[dict[str, str]] = []
|
|
253
|
+
|
|
254
|
+
for target_info in targets_info:
|
|
255
|
+
details = target_info["details"]
|
|
256
|
+
workspace_subdir = details.get("workspace_subdir")
|
|
257
|
+
|
|
258
|
+
if target_info["type"] == "local_code" and "target_path" in details:
|
|
259
|
+
local_sources.append(
|
|
260
|
+
{
|
|
261
|
+
"source_path": details["target_path"],
|
|
262
|
+
"workspace_subdir": workspace_subdir,
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
elif target_info["type"] == "repository" and "cloned_repo_path" in details:
|
|
267
|
+
local_sources.append(
|
|
268
|
+
{
|
|
269
|
+
"source_path": details["cloned_repo_path"],
|
|
270
|
+
"workspace_subdir": workspace_subdir,
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
return local_sources
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# Repository utilities
|
|
278
|
+
def clone_repository(repo_url: str, run_name: str, dest_name: str | None = None) -> str:
|
|
279
|
+
console = Console()
|
|
280
|
+
|
|
281
|
+
git_executable = shutil.which("git")
|
|
282
|
+
if git_executable is None:
|
|
283
|
+
raise FileNotFoundError("Git executable not found in PATH")
|
|
284
|
+
|
|
285
|
+
temp_dir = Path(tempfile.gettempdir()) / "strix_repos" / run_name
|
|
286
|
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
|
|
288
|
+
if dest_name:
|
|
289
|
+
repo_name = dest_name
|
|
290
|
+
else:
|
|
291
|
+
repo_name = Path(repo_url).stem if repo_url.endswith(".git") else Path(repo_url).name
|
|
292
|
+
|
|
293
|
+
clone_path = temp_dir / repo_name
|
|
294
|
+
|
|
295
|
+
if clone_path.exists():
|
|
296
|
+
shutil.rmtree(clone_path)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
with console.status(f"[bold cyan]Cloning repository {repo_url}...", spinner="dots"):
|
|
300
|
+
subprocess.run( # noqa: S603
|
|
301
|
+
[
|
|
302
|
+
git_executable,
|
|
303
|
+
"clone",
|
|
304
|
+
repo_url,
|
|
305
|
+
str(clone_path),
|
|
306
|
+
],
|
|
307
|
+
capture_output=True,
|
|
308
|
+
text=True,
|
|
309
|
+
check=True,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return str(clone_path.absolute())
|
|
313
|
+
|
|
314
|
+
except subprocess.CalledProcessError as e:
|
|
315
|
+
error_text = Text()
|
|
316
|
+
error_text.append("❌ ", style="bold red")
|
|
317
|
+
error_text.append("REPOSITORY CLONE FAILED", style="bold red")
|
|
318
|
+
error_text.append("\n\n", style="white")
|
|
319
|
+
error_text.append(f"Could not clone repository: {repo_url}\n", style="white")
|
|
320
|
+
error_text.append(
|
|
321
|
+
f"Error: {e.stderr if hasattr(e, 'stderr') and e.stderr else str(e)}", style="dim red"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
panel = Panel(
|
|
325
|
+
error_text,
|
|
326
|
+
title="[bold red]🛡️ STRIX CLONE ERROR",
|
|
327
|
+
title_align="center",
|
|
328
|
+
border_style="red",
|
|
329
|
+
padding=(1, 2),
|
|
330
|
+
)
|
|
331
|
+
console.print("\n")
|
|
332
|
+
console.print(panel)
|
|
333
|
+
console.print()
|
|
334
|
+
sys.exit(1)
|
|
335
|
+
except FileNotFoundError:
|
|
336
|
+
error_text = Text()
|
|
337
|
+
error_text.append("❌ ", style="bold red")
|
|
338
|
+
error_text.append("GIT NOT FOUND", style="bold red")
|
|
339
|
+
error_text.append("\n\n", style="white")
|
|
340
|
+
error_text.append("Git is not installed or not available in PATH.\n", style="white")
|
|
341
|
+
error_text.append("Please install Git to clone repositories.\n", style="white")
|
|
342
|
+
|
|
343
|
+
panel = Panel(
|
|
344
|
+
error_text,
|
|
345
|
+
title="[bold red]🛡️ STRIX CLONE ERROR",
|
|
346
|
+
title_align="center",
|
|
347
|
+
border_style="red",
|
|
348
|
+
padding=(1, 2),
|
|
349
|
+
)
|
|
350
|
+
console.print("\n")
|
|
351
|
+
console.print(panel)
|
|
352
|
+
console.print()
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# Docker utilities
|
|
357
|
+
def check_docker_connection() -> Any:
|
|
358
|
+
try:
|
|
359
|
+
return docker.from_env()
|
|
360
|
+
except DockerException:
|
|
361
|
+
console = Console()
|
|
362
|
+
error_text = Text()
|
|
363
|
+
error_text.append("❌ ", style="bold red")
|
|
364
|
+
error_text.append("DOCKER NOT AVAILABLE", style="bold red")
|
|
365
|
+
error_text.append("\n\n", style="white")
|
|
366
|
+
error_text.append("Cannot connect to Docker daemon.\n", style="white")
|
|
367
|
+
error_text.append("Please ensure Docker is installed and running.\n\n", style="white")
|
|
368
|
+
error_text.append("Try running: ", style="dim white")
|
|
369
|
+
error_text.append("sudo systemctl start docker", style="dim cyan")
|
|
370
|
+
|
|
371
|
+
panel = Panel(
|
|
372
|
+
error_text,
|
|
373
|
+
title="[bold red]🛡️ STRIX STARTUP ERROR",
|
|
374
|
+
title_align="center",
|
|
375
|
+
border_style="red",
|
|
376
|
+
padding=(1, 2),
|
|
377
|
+
)
|
|
378
|
+
console.print("\n", panel, "\n")
|
|
379
|
+
raise RuntimeError("Docker not available") from None
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def image_exists(client: Any, image_name: str) -> bool:
|
|
383
|
+
try:
|
|
384
|
+
client.images.get(image_name)
|
|
385
|
+
except ImageNotFound:
|
|
386
|
+
return False
|
|
387
|
+
else:
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def update_layer_status(layers_info: dict[str, str], layer_id: str, layer_status: str) -> None:
|
|
392
|
+
if "Pull complete" in layer_status or "Already exists" in layer_status:
|
|
393
|
+
layers_info[layer_id] = "✓"
|
|
394
|
+
elif "Downloading" in layer_status:
|
|
395
|
+
layers_info[layer_id] = "↓"
|
|
396
|
+
elif "Extracting" in layer_status:
|
|
397
|
+
layers_info[layer_id] = "📦"
|
|
398
|
+
elif "Waiting" in layer_status:
|
|
399
|
+
layers_info[layer_id] = "⏳"
|
|
400
|
+
else:
|
|
401
|
+
layers_info[layer_id] = "•"
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def process_pull_line(
|
|
405
|
+
line: dict[str, Any], layers_info: dict[str, str], status: Any, last_update: str
|
|
406
|
+
) -> str:
|
|
407
|
+
if "id" in line and "status" in line:
|
|
408
|
+
layer_id = line["id"]
|
|
409
|
+
update_layer_status(layers_info, layer_id, line["status"])
|
|
410
|
+
|
|
411
|
+
completed = sum(1 for v in layers_info.values() if v == "✓")
|
|
412
|
+
total = len(layers_info)
|
|
413
|
+
|
|
414
|
+
if total > 0:
|
|
415
|
+
update_msg = f"[bold cyan]Progress: {completed}/{total} layers complete"
|
|
416
|
+
if update_msg != last_update:
|
|
417
|
+
status.update(update_msg)
|
|
418
|
+
return update_msg
|
|
419
|
+
|
|
420
|
+
elif "status" in line and "id" not in line:
|
|
421
|
+
global_status = line["status"]
|
|
422
|
+
if "Pulling from" in global_status:
|
|
423
|
+
status.update("[bold cyan]Fetching image manifest...")
|
|
424
|
+
elif "Digest:" in global_status:
|
|
425
|
+
status.update("[bold cyan]Verifying image...")
|
|
426
|
+
elif "Status:" in global_status:
|
|
427
|
+
status.update("[bold cyan]Finalizing...")
|
|
428
|
+
|
|
429
|
+
return last_update
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# LLM utilities
|
|
433
|
+
def validate_llm_response(response: Any) -> None:
|
|
434
|
+
if not response or not response.choices or not response.choices[0].message.content:
|
|
435
|
+
raise RuntimeError("Invalid response from LLM")
|
strix/runtime/docker_runtime.py
CHANGED
|
@@ -40,7 +40,7 @@ class DockerRuntime(AbstractRuntime):
|
|
|
40
40
|
|
|
41
41
|
def _get_scan_id(self, agent_id: str) -> str:
|
|
42
42
|
try:
|
|
43
|
-
from strix.
|
|
43
|
+
from strix.telemetry.tracer import get_global_tracer
|
|
44
44
|
|
|
45
45
|
tracer = get_global_tracer()
|
|
46
46
|
if tracer and tracer.scan_config:
|
|
@@ -250,7 +250,9 @@ class DockerRuntime(AbstractRuntime):
|
|
|
250
250
|
|
|
251
251
|
time.sleep(5)
|
|
252
252
|
|
|
253
|
-
def _copy_local_directory_to_container(
|
|
253
|
+
def _copy_local_directory_to_container(
|
|
254
|
+
self, container: Container, local_path: str, target_name: str | None = None
|
|
255
|
+
) -> None:
|
|
254
256
|
import tarfile
|
|
255
257
|
from io import BytesIO
|
|
256
258
|
|
|
@@ -260,13 +262,20 @@ class DockerRuntime(AbstractRuntime):
|
|
|
260
262
|
logger.warning(f"Local path does not exist or is not directory: {local_path_obj}")
|
|
261
263
|
return
|
|
262
264
|
|
|
263
|
-
|
|
265
|
+
if target_name:
|
|
266
|
+
logger.info(
|
|
267
|
+
f"Copying local directory {local_path_obj} to container at "
|
|
268
|
+
f"/workspace/{target_name}"
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
logger.info(f"Copying local directory {local_path_obj} to container")
|
|
264
272
|
|
|
265
273
|
tar_buffer = BytesIO()
|
|
266
274
|
with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
|
|
267
275
|
for item in local_path_obj.rglob("*"):
|
|
268
276
|
if item.is_file():
|
|
269
|
-
|
|
277
|
+
rel_path = item.relative_to(local_path_obj)
|
|
278
|
+
arcname = Path(target_name) / rel_path if target_name else rel_path
|
|
270
279
|
tar.add(item, arcname=arcname)
|
|
271
280
|
|
|
272
281
|
tar_buffer.seek(0)
|
|
@@ -283,14 +292,26 @@ class DockerRuntime(AbstractRuntime):
|
|
|
283
292
|
logger.exception("Failed to copy local directory to container")
|
|
284
293
|
|
|
285
294
|
async def create_sandbox(
|
|
286
|
-
self,
|
|
295
|
+
self,
|
|
296
|
+
agent_id: str,
|
|
297
|
+
existing_token: str | None = None,
|
|
298
|
+
local_sources: list[dict[str, str]] | None = None,
|
|
287
299
|
) -> SandboxInfo:
|
|
288
300
|
scan_id = self._get_scan_id(agent_id)
|
|
289
301
|
container = self._get_or_create_scan_container(scan_id)
|
|
290
302
|
|
|
291
303
|
source_copied_key = f"_source_copied_{scan_id}"
|
|
292
|
-
if
|
|
293
|
-
|
|
304
|
+
if local_sources and not hasattr(self, source_copied_key):
|
|
305
|
+
for index, source in enumerate(local_sources, start=1):
|
|
306
|
+
source_path = source.get("source_path")
|
|
307
|
+
if not source_path:
|
|
308
|
+
continue
|
|
309
|
+
|
|
310
|
+
target_name = source.get("workspace_subdir")
|
|
311
|
+
if not target_name:
|
|
312
|
+
target_name = Path(source_path).name or f"target_{index}"
|
|
313
|
+
|
|
314
|
+
self._copy_local_directory_to_container(container, source_path, target_name)
|
|
294
315
|
setattr(self, source_copied_key, True)
|
|
295
316
|
|
|
296
317
|
container_id = container.id
|
strix/runtime/runtime.py
CHANGED
|
@@ -13,7 +13,10 @@ class SandboxInfo(TypedDict):
|
|
|
13
13
|
class AbstractRuntime(ABC):
|
|
14
14
|
@abstractmethod
|
|
15
15
|
async def create_sandbox(
|
|
16
|
-
self,
|
|
16
|
+
self,
|
|
17
|
+
agent_id: str,
|
|
18
|
+
existing_token: str | None = None,
|
|
19
|
+
local_sources: list[dict[str, str]] | None = None,
|
|
17
20
|
) -> SandboxInfo:
|
|
18
21
|
raise NotImplementedError
|
|
19
22
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from datetime import UTC, datetime
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any, Optional
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
5
5
|
from uuid import uuid4
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
|
|
8
12
|
logger = logging.getLogger(__name__)
|
|
9
13
|
|
|
10
14
|
_global_tracer: Optional["Tracer"] = None
|
|
@@ -40,14 +44,15 @@ class Tracer:
|
|
|
40
44
|
"run_name": self.run_name,
|
|
41
45
|
"start_time": self.start_time,
|
|
42
46
|
"end_time": None,
|
|
43
|
-
"
|
|
44
|
-
"scan_type": None,
|
|
47
|
+
"targets": [],
|
|
45
48
|
"status": "running",
|
|
46
49
|
}
|
|
47
50
|
self._run_dir: Path | None = None
|
|
48
51
|
self._next_execution_id = 1
|
|
49
52
|
self._next_message_id = 1
|
|
50
53
|
|
|
54
|
+
self.vulnerability_found_callback: Callable[[str, str, str, str], None] | None = None
|
|
55
|
+
|
|
51
56
|
def set_run_name(self, run_name: str) -> None:
|
|
52
57
|
self.run_name = run_name
|
|
53
58
|
self.run_id = run_name
|
|
@@ -81,6 +86,12 @@ class Tracer:
|
|
|
81
86
|
|
|
82
87
|
self.vulnerability_reports.append(report)
|
|
83
88
|
logger.info(f"Added vulnerability report: {report_id} - {title}")
|
|
89
|
+
|
|
90
|
+
if self.vulnerability_found_callback:
|
|
91
|
+
self.vulnerability_found_callback(
|
|
92
|
+
report_id, title.strip(), content.strip(), severity.lower().strip()
|
|
93
|
+
)
|
|
94
|
+
|
|
84
95
|
return report_id
|
|
85
96
|
|
|
86
97
|
def set_final_scan_result(
|
|
@@ -181,8 +192,7 @@ class Tracer:
|
|
|
181
192
|
self.scan_config = config
|
|
182
193
|
self.run_metadata.update(
|
|
183
194
|
{
|
|
184
|
-
"
|
|
185
|
-
"scan_type": config.get("scan_type", "general"),
|
|
195
|
+
"targets": config.get("targets", []),
|
|
186
196
|
"user_instructions": config.get("user_instructions", ""),
|
|
187
197
|
"max_iterations": config.get("max_iterations", 200),
|
|
188
198
|
}
|
|
@@ -194,14 +204,16 @@ class Tracer:
|
|
|
194
204
|
self.end_time = datetime.now(UTC).isoformat()
|
|
195
205
|
|
|
196
206
|
if self.final_scan_result:
|
|
197
|
-
|
|
198
|
-
with
|
|
199
|
-
f.write("# Security
|
|
207
|
+
penetration_test_report_file = run_dir / "penetration_test_report.md"
|
|
208
|
+
with penetration_test_report_file.open("w", encoding="utf-8") as f:
|
|
209
|
+
f.write("# Security Penetration Test Report\n\n")
|
|
200
210
|
f.write(
|
|
201
211
|
f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n"
|
|
202
212
|
)
|
|
203
213
|
f.write(f"{self.final_scan_result}\n")
|
|
204
|
-
logger.info(
|
|
214
|
+
logger.info(
|
|
215
|
+
f"Saved final penetration test report to: {penetration_test_report_file}"
|
|
216
|
+
)
|
|
205
217
|
|
|
206
218
|
if self.vulnerability_reports:
|
|
207
219
|
vuln_dir = run_dir / "vulnerabilities"
|
|
@@ -228,15 +228,19 @@ def create_agent(
|
|
|
228
228
|
from strix.agents.state import AgentState
|
|
229
229
|
from strix.llm.config import LLMConfig
|
|
230
230
|
|
|
231
|
-
state = AgentState(task=task, agent_name=name, parent_id=parent_id, max_iterations=
|
|
231
|
+
state = AgentState(task=task, agent_name=name, parent_id=parent_id, max_iterations=300)
|
|
232
232
|
|
|
233
233
|
llm_config = LLMConfig(prompt_modules=module_list)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
234
|
+
|
|
235
|
+
parent_agent = _agent_instances.get(parent_id)
|
|
236
|
+
agent_config = {
|
|
237
|
+
"llm_config": llm_config,
|
|
238
|
+
"state": state,
|
|
239
|
+
}
|
|
240
|
+
if parent_agent and hasattr(parent_agent, "non_interactive"):
|
|
241
|
+
agent_config["non_interactive"] = parent_agent.non_interactive
|
|
242
|
+
|
|
243
|
+
agent = StrixAgent(agent_config)
|
|
240
244
|
|
|
241
245
|
inherited_messages = []
|
|
242
246
|
if inherit_context:
|
|
@@ -487,7 +491,7 @@ def stop_agent(agent_id: str) -> dict[str, Any]:
|
|
|
487
491
|
agent_node["status"] = "stopping"
|
|
488
492
|
|
|
489
493
|
try:
|
|
490
|
-
from strix.
|
|
494
|
+
from strix.telemetry.tracer import get_global_tracer
|
|
491
495
|
|
|
492
496
|
tracer = get_global_tracer()
|
|
493
497
|
if tracer:
|
|
@@ -578,7 +582,7 @@ def wait_for_message(
|
|
|
578
582
|
_agent_graph["nodes"][agent_id]["waiting_reason"] = reason
|
|
579
583
|
|
|
580
584
|
try:
|
|
581
|
-
from strix.
|
|
585
|
+
from strix.telemetry.tracer import get_global_tracer
|
|
582
586
|
|
|
583
587
|
tracer = get_global_tracer()
|
|
584
588
|
if tracer:
|
strix/tools/executor.py
CHANGED
|
@@ -240,7 +240,7 @@ async def _execute_single_tool(
|
|
|
240
240
|
|
|
241
241
|
def _get_tracer_and_agent_id(agent_state: Any | None) -> tuple[Any | None, str]:
|
|
242
242
|
try:
|
|
243
|
-
from strix.
|
|
243
|
+
from strix.telemetry.tracer import get_global_tracer
|
|
244
244
|
|
|
245
245
|
tracer = get_global_tracer()
|
|
246
246
|
agent_id = agent_state.agent_id if agent_state else "unknown_agent"
|
|
@@ -107,7 +107,7 @@ def _check_active_agents(agent_state: Any = None) -> dict[str, Any] | None:
|
|
|
107
107
|
|
|
108
108
|
def _finalize_with_tracer(content: str, success: bool) -> dict[str, Any]:
|
|
109
109
|
try:
|
|
110
|
-
from strix.
|
|
110
|
+
from strix.telemetry.tracer import get_global_tracer
|
|
111
111
|
|
|
112
112
|
tracer = get_global_tracer()
|
|
113
113
|
if tracer:
|