strix-agent 0.4.0__py3-none-any.whl → 0.6.2__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 (117) hide show
  1. strix/agents/StrixAgent/strix_agent.py +3 -3
  2. strix/agents/StrixAgent/system_prompt.jinja +30 -26
  3. strix/agents/base_agent.py +159 -75
  4. strix/agents/state.py +5 -2
  5. strix/config/__init__.py +12 -0
  6. strix/config/config.py +172 -0
  7. strix/interface/assets/tui_styles.tcss +195 -230
  8. strix/interface/cli.py +16 -41
  9. strix/interface/main.py +151 -74
  10. strix/interface/streaming_parser.py +119 -0
  11. strix/interface/tool_components/__init__.py +4 -0
  12. strix/interface/tool_components/agent_message_renderer.py +190 -0
  13. strix/interface/tool_components/agents_graph_renderer.py +54 -38
  14. strix/interface/tool_components/base_renderer.py +68 -36
  15. strix/interface/tool_components/browser_renderer.py +106 -91
  16. strix/interface/tool_components/file_edit_renderer.py +117 -36
  17. strix/interface/tool_components/finish_renderer.py +43 -10
  18. strix/interface/tool_components/notes_renderer.py +63 -38
  19. strix/interface/tool_components/proxy_renderer.py +133 -92
  20. strix/interface/tool_components/python_renderer.py +121 -8
  21. strix/interface/tool_components/registry.py +19 -12
  22. strix/interface/tool_components/reporting_renderer.py +196 -28
  23. strix/interface/tool_components/scan_info_renderer.py +22 -19
  24. strix/interface/tool_components/terminal_renderer.py +270 -90
  25. strix/interface/tool_components/thinking_renderer.py +8 -6
  26. strix/interface/tool_components/todo_renderer.py +225 -0
  27. strix/interface/tool_components/user_message_renderer.py +26 -19
  28. strix/interface/tool_components/web_search_renderer.py +7 -6
  29. strix/interface/tui.py +907 -262
  30. strix/interface/utils.py +236 -4
  31. strix/llm/__init__.py +6 -2
  32. strix/llm/config.py +8 -5
  33. strix/llm/dedupe.py +217 -0
  34. strix/llm/llm.py +209 -356
  35. strix/llm/memory_compressor.py +6 -5
  36. strix/llm/utils.py +17 -8
  37. strix/runtime/__init__.py +12 -3
  38. strix/runtime/docker_runtime.py +121 -202
  39. strix/runtime/tool_server.py +55 -95
  40. strix/skills/README.md +64 -0
  41. strix/skills/__init__.py +110 -0
  42. strix/{prompts → skills}/frameworks/nextjs.jinja +26 -0
  43. strix/skills/scan_modes/deep.jinja +145 -0
  44. strix/skills/scan_modes/quick.jinja +63 -0
  45. strix/skills/scan_modes/standard.jinja +91 -0
  46. strix/telemetry/README.md +38 -0
  47. strix/telemetry/__init__.py +7 -1
  48. strix/telemetry/posthog.py +137 -0
  49. strix/telemetry/tracer.py +194 -54
  50. strix/tools/__init__.py +11 -4
  51. strix/tools/agents_graph/agents_graph_actions.py +20 -21
  52. strix/tools/agents_graph/agents_graph_actions_schema.xml +8 -8
  53. strix/tools/browser/browser_actions.py +10 -6
  54. strix/tools/browser/browser_actions_schema.xml +6 -1
  55. strix/tools/browser/browser_instance.py +96 -48
  56. strix/tools/browser/tab_manager.py +121 -102
  57. strix/tools/context.py +12 -0
  58. strix/tools/executor.py +63 -4
  59. strix/tools/file_edit/file_edit_actions.py +6 -3
  60. strix/tools/file_edit/file_edit_actions_schema.xml +45 -3
  61. strix/tools/finish/finish_actions.py +80 -105
  62. strix/tools/finish/finish_actions_schema.xml +121 -14
  63. strix/tools/notes/notes_actions.py +6 -33
  64. strix/tools/notes/notes_actions_schema.xml +50 -46
  65. strix/tools/proxy/proxy_actions.py +14 -2
  66. strix/tools/proxy/proxy_actions_schema.xml +0 -1
  67. strix/tools/proxy/proxy_manager.py +28 -16
  68. strix/tools/python/python_actions.py +2 -2
  69. strix/tools/python/python_actions_schema.xml +9 -1
  70. strix/tools/python/python_instance.py +39 -37
  71. strix/tools/python/python_manager.py +43 -31
  72. strix/tools/registry.py +73 -12
  73. strix/tools/reporting/reporting_actions.py +218 -31
  74. strix/tools/reporting/reporting_actions_schema.xml +256 -8
  75. strix/tools/terminal/terminal_actions.py +2 -2
  76. strix/tools/terminal/terminal_actions_schema.xml +6 -0
  77. strix/tools/terminal/terminal_manager.py +41 -30
  78. strix/tools/thinking/thinking_actions_schema.xml +27 -25
  79. strix/tools/todo/__init__.py +18 -0
  80. strix/tools/todo/todo_actions.py +568 -0
  81. strix/tools/todo/todo_actions_schema.xml +225 -0
  82. strix/utils/__init__.py +0 -0
  83. strix/utils/resource_paths.py +13 -0
  84. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/METADATA +90 -65
  85. strix_agent-0.6.2.dist-info/RECORD +134 -0
  86. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/WHEEL +1 -1
  87. strix/llm/request_queue.py +0 -87
  88. strix/prompts/README.md +0 -64
  89. strix/prompts/__init__.py +0 -109
  90. strix_agent-0.4.0.dist-info/RECORD +0 -118
  91. /strix/{prompts → skills}/cloud/.gitkeep +0 -0
  92. /strix/{prompts → skills}/coordination/root_agent.jinja +0 -0
  93. /strix/{prompts → skills}/custom/.gitkeep +0 -0
  94. /strix/{prompts → skills}/frameworks/fastapi.jinja +0 -0
  95. /strix/{prompts → skills}/protocols/graphql.jinja +0 -0
  96. /strix/{prompts → skills}/reconnaissance/.gitkeep +0 -0
  97. /strix/{prompts → skills}/technologies/firebase_firestore.jinja +0 -0
  98. /strix/{prompts → skills}/technologies/supabase.jinja +0 -0
  99. /strix/{prompts → skills}/vulnerabilities/authentication_jwt.jinja +0 -0
  100. /strix/{prompts → skills}/vulnerabilities/broken_function_level_authorization.jinja +0 -0
  101. /strix/{prompts → skills}/vulnerabilities/business_logic.jinja +0 -0
  102. /strix/{prompts → skills}/vulnerabilities/csrf.jinja +0 -0
  103. /strix/{prompts → skills}/vulnerabilities/idor.jinja +0 -0
  104. /strix/{prompts → skills}/vulnerabilities/information_disclosure.jinja +0 -0
  105. /strix/{prompts → skills}/vulnerabilities/insecure_file_uploads.jinja +0 -0
  106. /strix/{prompts → skills}/vulnerabilities/mass_assignment.jinja +0 -0
  107. /strix/{prompts → skills}/vulnerabilities/open_redirect.jinja +0 -0
  108. /strix/{prompts → skills}/vulnerabilities/path_traversal_lfi_rfi.jinja +0 -0
  109. /strix/{prompts → skills}/vulnerabilities/race_conditions.jinja +0 -0
  110. /strix/{prompts → skills}/vulnerabilities/rce.jinja +0 -0
  111. /strix/{prompts → skills}/vulnerabilities/sql_injection.jinja +0 -0
  112. /strix/{prompts → skills}/vulnerabilities/ssrf.jinja +0 -0
  113. /strix/{prompts → skills}/vulnerabilities/subdomain_takeover.jinja +0 -0
  114. /strix/{prompts → skills}/vulnerabilities/xss.jinja +0 -0
  115. /strix/{prompts → skills}/vulnerabilities/xxe.jinja +0 -0
  116. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info}/entry_points.txt +0 -0
  117. {strix_agent-0.4.0.dist-info → strix_agent-0.6.2.dist-info/licenses}/LICENSE +0 -0
@@ -1,9 +1,10 @@
1
1
  import logging
2
- import os
3
2
  from typing import Any
4
3
 
5
4
  import litellm
6
5
 
6
+ from strix.config import Config
7
+
7
8
 
8
9
  logger = logging.getLogger(__name__)
9
10
 
@@ -85,7 +86,7 @@ def _extract_message_text(msg: dict[str, Any]) -> str:
85
86
  def _summarize_messages(
86
87
  messages: list[dict[str, Any]],
87
88
  model: str,
88
- timeout: int = 600,
89
+ timeout: int = 30,
89
90
  ) -> dict[str, Any]:
90
91
  if not messages:
91
92
  empty_summary = "<context_summary message_count='0'>{text}</context_summary>"
@@ -147,11 +148,11 @@ class MemoryCompressor:
147
148
  self,
148
149
  max_images: int = 3,
149
150
  model_name: str | None = None,
150
- timeout: int = 600,
151
+ timeout: int | None = None,
151
152
  ):
152
153
  self.max_images = max_images
153
- self.model_name = model_name or os.getenv("STRIX_LLM", "openai/gpt-5")
154
- self.timeout = timeout
154
+ self.model_name = model_name or Config.get("strix_llm")
155
+ self.timeout = timeout or int(Config.get("strix_memory_compressor_timeout") or "30")
155
156
 
156
157
  if not self.model_name:
157
158
  raise ValueError("STRIX_LLM environment variable must be set and not empty")
strix/llm/utils.py CHANGED
@@ -18,7 +18,7 @@ def _truncate_to_first_function(content: str) -> str:
18
18
 
19
19
 
20
20
  def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
21
- content = _fix_stopword(content)
21
+ content = fix_incomplete_tool_call(content)
22
22
 
23
23
  tool_invocations: list[dict[str, Any]] = []
24
24
 
@@ -46,12 +46,15 @@ def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
46
46
  return tool_invocations if tool_invocations else None
47
47
 
48
48
 
49
- def _fix_stopword(content: str) -> str:
50
- if "<function=" in content and content.count("<function=") == 1:
51
- if content.endswith("</"):
52
- content = content.rstrip() + "function>"
53
- elif not content.rstrip().endswith("</function>"):
54
- content = content + "\n</function>"
49
+ def fix_incomplete_tool_call(content: str) -> str:
50
+ """Fix incomplete tool calls by adding missing </function> tag."""
51
+ if (
52
+ "<function=" in content
53
+ and content.count("<function=") == 1
54
+ and "</function>" not in content
55
+ ):
56
+ content = content.rstrip()
57
+ content = content + "function>" if content.endswith("</") else content + "\n</function>"
55
58
  return content
56
59
 
57
60
 
@@ -70,11 +73,17 @@ def clean_content(content: str) -> str:
70
73
  if not content:
71
74
  return ""
72
75
 
73
- content = _fix_stopword(content)
76
+ content = fix_incomplete_tool_call(content)
74
77
 
75
78
  tool_pattern = r"<function=[^>]+>.*?</function>"
76
79
  cleaned = re.sub(tool_pattern, "", content, flags=re.DOTALL)
77
80
 
81
+ incomplete_tool_pattern = r"<function=[^>]+>.*$"
82
+ cleaned = re.sub(incomplete_tool_pattern, "", cleaned, flags=re.DOTALL)
83
+
84
+ partial_tag_pattern = r"<f(?:u(?:n(?:c(?:t(?:i(?:o(?:n(?:=(?:[^>]*)?)?)?)?)?)?)?)?)?$"
85
+ cleaned = re.sub(partial_tag_pattern, "", cleaned)
86
+
78
87
  hidden_xml_patterns = [
79
88
  r"<inter_agent_message>.*?</inter_agent_message>",
80
89
  r"<agent_completion_report>.*?</agent_completion_report>",
strix/runtime/__init__.py CHANGED
@@ -1,10 +1,19 @@
1
- import os
1
+ from strix.config import Config
2
2
 
3
3
  from .runtime import AbstractRuntime
4
4
 
5
5
 
6
+ class SandboxInitializationError(Exception):
7
+ """Raised when sandbox initialization fails (e.g., Docker issues)."""
8
+
9
+ def __init__(self, message: str, details: str | None = None):
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.details = details
13
+
14
+
6
15
  def get_runtime() -> AbstractRuntime:
7
- runtime_backend = os.getenv("STRIX_RUNTIME_BACKEND", "docker")
16
+ runtime_backend = Config.get("strix_runtime_backend")
8
17
 
9
18
  if runtime_backend == "docker":
10
19
  from .docker_runtime import DockerRuntime
@@ -16,4 +25,4 @@ def get_runtime() -> AbstractRuntime:
16
25
  )
17
26
 
18
27
 
19
- __all__ = ["AbstractRuntime", "get_runtime"]
28
+ __all__ = ["AbstractRuntime", "SandboxInitializationError", "get_runtime"]
@@ -1,5 +1,4 @@
1
1
  import contextlib
2
- import logging
3
2
  import os
4
3
  import secrets
5
4
  import socket
@@ -8,31 +7,37 @@ from pathlib import Path
8
7
  from typing import cast
9
8
 
10
9
  import docker
10
+ import httpx
11
11
  from docker.errors import DockerException, ImageNotFound, NotFound
12
12
  from docker.models.containers import Container
13
+ from requests.exceptions import ConnectionError as RequestsConnectionError
14
+ from requests.exceptions import Timeout as RequestsTimeout
13
15
 
16
+ from strix.config import Config
17
+
18
+ from . import SandboxInitializationError
14
19
  from .runtime import AbstractRuntime, SandboxInfo
15
20
 
16
21
 
17
- STRIX_IMAGE = os.getenv("STRIX_IMAGE", "ghcr.io/usestrix/strix-sandbox:0.1.10")
18
- logger = logging.getLogger(__name__)
22
+ HOST_GATEWAY_HOSTNAME = "host.docker.internal"
23
+ DOCKER_TIMEOUT = 60
24
+ CONTAINER_TOOL_SERVER_PORT = 48081
19
25
 
20
26
 
21
27
  class DockerRuntime(AbstractRuntime):
22
28
  def __init__(self) -> None:
23
29
  try:
24
- self.client = docker.from_env()
25
- except DockerException as e:
26
- logger.exception("Failed to connect to Docker daemon")
27
- raise RuntimeError("Docker is not available or not configured correctly.") from e
30
+ self.client = docker.from_env(timeout=DOCKER_TIMEOUT)
31
+ except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
32
+ raise SandboxInitializationError(
33
+ "Docker is not available",
34
+ "Please ensure Docker Desktop is installed and running.",
35
+ ) from e
28
36
 
29
37
  self._scan_container: Container | None = None
30
38
  self._tool_server_port: int | None = None
31
39
  self._tool_server_token: str | None = None
32
40
 
33
- def _generate_sandbox_token(self) -> str:
34
- return secrets.token_urlsafe(32)
35
-
36
41
  def _find_available_port(self) -> int:
37
42
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
38
43
  s.bind(("", 0))
@@ -45,113 +50,118 @@ class DockerRuntime(AbstractRuntime):
45
50
  tracer = get_global_tracer()
46
51
  if tracer and tracer.scan_config:
47
52
  return str(tracer.scan_config.get("scan_id", "default-scan"))
48
- except ImportError:
49
- logger.debug("Failed to import tracer, using fallback scan ID")
50
- except AttributeError:
51
- logger.debug("Tracer missing scan_config, using fallback scan ID")
52
-
53
+ except (ImportError, AttributeError):
54
+ pass
53
55
  return f"scan-{agent_id.split('-')[0]}"
54
56
 
55
57
  def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None:
56
- def _validate_image(image: docker.models.images.Image) -> None:
57
- if not image.id or not image.attrs:
58
- raise ImageNotFound(f"Image {image_name} metadata incomplete")
59
-
60
58
  for attempt in range(max_retries):
61
59
  try:
62
60
  image = self.client.images.get(image_name)
63
- _validate_image(image)
64
- except ImageNotFound:
61
+ if not image.id or not image.attrs:
62
+ raise ImageNotFound(f"Image {image_name} metadata incomplete") # noqa: TRY301
63
+ except (ImageNotFound, DockerException):
65
64
  if attempt == max_retries - 1:
66
- logger.exception(f"Image {image_name} not found after {max_retries} attempts")
67
65
  raise
68
- logger.warning(f"Image {image_name} not ready, attempt {attempt + 1}/{max_retries}")
69
- time.sleep(2**attempt)
70
- except DockerException:
71
- if attempt == max_retries - 1:
72
- logger.exception(f"Failed to verify image {image_name}")
73
- raise
74
- logger.warning(f"Docker error verifying image, attempt {attempt + 1}/{max_retries}")
75
66
  time.sleep(2**attempt)
76
67
  else:
77
- logger.debug(f"Image {image_name} verified as available")
78
68
  return
79
69
 
80
- def _create_container_with_retry(self, scan_id: str, max_retries: int = 3) -> Container:
81
- last_exception = None
82
- container_name = f"strix-scan-{scan_id}"
70
+ def _recover_container_state(self, container: Container) -> None:
71
+ for env_var in container.attrs["Config"]["Env"]:
72
+ if env_var.startswith("TOOL_SERVER_TOKEN="):
73
+ self._tool_server_token = env_var.split("=", 1)[1]
74
+ break
75
+
76
+ port_bindings = container.attrs.get("NetworkSettings", {}).get("Ports", {})
77
+ port_key = f"{CONTAINER_TOOL_SERVER_PORT}/tcp"
78
+ if port_bindings.get(port_key):
79
+ self._tool_server_port = int(port_bindings[port_key][0]["HostPort"])
80
+
81
+ def _wait_for_tool_server(self, max_retries: int = 30, timeout: int = 5) -> None:
82
+ host = self._resolve_docker_host()
83
+ health_url = f"http://{host}:{self._tool_server_port}/health"
84
+
85
+ time.sleep(5)
83
86
 
84
87
  for attempt in range(max_retries):
85
88
  try:
86
- self._verify_image_available(STRIX_IMAGE)
89
+ with httpx.Client(trust_env=False, timeout=timeout) as client:
90
+ response = client.get(health_url)
91
+ if response.status_code == 200:
92
+ data = response.json()
93
+ if data.get("status") == "healthy":
94
+ return
95
+ except (httpx.ConnectError, httpx.TimeoutException, httpx.RequestError):
96
+ pass
97
+
98
+ time.sleep(min(2**attempt * 0.5, 5))
99
+
100
+ raise SandboxInitializationError(
101
+ "Tool server failed to start",
102
+ "Container initialization timed out. Please try again.",
103
+ )
87
104
 
88
- try:
89
- existing_container = self.client.containers.get(container_name)
90
- logger.warning(f"Container {container_name} already exists, removing it")
105
+ def _create_container(self, scan_id: str, max_retries: int = 2) -> Container:
106
+ container_name = f"strix-scan-{scan_id}"
107
+ image_name = Config.get("strix_image")
108
+ if not image_name:
109
+ raise ValueError("STRIX_IMAGE must be configured")
110
+
111
+ self._verify_image_available(image_name)
112
+
113
+ last_error: Exception | None = None
114
+ for attempt in range(max_retries + 1):
115
+ try:
116
+ with contextlib.suppress(NotFound):
117
+ existing = self.client.containers.get(container_name)
91
118
  with contextlib.suppress(Exception):
92
- existing_container.stop(timeout=5)
93
- existing_container.remove(force=True)
119
+ existing.stop(timeout=5)
120
+ existing.remove(force=True)
94
121
  time.sleep(1)
95
- except NotFound:
96
- pass
97
- except DockerException as e:
98
- logger.warning(f"Error checking/removing existing container: {e}")
99
-
100
- caido_port = self._find_available_port()
101
- tool_server_port = self._find_available_port()
102
- tool_server_token = self._generate_sandbox_token()
103
122
 
104
- self._tool_server_port = tool_server_port
105
- self._tool_server_token = tool_server_token
123
+ self._tool_server_port = self._find_available_port()
124
+ self._tool_server_token = secrets.token_urlsafe(32)
125
+ execution_timeout = Config.get("strix_sandbox_execution_timeout") or "120"
106
126
 
107
127
  container = self.client.containers.run(
108
- STRIX_IMAGE,
128
+ image_name,
109
129
  command="sleep infinity",
110
130
  detach=True,
111
131
  name=container_name,
112
- hostname=f"strix-scan-{scan_id}",
113
- ports={
114
- f"{caido_port}/tcp": caido_port,
115
- f"{tool_server_port}/tcp": tool_server_port,
116
- },
132
+ hostname=container_name,
133
+ ports={f"{CONTAINER_TOOL_SERVER_PORT}/tcp": self._tool_server_port},
117
134
  cap_add=["NET_ADMIN", "NET_RAW"],
118
135
  labels={"strix-scan-id": scan_id},
119
136
  environment={
120
137
  "PYTHONUNBUFFERED": "1",
121
- "CAIDO_PORT": str(caido_port),
122
- "TOOL_SERVER_PORT": str(tool_server_port),
123
- "TOOL_SERVER_TOKEN": tool_server_token,
138
+ "TOOL_SERVER_PORT": str(CONTAINER_TOOL_SERVER_PORT),
139
+ "TOOL_SERVER_TOKEN": self._tool_server_token,
140
+ "STRIX_SANDBOX_EXECUTION_TIMEOUT": str(execution_timeout),
141
+ "HOST_GATEWAY": HOST_GATEWAY_HOSTNAME,
124
142
  },
143
+ extra_hosts={HOST_GATEWAY_HOSTNAME: "host-gateway"},
125
144
  tty=True,
126
145
  )
127
146
 
128
147
  self._scan_container = container
129
- logger.info("Created container %s for scan %s", container.id, scan_id)
130
-
131
- self._initialize_container(
132
- container, caido_port, tool_server_port, tool_server_token
133
- )
134
- except DockerException as e:
135
- last_exception = e
136
- if attempt == max_retries - 1:
137
- logger.exception(f"Failed to create container after {max_retries} attempts")
138
- break
139
-
140
- logger.warning(f"Container creation attempt {attempt + 1}/{max_retries} failed")
141
-
142
- self._tool_server_port = None
143
- self._tool_server_token = None
144
-
145
- sleep_time = (2**attempt) + (0.1 * attempt)
146
- time.sleep(sleep_time)
148
+ self._wait_for_tool_server()
149
+
150
+ except (DockerException, RequestsConnectionError, RequestsTimeout) as e:
151
+ last_error = e
152
+ if attempt < max_retries:
153
+ self._tool_server_port = None
154
+ self._tool_server_token = None
155
+ time.sleep(2**attempt)
147
156
  else:
148
157
  return container
149
158
 
150
- raise RuntimeError(
151
- f"Failed to create Docker container after {max_retries} attempts: {last_exception}"
152
- ) from last_exception
159
+ raise SandboxInitializationError(
160
+ "Failed to create container",
161
+ f"Container creation failed after {max_retries + 1} attempts: {last_error}",
162
+ ) from last_error
153
163
 
154
- def _get_or_create_scan_container(self, scan_id: str) -> Container: # noqa: PLR0912
164
+ def _get_or_create_container(self, scan_id: str) -> Container:
155
165
  container_name = f"strix-scan-{scan_id}"
156
166
 
157
167
  if self._scan_container:
@@ -168,33 +178,14 @@ class DockerRuntime(AbstractRuntime):
168
178
  container = self.client.containers.get(container_name)
169
179
  container.reload()
170
180
 
171
- if (
172
- "strix-scan-id" not in container.labels
173
- or container.labels["strix-scan-id"] != scan_id
174
- ):
175
- logger.warning(
176
- f"Container {container_name} exists but missing/wrong label, updating"
177
- )
178
-
179
181
  if container.status != "running":
180
- logger.info(f"Starting existing container {container_name}")
181
182
  container.start()
182
183
  time.sleep(2)
183
184
 
184
185
  self._scan_container = container
185
-
186
- for env_var in container.attrs["Config"]["Env"]:
187
- if env_var.startswith("TOOL_SERVER_PORT="):
188
- self._tool_server_port = int(env_var.split("=")[1])
189
- elif env_var.startswith("TOOL_SERVER_TOKEN="):
190
- self._tool_server_token = env_var.split("=")[1]
191
-
192
- logger.info(f"Reusing existing container {container_name}")
193
-
186
+ self._recover_container_state(container)
194
187
  except NotFound:
195
188
  pass
196
- except DockerException as e:
197
- logger.warning(f"Failed to get container by name {container_name}: {e}")
198
189
  else:
199
190
  return container
200
191
 
@@ -203,52 +194,18 @@ class DockerRuntime(AbstractRuntime):
203
194
  all=True, filters={"label": f"strix-scan-id={scan_id}"}
204
195
  )
205
196
  if containers:
206
- container = cast("Container", containers[0])
197
+ container = containers[0]
207
198
  if container.status != "running":
208
199
  container.start()
209
200
  time.sleep(2)
210
- self._scan_container = container
211
-
212
- for env_var in container.attrs["Config"]["Env"]:
213
- if env_var.startswith("TOOL_SERVER_PORT="):
214
- self._tool_server_port = int(env_var.split("=")[1])
215
- elif env_var.startswith("TOOL_SERVER_TOKEN="):
216
- self._tool_server_token = env_var.split("=")[1]
217
201
 
218
- logger.info(f"Found existing container by label for scan {scan_id}")
202
+ self._scan_container = container
203
+ self._recover_container_state(container)
219
204
  return container
220
- except DockerException as e:
221
- logger.warning("Failed to find existing container by label for scan %s: %s", scan_id, e)
222
-
223
- logger.info("Creating new Docker container for scan %s", scan_id)
224
- return self._create_container_with_retry(scan_id)
225
-
226
- def _initialize_container(
227
- self, container: Container, caido_port: int, tool_server_port: int, tool_server_token: str
228
- ) -> None:
229
- logger.info("Initializing Caido proxy on port %s", caido_port)
230
- result = container.exec_run(
231
- f"bash -c 'export CAIDO_PORT={caido_port} && /usr/local/bin/docker-entrypoint.sh true'",
232
- detach=False,
233
- )
234
-
235
- time.sleep(5)
236
-
237
- result = container.exec_run(
238
- "bash -c 'source /etc/profile.d/proxy.sh && echo $CAIDO_API_TOKEN'", user="pentester"
239
- )
240
- caido_token = result.output.decode().strip() if result.exit_code == 0 else ""
241
-
242
- container.exec_run(
243
- f"bash -c 'source /etc/profile.d/proxy.sh && cd /app && "
244
- f"STRIX_SANDBOX_MODE=true CAIDO_API_TOKEN={caido_token} CAIDO_PORT={caido_port} "
245
- f"poetry run python strix/runtime/tool_server.py --token {tool_server_token} "
246
- f"--host 0.0.0.0 --port {tool_server_port} &'",
247
- detach=True,
248
- user="pentester",
249
- )
205
+ except DockerException:
206
+ pass
250
207
 
251
- time.sleep(5)
208
+ return self._create_container(scan_id)
252
209
 
253
210
  def _copy_local_directory_to_container(
254
211
  self, container: Container, local_path: str, target_name: str | None = None
@@ -259,17 +216,8 @@ class DockerRuntime(AbstractRuntime):
259
216
  try:
260
217
  local_path_obj = Path(local_path).resolve()
261
218
  if not local_path_obj.exists() or not local_path_obj.is_dir():
262
- logger.warning(f"Local path does not exist or is not directory: {local_path_obj}")
263
219
  return
264
220
 
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")
272
-
273
221
  tar_buffer = BytesIO()
274
222
  with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
275
223
  for item in local_path_obj.rglob("*"):
@@ -280,16 +228,12 @@ class DockerRuntime(AbstractRuntime):
280
228
 
281
229
  tar_buffer.seek(0)
282
230
  container.put_archive("/workspace", tar_buffer.getvalue())
283
-
284
231
  container.exec_run(
285
232
  "chown -R pentester:pentester /workspace && chmod -R 755 /workspace",
286
233
  user="root",
287
234
  )
288
-
289
- logger.info("Successfully copied local directory to /workspace")
290
-
291
235
  except (OSError, DockerException):
292
- logger.exception("Failed to copy local directory to container")
236
+ pass
293
237
 
294
238
  async def create_sandbox(
295
239
  self,
@@ -298,7 +242,7 @@ class DockerRuntime(AbstractRuntime):
298
242
  local_sources: list[dict[str, str]] | None = None,
299
243
  ) -> SandboxInfo:
300
244
  scan_id = self._get_scan_id(agent_id)
301
- container = self._get_or_create_scan_container(scan_id)
245
+ container = self._get_or_create_container(scan_id)
302
246
 
303
247
  source_copied_key = f"_source_copied_{scan_id}"
304
248
  if local_sources and not hasattr(self, source_copied_key):
@@ -306,40 +250,33 @@ class DockerRuntime(AbstractRuntime):
306
250
  source_path = source.get("source_path")
307
251
  if not source_path:
308
252
  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
-
253
+ target_name = (
254
+ source.get("workspace_subdir") or Path(source_path).name or f"target_{index}"
255
+ )
314
256
  self._copy_local_directory_to_container(container, source_path, target_name)
315
257
  setattr(self, source_copied_key, True)
316
258
 
317
- container_id = container.id
318
- if container_id is None:
259
+ if container.id is None:
319
260
  raise RuntimeError("Docker container ID is unexpectedly None")
320
261
 
321
- token = existing_token if existing_token is not None else self._tool_server_token
322
-
262
+ token = existing_token or self._tool_server_token
323
263
  if self._tool_server_port is None or token is None:
324
- raise RuntimeError("Tool server not initialized or no token available")
264
+ raise RuntimeError("Tool server not initialized")
325
265
 
326
- api_url = await self.get_sandbox_url(container_id, self._tool_server_port)
266
+ host = self._resolve_docker_host()
267
+ api_url = f"http://{host}:{self._tool_server_port}"
327
268
 
328
- await self._register_agent_with_tool_server(api_url, agent_id, token)
269
+ await self._register_agent(api_url, agent_id, token)
329
270
 
330
271
  return {
331
- "workspace_id": container_id,
272
+ "workspace_id": container.id,
332
273
  "api_url": api_url,
333
274
  "auth_token": token,
334
275
  "tool_server_port": self._tool_server_port,
335
276
  "agent_id": agent_id,
336
277
  }
337
278
 
338
- async def _register_agent_with_tool_server(
339
- self, api_url: str, agent_id: str, token: str
340
- ) -> None:
341
- import httpx
342
-
279
+ async def _register_agent(self, api_url: str, agent_id: str, token: str) -> None:
343
280
  try:
344
281
  async with httpx.AsyncClient(trust_env=False) as client:
345
282
  response = await client.post(
@@ -349,51 +286,33 @@ class DockerRuntime(AbstractRuntime):
349
286
  timeout=30,
350
287
  )
351
288
  response.raise_for_status()
352
- logger.info(f"Registered agent {agent_id} with tool server")
353
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
354
- logger.warning(f"Failed to register agent {agent_id}: {e}")
289
+ except httpx.RequestError:
290
+ pass
355
291
 
356
292
  async def get_sandbox_url(self, container_id: str, port: int) -> str:
357
293
  try:
358
- container = self.client.containers.get(container_id)
359
- container.reload()
360
-
361
- host = self._resolve_docker_host()
362
-
294
+ self.client.containers.get(container_id)
295
+ return f"http://{self._resolve_docker_host()}:{port}"
363
296
  except NotFound:
364
297
  raise ValueError(f"Container {container_id} not found.") from None
365
- except DockerException as e:
366
- raise RuntimeError(f"Failed to get container URL for {container_id}: {e}") from e
367
- else:
368
- return f"http://{host}:{port}"
369
298
 
370
299
  def _resolve_docker_host(self) -> str:
371
300
  docker_host = os.getenv("DOCKER_HOST", "")
372
- if not docker_host:
373
- return "127.0.0.1"
374
-
375
- from urllib.parse import urlparse
376
-
377
- parsed = urlparse(docker_host)
378
-
379
- if parsed.scheme in ("tcp", "http", "https") and parsed.hostname:
380
- return parsed.hostname
301
+ if docker_host:
302
+ from urllib.parse import urlparse
381
303
 
304
+ parsed = urlparse(docker_host)
305
+ if parsed.scheme in ("tcp", "http", "https") and parsed.hostname:
306
+ return parsed.hostname
382
307
  return "127.0.0.1"
383
308
 
384
309
  async def destroy_sandbox(self, container_id: str) -> None:
385
- logger.info("Destroying scan container %s", container_id)
386
310
  try:
387
311
  container = self.client.containers.get(container_id)
388
312
  container.stop()
389
313
  container.remove()
390
- logger.info("Successfully destroyed container %s", container_id)
391
-
392
314
  self._scan_container = None
393
315
  self._tool_server_port = None
394
316
  self._tool_server_token = None
395
-
396
- except NotFound:
397
- logger.warning("Container %s not found for destruction.", container_id)
398
- except DockerException as e:
399
- logger.warning("Failed to destroy container %s: %s", container_id, e)
317
+ except (NotFound, DockerException):
318
+ pass