strix-agent 0.1.18__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.

Files changed (68) hide show
  1. strix/agents/StrixAgent/strix_agent.py +49 -39
  2. strix/agents/StrixAgent/system_prompt.jinja +23 -10
  3. strix/agents/base_agent.py +90 -10
  4. strix/agents/state.py +23 -2
  5. strix/interface/cli.py +171 -0
  6. strix/interface/main.py +482 -0
  7. strix/{cli → interface}/tool_components/base_renderer.py +2 -2
  8. strix/{cli → interface}/tool_components/reporting_renderer.py +2 -1
  9. strix/{cli → interface}/tool_components/scan_info_renderer.py +17 -12
  10. strix/{cli/app.py → interface/tui.py} +107 -31
  11. strix/interface/utils.py +435 -0
  12. strix/prompts/README.md +64 -0
  13. strix/prompts/__init__.py +1 -1
  14. strix/prompts/cloud/.gitkeep +0 -0
  15. strix/prompts/custom/.gitkeep +0 -0
  16. strix/prompts/frameworks/fastapi.jinja +142 -0
  17. strix/prompts/frameworks/nextjs.jinja +126 -0
  18. strix/prompts/protocols/graphql.jinja +215 -0
  19. strix/prompts/reconnaissance/.gitkeep +0 -0
  20. strix/prompts/technologies/firebase_firestore.jinja +177 -0
  21. strix/prompts/technologies/supabase.jinja +189 -0
  22. strix/prompts/vulnerabilities/authentication_jwt.jinja +133 -115
  23. strix/prompts/vulnerabilities/broken_function_level_authorization.jinja +146 -0
  24. strix/prompts/vulnerabilities/business_logic.jinja +146 -118
  25. strix/prompts/vulnerabilities/csrf.jinja +137 -131
  26. strix/prompts/vulnerabilities/idor.jinja +149 -118
  27. strix/prompts/vulnerabilities/insecure_file_uploads.jinja +188 -0
  28. strix/prompts/vulnerabilities/mass_assignment.jinja +141 -0
  29. strix/prompts/vulnerabilities/path_traversal_lfi_rfi.jinja +142 -0
  30. strix/prompts/vulnerabilities/race_conditions.jinja +135 -165
  31. strix/prompts/vulnerabilities/rce.jinja +128 -180
  32. strix/prompts/vulnerabilities/sql_injection.jinja +128 -192
  33. strix/prompts/vulnerabilities/ssrf.jinja +118 -151
  34. strix/prompts/vulnerabilities/xss.jinja +144 -196
  35. strix/prompts/vulnerabilities/xxe.jinja +151 -243
  36. strix/runtime/docker_runtime.py +28 -7
  37. strix/runtime/runtime.py +4 -1
  38. strix/telemetry/__init__.py +4 -0
  39. strix/{cli → telemetry}/tracer.py +21 -9
  40. strix/tools/agents_graph/agents_graph_actions.py +17 -12
  41. strix/tools/agents_graph/agents_graph_actions_schema.xml +10 -14
  42. strix/tools/executor.py +1 -1
  43. strix/tools/finish/finish_actions.py +1 -1
  44. strix/tools/registry.py +1 -1
  45. strix/tools/reporting/reporting_actions.py +1 -1
  46. {strix_agent-0.1.18.dist-info → strix_agent-0.3.1.dist-info}/METADATA +95 -15
  47. strix_agent-0.3.1.dist-info/RECORD +115 -0
  48. strix_agent-0.3.1.dist-info/entry_points.txt +3 -0
  49. strix/cli/main.py +0 -702
  50. strix_agent-0.1.18.dist-info/RECORD +0 -99
  51. strix_agent-0.1.18.dist-info/entry_points.txt +0 -3
  52. /strix/{cli → interface}/__init__.py +0 -0
  53. /strix/{cli/assets/cli.tcss → interface/assets/tui_styles.tcss} +0 -0
  54. /strix/{cli → interface}/tool_components/__init__.py +0 -0
  55. /strix/{cli → interface}/tool_components/agents_graph_renderer.py +0 -0
  56. /strix/{cli → interface}/tool_components/browser_renderer.py +0 -0
  57. /strix/{cli → interface}/tool_components/file_edit_renderer.py +0 -0
  58. /strix/{cli → interface}/tool_components/finish_renderer.py +0 -0
  59. /strix/{cli → interface}/tool_components/notes_renderer.py +0 -0
  60. /strix/{cli → interface}/tool_components/proxy_renderer.py +0 -0
  61. /strix/{cli → interface}/tool_components/python_renderer.py +0 -0
  62. /strix/{cli → interface}/tool_components/registry.py +0 -0
  63. /strix/{cli → interface}/tool_components/terminal_renderer.py +0 -0
  64. /strix/{cli → interface}/tool_components/thinking_renderer.py +0 -0
  65. /strix/{cli → interface}/tool_components/user_message_renderer.py +0 -0
  66. /strix/{cli → interface}/tool_components/web_search_renderer.py +0 -0
  67. {strix_agent-0.1.18.dist-info → strix_agent-0.3.1.dist-info}/LICENSE +0 -0
  68. {strix_agent-0.1.18.dist-info → strix_agent-0.3.1.dist-info}/WHEEL +0 -0
@@ -7,9 +7,19 @@ import signal
7
7
  import sys
8
8
  import threading
9
9
  from collections.abc import Callable
10
- from typing import Any, ClassVar
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as pkg_version
12
+ from typing import TYPE_CHECKING, Any, ClassVar, cast
11
13
 
14
+
15
+ if TYPE_CHECKING:
16
+ from textual.timer import Timer
17
+
18
+ from rich.align import Align
19
+ from rich.console import Group
12
20
  from rich.markup import escape as rich_escape
21
+ from rich.panel import Panel
22
+ from rich.style import Style
13
23
  from rich.text import Text
14
24
  from textual import events, on
15
25
  from textual.app import App, ComposeResult
@@ -21,20 +31,27 @@ from textual.widgets import Button, Label, Static, TextArea, Tree
21
31
  from textual.widgets.tree import TreeNode
22
32
 
23
33
  from strix.agents.StrixAgent import StrixAgent
24
- from strix.cli.tracer import Tracer, set_global_tracer
25
34
  from strix.llm.config import LLMConfig
35
+ from strix.telemetry.tracer import Tracer, set_global_tracer
26
36
 
27
37
 
28
38
  def escape_markup(text: str) -> str:
29
- return rich_escape(text)
39
+ return cast("str", rich_escape(text))
40
+
41
+
42
+ def get_package_version() -> str:
43
+ try:
44
+ return pkg_version("strix-agent")
45
+ except PackageNotFoundError:
46
+ return "dev"
30
47
 
31
48
 
32
49
  class ChatTextArea(TextArea): # type: ignore[misc]
33
50
  def __init__(self, *args: Any, **kwargs: Any) -> None:
34
51
  super().__init__(*args, **kwargs)
35
- self._app_reference: StrixCLIApp | None = None
52
+ self._app_reference: StrixTUIApp | None = None
36
53
 
37
- def set_app_reference(self, app: "StrixCLIApp") -> None:
54
+ def set_app_reference(self, app: "StrixTUIApp") -> None:
38
55
  self._app_reference = app
39
56
 
40
57
  def _on_key(self, event: events.Key) -> None:
@@ -53,24 +70,85 @@ class ChatTextArea(TextArea): # type: ignore[misc]
53
70
 
54
71
 
55
72
  class SplashScreen(Static): # type: ignore[misc]
73
+ PRIMARY_GREEN = "#22c55e"
74
+ BANNER = (
75
+ " ███████╗████████╗██████╗ ██╗██╗ ██╗\n"
76
+ " ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝\n"
77
+ " ███████╗ ██║ ██████╔╝██║ ╚███╔╝\n"
78
+ " ╚════██║ ██║ ██╔══██╗██║ ██╔██╗\n"
79
+ " ███████║ ██║ ██║ ██║██║██╔╝ ██╗\n"
80
+ " ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝"
81
+ )
82
+
83
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
84
+ super().__init__(*args, **kwargs)
85
+ self._animation_step = 0
86
+ self._animation_timer: Timer | None = None
87
+ self._panel_static: Static | None = None
88
+ self._version = "dev"
89
+
56
90
  def compose(self) -> ComposeResult:
57
- ascii_art = r"""
58
- [bright_green]
91
+ self._version = get_package_version()
92
+ self._animation_step = 0
93
+ start_line = self._build_start_line_text(self._animation_step)
94
+ panel = self._build_panel(start_line)
95
+
96
+ panel_static = Static(panel, id="splash_content")
97
+ self._panel_static = panel_static
98
+ yield panel_static
99
+
100
+ def on_mount(self) -> None:
101
+ self._animation_timer = self.set_interval(0.45, self._animate_start_line)
102
+
103
+ def on_unmount(self) -> None:
104
+ if self._animation_timer is not None:
105
+ self._animation_timer.stop()
106
+ self._animation_timer = None
107
+
108
+ def _animate_start_line(self) -> None:
109
+ if not self._panel_static:
110
+ return
111
+
112
+ self._animation_step += 1
113
+ start_line = self._build_start_line_text(self._animation_step)
114
+ panel = self._build_panel(start_line)
115
+ self._panel_static.update(panel)
116
+
117
+ def _build_panel(self, start_line: Text) -> Panel:
118
+ content = Group(
119
+ Align.center(Text(self.BANNER.strip("\n"), style=self.PRIMARY_GREEN, justify="center")),
120
+ Align.center(Text(" ")),
121
+ Align.center(self._build_welcome_text()),
122
+ Align.center(self._build_version_text()),
123
+ Align.center(self._build_tagline_text()),
124
+ Align.center(Text(" ")),
125
+ Align.center(start_line.copy()),
126
+ )
127
+
128
+ return Panel.fit(content, border_style=self.PRIMARY_GREEN, padding=(1, 6))
129
+
130
+ def _build_welcome_text(self) -> Text:
131
+ text = Text("Welcome to ", style=Style(color="white", bold=True))
132
+ text.append("Strix", style=Style(color=self.PRIMARY_GREEN, bold=True))
133
+ text.append("!", style=Style(color="white", bold=True))
134
+ return text
59
135
 
136
+ def _build_version_text(self) -> Text:
137
+ return Text(f"v{self._version}", style=Style(color="white", dim=True))
60
138
 
61
- ███████╗████████╗██████╗ ██╗██╗ ██╗
62
- ██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝
63
- ███████╗ ██║ ██████╔╝██║ ╚███╔╝
64
- ╚════██║ ██║ ██╔══██╗██║ ██╔██╗
65
- ███████║ ██║ ██║ ██║██║██╔╝ ██╗
66
- ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝
139
+ def _build_tagline_text(self) -> Text:
140
+ return Text("Open-source AI hackers for your apps", style=Style(color="white", dim=True))
67
141
 
142
+ def _build_start_line_text(self, phase: int) -> Text:
143
+ emphasize = phase % 2 == 1
144
+ base_style = Style(color="white", dim=not emphasize, bold=emphasize)
145
+ strix_style = Style(color=self.PRIMARY_GREEN, bold=bool(emphasize))
68
146
 
69
- [/bright_green]
147
+ text = Text("Starting ", style=base_style)
148
+ text.append("Strix", style=strix_style)
149
+ text.append(" Cybersecurity Agent", style=base_style)
70
150
 
71
- [bright_green]Starting Strix Cybersecurity Agent...[/bright_green]
72
- """
73
- yield Static(ascii_art, id="splash_content")
151
+ return text
74
152
 
75
153
 
76
154
  class HelpScreen(ModalScreen): # type: ignore[misc]
@@ -182,8 +260,8 @@ class QuitScreen(ModalScreen): # type: ignore[misc]
182
260
  self.app.pop_screen()
183
261
 
184
262
 
185
- class StrixCLIApp(App): # type: ignore[misc]
186
- CSS_PATH = "assets/cli.tcss"
263
+ class StrixTUIApp(App): # type: ignore[misc]
264
+ CSS_PATH = "assets/tui_styles.tcss"
187
265
 
188
266
  selected_agent_id: reactive[str | None] = reactive(default=None)
189
267
  show_splash: reactive[bool] = reactive(default=True)
@@ -234,8 +312,7 @@ class StrixCLIApp(App): # type: ignore[misc]
234
312
  def _build_scan_config(self, args: argparse.Namespace) -> dict[str, Any]:
235
313
  return {
236
314
  "scan_id": args.run_name,
237
- "scan_type": args.target_type,
238
- "target": args.target_dict,
315
+ "targets": args.targets_info,
239
316
  "user_instructions": args.instruction or "",
240
317
  "run_name": args.run_name,
241
318
  }
@@ -245,13 +322,11 @@ class StrixCLIApp(App): # type: ignore[misc]
245
322
 
246
323
  config = {
247
324
  "llm_config": llm_config,
248
- "max_iterations": 200,
325
+ "max_iterations": 300,
249
326
  }
250
327
 
251
- if args.target_type == "local_code" and "target_path" in args.target_dict:
252
- config["local_source_path"] = args.target_dict["target_path"]
253
- elif args.target_type == "repository" and "cloned_repo_path" in args.target_dict:
254
- config["local_source_path"] = args.target_dict["cloned_repo_path"]
328
+ if getattr(args, "local_sources", None):
329
+ config["local_sources"] = args.local_sources
255
330
 
256
331
  return config
257
332
 
@@ -362,7 +437,7 @@ class StrixCLIApp(App): # type: ignore[misc]
362
437
  def on_mount(self) -> None:
363
438
  self.title = "strix"
364
439
 
365
- self.set_timer(3.0, self._hide_splash_screen)
440
+ self.set_timer(4.5, self._hide_splash_screen)
366
441
 
367
442
  def _hide_splash_screen(self) -> None:
368
443
  self.show_splash = False
@@ -884,7 +959,7 @@ class StrixCLIApp(App): # type: ignore[misc]
884
959
  return ""
885
960
 
886
961
  if role == "user":
887
- from strix.cli.tool_components.user_message_renderer import UserMessageRenderer
962
+ from strix.interface.tool_components.user_message_renderer import UserMessageRenderer
888
963
 
889
964
  return UserMessageRenderer.render_simple(content)
890
965
  return content
@@ -914,7 +989,7 @@ class StrixCLIApp(App): # type: ignore[misc]
914
989
 
915
990
  color = tool_colors.get(tool_name, "#737373")
916
991
 
917
- from strix.cli.tool_components.registry import get_tool_renderer
992
+ from strix.interface.tool_components.registry import get_tool_renderer
918
993
 
919
994
  renderer = get_tool_renderer(tool_name)
920
995
 
@@ -1159,6 +1234,7 @@ class StrixCLIApp(App): # type: ignore[misc]
1159
1234
  widget.update(plain_text)
1160
1235
 
1161
1236
 
1162
- async def run_strix_cli(args: argparse.Namespace) -> None:
1163
- app = StrixCLIApp(args)
1237
+ async def run_tui(args: argparse.Namespace) -> None:
1238
+ """Run strix in interactive TUI mode with textual."""
1239
+ app = StrixTUIApp(args)
1164
1240
  await app.run_async()
@@ -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")