strix-agent 0.1.8__py3-none-any.whl → 0.1.10__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 (35) hide show
  1. strix/agents/StrixAgent/strix_agent.py +18 -6
  2. strix/agents/StrixAgent/system_prompt.jinja +29 -203
  3. strix/agents/base_agent.py +3 -0
  4. strix/cli/app.py +3 -1
  5. strix/cli/main.py +95 -8
  6. strix/cli/tool_components/terminal_renderer.py +92 -60
  7. strix/llm/config.py +1 -1
  8. strix/llm/llm.py +66 -2
  9. strix/llm/memory_compressor.py +1 -1
  10. strix/prompts/__init__.py +9 -13
  11. strix/prompts/vulnerabilities/authentication_jwt.jinja +7 -7
  12. strix/prompts/vulnerabilities/csrf.jinja +1 -1
  13. strix/prompts/vulnerabilities/idor.jinja +3 -3
  14. strix/prompts/vulnerabilities/rce.jinja +1 -1
  15. strix/prompts/vulnerabilities/sql_injection.jinja +3 -3
  16. strix/prompts/vulnerabilities/xss.jinja +3 -3
  17. strix/prompts/vulnerabilities/xxe.jinja +1 -1
  18. strix/runtime/docker_runtime.py +204 -160
  19. strix/runtime/runtime.py +3 -2
  20. strix/runtime/tool_server.py +136 -28
  21. strix/tools/agents_graph/agents_graph_actions.py +4 -10
  22. strix/tools/agents_graph/agents_graph_actions_schema.xml +18 -12
  23. strix/tools/argument_parser.py +2 -1
  24. strix/tools/executor.py +3 -0
  25. strix/tools/terminal/__init__.py +2 -2
  26. strix/tools/terminal/terminal_actions.py +22 -40
  27. strix/tools/terminal/terminal_actions_schema.xml +113 -84
  28. strix/tools/terminal/terminal_manager.py +83 -123
  29. strix/tools/terminal/terminal_session.py +447 -0
  30. {strix_agent-0.1.8.dist-info → strix_agent-0.1.10.dist-info}/METADATA +6 -4
  31. {strix_agent-0.1.8.dist-info → strix_agent-0.1.10.dist-info}/RECORD +34 -34
  32. strix/tools/terminal/terminal_instance.py +0 -231
  33. {strix_agent-0.1.8.dist-info → strix_agent-0.1.10.dist-info}/LICENSE +0 -0
  34. {strix_agent-0.1.8.dist-info → strix_agent-0.1.10.dist-info}/WHEEL +0 -0
  35. {strix_agent-0.1.8.dist-info → strix_agent-0.1.10.dist-info}/entry_points.txt +0 -0
@@ -7,19 +7,15 @@ from pathlib import Path
7
7
  from typing import cast
8
8
 
9
9
  import docker
10
- from docker.errors import DockerException, NotFound
10
+ from docker.errors import DockerException, ImageNotFound, NotFound
11
11
  from docker.models.containers import Container
12
12
 
13
13
  from .runtime import AbstractRuntime, SandboxInfo
14
14
 
15
15
 
16
- STRIX_AGENT_LABEL = "StrixAgent_ID"
17
- STRIX_SCAN_LABEL = "StrixScan_ID"
18
- STRIX_IMAGE = os.getenv("STRIX_IMAGE", "ghcr.io/usestrix/strix-sandbox:0.1.4")
16
+ STRIX_IMAGE = os.getenv("STRIX_IMAGE", "ghcr.io/usestrix/strix-sandbox:0.1.10")
19
17
  logger = logging.getLogger(__name__)
20
18
 
21
- _initialized_volumes: set[str] = set()
22
-
23
19
 
24
20
  class DockerRuntime(AbstractRuntime):
25
21
  def __init__(self) -> None:
@@ -29,9 +25,18 @@ class DockerRuntime(AbstractRuntime):
29
25
  logger.exception("Failed to connect to Docker daemon")
30
26
  raise RuntimeError("Docker is not available or not configured correctly.") from e
31
27
 
28
+ self._scan_container: Container | None = None
29
+ self._tool_server_port: int | None = None
30
+ self._tool_server_token: str | None = None
31
+
32
32
  def _generate_sandbox_token(self) -> str:
33
33
  return secrets.token_urlsafe(32)
34
34
 
35
+ def _find_available_port(self) -> int:
36
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
37
+ s.bind(("", 0))
38
+ return cast("int", s.getsockname()[1])
39
+
35
40
  def _get_scan_id(self, agent_id: str) -> str:
36
41
  try:
37
42
  from strix.cli.tracer import get_global_tracer
@@ -46,37 +51,151 @@ class DockerRuntime(AbstractRuntime):
46
51
 
47
52
  return f"scan-{agent_id.split('-')[0]}"
48
53
 
49
- def _find_available_port(self) -> int:
50
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
51
- s.bind(("", 0))
52
- return cast("int", s.getsockname()[1])
54
+ def _verify_image_available(self, image_name: str, max_retries: int = 3) -> None:
55
+ def _validate_image(image: docker.models.images.Image) -> None:
56
+ if not image.id or not image.attrs:
57
+ raise ImageNotFound(f"Image {image_name} metadata incomplete")
58
+
59
+ for attempt in range(max_retries):
60
+ try:
61
+ image = self.client.images.get(image_name)
62
+ _validate_image(image)
63
+ except ImageNotFound:
64
+ if attempt == max_retries - 1:
65
+ logger.exception(f"Image {image_name} not found after {max_retries} attempts")
66
+ raise
67
+ logger.warning(f"Image {image_name} not ready, attempt {attempt + 1}/{max_retries}")
68
+ time.sleep(2**attempt)
69
+ except DockerException:
70
+ if attempt == max_retries - 1:
71
+ logger.exception(f"Failed to verify image {image_name}")
72
+ raise
73
+ logger.warning(f"Docker error verifying image, attempt {attempt + 1}/{max_retries}")
74
+ time.sleep(2**attempt)
75
+ else:
76
+ logger.debug(f"Image {image_name} verified as available")
77
+ return
53
78
 
54
- def _get_workspace_volume_name(self, scan_id: str) -> str:
55
- return f"strix-workspace-{scan_id}"
79
+ def _create_container_with_retry(self, scan_id: str, max_retries: int = 3) -> Container:
80
+ last_exception = None
56
81
 
57
- def _get_sandbox_by_agent_id(self, agent_id: str) -> Container | None:
58
- try:
59
- containers = self.client.containers.list(
60
- filters={"label": f"{STRIX_AGENT_LABEL}={agent_id}"}
61
- )
62
- if not containers:
63
- return None
64
- if len(containers) > 1:
65
- logger.warning(
66
- "Multiple sandboxes found for agent ID %s, using the first one.", agent_id
82
+ for attempt in range(max_retries):
83
+ try:
84
+ self._verify_image_available(STRIX_IMAGE)
85
+
86
+ caido_port = self._find_available_port()
87
+ tool_server_port = self._find_available_port()
88
+ tool_server_token = self._generate_sandbox_token()
89
+
90
+ self._tool_server_port = tool_server_port
91
+ self._tool_server_token = tool_server_token
92
+
93
+ container = self.client.containers.run(
94
+ STRIX_IMAGE,
95
+ command="sleep infinity",
96
+ detach=True,
97
+ name=f"strix-scan-{scan_id}",
98
+ hostname=f"strix-scan-{scan_id}",
99
+ ports={
100
+ f"{caido_port}/tcp": caido_port,
101
+ f"{tool_server_port}/tcp": tool_server_port,
102
+ },
103
+ cap_add=["NET_ADMIN", "NET_RAW"],
104
+ labels={"strix-scan-id": scan_id},
105
+ environment={
106
+ "PYTHONUNBUFFERED": "1",
107
+ "CAIDO_PORT": str(caido_port),
108
+ "TOOL_SERVER_PORT": str(tool_server_port),
109
+ "TOOL_SERVER_TOKEN": tool_server_token,
110
+ },
111
+ tty=True,
67
112
  )
68
- return cast("Container", containers[0])
69
- except DockerException as e:
70
- logger.warning("Failed to get sandbox by agent ID %s: %s", agent_id, e)
71
- return None
72
113
 
73
- def _ensure_workspace_volume(self, volume_name: str) -> None:
114
+ self._scan_container = container
115
+ logger.info("Created container %s for scan %s", container.id, scan_id)
116
+
117
+ self._initialize_container(
118
+ container, caido_port, tool_server_port, tool_server_token
119
+ )
120
+ except DockerException as e:
121
+ last_exception = e
122
+ if attempt == max_retries - 1:
123
+ logger.exception(f"Failed to create container after {max_retries} attempts")
124
+ break
125
+
126
+ logger.warning(f"Container creation attempt {attempt + 1}/{max_retries} failed")
127
+
128
+ self._tool_server_port = None
129
+ self._tool_server_token = None
130
+
131
+ sleep_time = (2**attempt) + (0.1 * attempt)
132
+ time.sleep(sleep_time)
133
+ else:
134
+ return container
135
+
136
+ raise RuntimeError(
137
+ f"Failed to create Docker container after {max_retries} attempts: {last_exception}"
138
+ ) from last_exception
139
+
140
+ def _get_or_create_scan_container(self, scan_id: str) -> Container:
141
+ if self._scan_container:
142
+ try:
143
+ self._scan_container.reload()
144
+ if self._scan_container.status == "running":
145
+ return self._scan_container
146
+ except NotFound:
147
+ self._scan_container = None
148
+ self._tool_server_port = None
149
+ self._tool_server_token = None
150
+
74
151
  try:
75
- self.client.volumes.get(volume_name)
76
- logger.info(f"Using existing workspace volume: {volume_name}")
77
- except NotFound:
78
- self.client.volumes.create(name=volume_name, driver="local")
79
- logger.info(f"Created new workspace volume: {volume_name}")
152
+ containers = self.client.containers.list(filters={"label": f"strix-scan-id={scan_id}"})
153
+ if containers:
154
+ container = cast("Container", containers[0])
155
+ if container.status != "running":
156
+ container.start()
157
+ time.sleep(2)
158
+ self._scan_container = container
159
+
160
+ for env_var in container.attrs["Config"]["Env"]:
161
+ if env_var.startswith("TOOL_SERVER_PORT="):
162
+ self._tool_server_port = int(env_var.split("=")[1])
163
+ elif env_var.startswith("TOOL_SERVER_TOKEN="):
164
+ self._tool_server_token = env_var.split("=")[1]
165
+
166
+ return container
167
+ except DockerException as e:
168
+ logger.warning("Failed to find existing container for scan %s: %s", scan_id, e)
169
+
170
+ logger.info("Creating new Docker container for scan %s", scan_id)
171
+ return self._create_container_with_retry(scan_id)
172
+
173
+ def _initialize_container(
174
+ self, container: Container, caido_port: int, tool_server_port: int, tool_server_token: str
175
+ ) -> None:
176
+ logger.info("Initializing Caido proxy on port %s", caido_port)
177
+ result = container.exec_run(
178
+ f"bash -c 'export CAIDO_PORT={caido_port} && /usr/local/bin/docker-entrypoint.sh true'",
179
+ detach=False,
180
+ )
181
+
182
+ time.sleep(5)
183
+
184
+ result = container.exec_run(
185
+ "bash -c 'source /etc/profile.d/proxy.sh && echo $CAIDO_API_TOKEN'", user="pentester"
186
+ )
187
+ caido_token = result.output.decode().strip() if result.exit_code == 0 else ""
188
+
189
+ container.exec_run(
190
+ f"bash -c 'source /etc/profile.d/proxy.sh && cd /app && "
191
+ f"STRIX_SANDBOX_MODE=true CAIDO_API_TOKEN={caido_token} CAIDO_PORT={caido_port} "
192
+ f"poetry run python strix/runtime/tool_server.py --token {tool_server_token} "
193
+ f"--host 0.0.0.0 --port {tool_server_port} &'",
194
+ detach=True,
195
+ user="pentester",
196
+ )
197
+
198
+ time.sleep(5)
80
199
 
81
200
  def _copy_local_directory_to_container(self, container: Container, local_path: str) -> None:
82
201
  import tarfile
@@ -85,10 +204,10 @@ class DockerRuntime(AbstractRuntime):
85
204
  try:
86
205
  local_path_obj = Path(local_path).resolve()
87
206
  if not local_path_obj.exists() or not local_path_obj.is_dir():
88
- logger.warning(f"Local path does not exist or is not a directory: {local_path_obj}")
207
+ logger.warning(f"Local path does not exist or is not directory: {local_path_obj}")
89
208
  return
90
209
 
91
- logger.info(f"Copying local directory {local_path_obj} to container {container.id}")
210
+ logger.info(f"Copying local directory {local_path_obj} to container")
92
211
 
93
212
  tar_buffer = BytesIO()
94
213
  with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
@@ -98,18 +217,14 @@ class DockerRuntime(AbstractRuntime):
98
217
  tar.add(item, arcname=arcname)
99
218
 
100
219
  tar_buffer.seek(0)
101
-
102
- container.put_archive("/shared_workspace", tar_buffer.getvalue())
220
+ container.put_archive("/workspace", tar_buffer.getvalue())
103
221
 
104
222
  container.exec_run(
105
- "chown -R pentester:pentester /shared_workspace && chmod -R 755 /shared_workspace",
223
+ "chown -R pentester:pentester /workspace && chmod -R 755 /workspace",
106
224
  user="root",
107
225
  )
108
226
 
109
- logger.info(
110
- f"Successfully copied {local_path_obj} to /shared_workspace in container "
111
- f"{container.id}"
112
- )
227
+ logger.info("Successfully copied local directory to /workspace")
113
228
 
114
229
  except (OSError, DockerException):
115
230
  logger.exception("Failed to copy local directory to container")
@@ -117,94 +232,56 @@ class DockerRuntime(AbstractRuntime):
117
232
  async def create_sandbox(
118
233
  self, agent_id: str, existing_token: str | None = None, local_source_path: str | None = None
119
234
  ) -> SandboxInfo:
120
- sandbox = self._get_sandbox_by_agent_id(agent_id)
121
- auth_token = existing_token or self._generate_sandbox_token()
122
-
123
235
  scan_id = self._get_scan_id(agent_id)
124
- volume_name = self._get_workspace_volume_name(scan_id)
125
-
126
- self._ensure_workspace_volume(volume_name)
127
-
128
- if not sandbox:
129
- logger.info("Creating new Docker sandbox for agent %s", agent_id)
130
- try:
131
- tool_server_port = self._find_available_port()
132
- caido_port = self._find_available_port()
236
+ container = self._get_or_create_scan_container(scan_id)
133
237
 
134
- volumes_config = {volume_name: {"bind": "/shared_workspace", "mode": "rw"}}
135
- container_name = f"strix-{agent_id}"
136
-
137
- sandbox = self.client.containers.run(
138
- STRIX_IMAGE,
139
- command="sleep infinity",
140
- detach=True,
141
- name=container_name,
142
- hostname=container_name,
143
- ports={
144
- f"{tool_server_port}/tcp": tool_server_port,
145
- f"{caido_port}/tcp": caido_port,
146
- },
147
- cap_add=["NET_ADMIN", "NET_RAW"],
148
- labels={
149
- STRIX_AGENT_LABEL: agent_id,
150
- STRIX_SCAN_LABEL: scan_id,
151
- },
152
- environment={
153
- "PYTHONUNBUFFERED": "1",
154
- "STRIX_AGENT_ID": agent_id,
155
- "STRIX_SANDBOX_TOKEN": auth_token,
156
- "STRIX_TOOL_SERVER_PORT": str(tool_server_port),
157
- "CAIDO_PORT": str(caido_port),
158
- },
159
- volumes=volumes_config,
160
- tty=True,
161
- )
162
- logger.info(
163
- "Created new sandbox %s for agent %s with shared workspace %s",
164
- sandbox.id,
165
- agent_id,
166
- volume_name,
167
- )
168
- except DockerException as e:
169
- raise RuntimeError(f"Failed to create Docker sandbox: {e}") from e
238
+ source_copied_key = f"_source_copied_{scan_id}"
239
+ if local_source_path and not hasattr(self, source_copied_key):
240
+ self._copy_local_directory_to_container(container, local_source_path)
241
+ setattr(self, source_copied_key, True)
170
242
 
171
- assert sandbox is not None
172
- if sandbox.status != "running":
173
- sandbox.start()
174
- time.sleep(15)
243
+ container_id = container.id
244
+ if container_id is None:
245
+ raise RuntimeError("Docker container ID is unexpectedly None")
175
246
 
176
- if local_source_path and volume_name not in _initialized_volumes:
177
- self._copy_local_directory_to_container(sandbox, local_source_path)
178
- _initialized_volumes.add(volume_name)
247
+ token = existing_token if existing_token is not None else self._tool_server_token
179
248
 
180
- sandbox_id = sandbox.id
181
- if sandbox_id is None:
182
- raise RuntimeError("Docker container ID is unexpectedly None")
249
+ if self._tool_server_port is None or token is None:
250
+ raise RuntimeError("Tool server not initialized or no token available")
183
251
 
184
- tool_server_port_str = sandbox.attrs["Config"]["Env"][
185
- next(
186
- (
187
- i
188
- for i, s in enumerate(sandbox.attrs["Config"]["Env"])
189
- if s.startswith("STRIX_TOOL_SERVER_PORT=")
190
- ),
191
- -1,
192
- )
193
- ].split("=")[1]
194
- tool_server_port = int(tool_server_port_str)
252
+ api_url = await self.get_sandbox_url(container_id, self._tool_server_port)
195
253
 
196
- api_url = await self.get_sandbox_url(sandbox_id, tool_server_port)
254
+ await self._register_agent_with_tool_server(api_url, agent_id, token)
197
255
 
198
256
  return {
199
- "workspace_id": sandbox_id,
257
+ "workspace_id": container_id,
200
258
  "api_url": api_url,
201
- "auth_token": auth_token,
202
- "tool_server_port": tool_server_port,
259
+ "auth_token": token,
260
+ "tool_server_port": self._tool_server_port,
261
+ "agent_id": agent_id,
203
262
  }
204
263
 
205
- async def get_sandbox_url(self, sandbox_id: str, port: int) -> str:
264
+ async def _register_agent_with_tool_server(
265
+ self, api_url: str, agent_id: str, token: str
266
+ ) -> None:
267
+ import httpx
268
+
269
+ try:
270
+ async with httpx.AsyncClient() as client:
271
+ response = await client.post(
272
+ f"{api_url}/register_agent",
273
+ params={"agent_id": agent_id},
274
+ headers={"Authorization": f"Bearer {token}"},
275
+ timeout=30,
276
+ )
277
+ response.raise_for_status()
278
+ logger.info(f"Registered agent {agent_id} with tool server")
279
+ except (httpx.RequestError, httpx.HTTPStatusError) as e:
280
+ logger.warning(f"Failed to register agent {agent_id}: {e}")
281
+
282
+ async def get_sandbox_url(self, container_id: str, port: int) -> str:
206
283
  try:
207
- container = self.client.containers.get(sandbox_id)
284
+ container = self.client.containers.get(container_id)
208
285
  container.reload()
209
286
 
210
287
  host = "localhost"
@@ -214,58 +291,25 @@ class DockerRuntime(AbstractRuntime):
214
291
  host = docker_host.split("://")[1].split(":")[0]
215
292
 
216
293
  except NotFound:
217
- raise ValueError(f"Sandbox {sandbox_id} not found.") from None
294
+ raise ValueError(f"Container {container_id} not found.") from None
218
295
  except DockerException as e:
219
- raise RuntimeError(f"Failed to get sandbox URL for {sandbox_id}: {e}") from e
296
+ raise RuntimeError(f"Failed to get container URL for {container_id}: {e}") from e
220
297
  else:
221
298
  return f"http://{host}:{port}"
222
299
 
223
- async def destroy_sandbox(self, sandbox_id: str) -> None:
224
- logger.info("Destroying Docker sandbox %s", sandbox_id)
300
+ async def destroy_sandbox(self, container_id: str) -> None:
301
+ logger.info("Destroying scan container %s", container_id)
225
302
  try:
226
- container = self.client.containers.get(sandbox_id)
227
-
228
- scan_id = None
229
- if container.labels and STRIX_SCAN_LABEL in container.labels:
230
- scan_id = container.labels[STRIX_SCAN_LABEL]
231
-
303
+ container = self.client.containers.get(container_id)
232
304
  container.stop()
233
305
  container.remove()
234
- logger.info("Successfully destroyed sandbox %s", sandbox_id)
306
+ logger.info("Successfully destroyed container %s", container_id)
235
307
 
236
- if scan_id:
237
- await self._cleanup_workspace_if_empty(scan_id)
308
+ self._scan_container = None
309
+ self._tool_server_port = None
310
+ self._tool_server_token = None
238
311
 
239
312
  except NotFound:
240
- logger.warning("Sandbox %s not found for destruction.", sandbox_id)
313
+ logger.warning("Container %s not found for destruction.", container_id)
241
314
  except DockerException as e:
242
- logger.warning("Failed to destroy sandbox %s: %s", sandbox_id, e)
243
-
244
- async def _cleanup_workspace_if_empty(self, scan_id: str) -> None:
245
- try:
246
- volume_name = self._get_workspace_volume_name(scan_id)
247
-
248
- containers = self.client.containers.list(
249
- all=True, filters={"label": f"{STRIX_SCAN_LABEL}={scan_id}"}
250
- )
251
-
252
- if not containers:
253
- try:
254
- volume = self.client.volumes.get(volume_name)
255
- volume.remove()
256
- logger.info(
257
- f"Cleaned up workspace volume {volume_name} for completed scan {scan_id}"
258
- )
259
-
260
- _initialized_volumes.discard(volume_name)
261
-
262
- except NotFound:
263
- logger.debug(f"Volume {volume_name} already removed")
264
- except DockerException as e:
265
- logger.warning(f"Failed to remove volume {volume_name}: {e}")
266
-
267
- except DockerException as e:
268
- logger.warning("Error during workspace cleanup for scan %s: %s", scan_id, e)
269
-
270
- async def cleanup_scan_workspace(self, scan_id: str) -> None:
271
- await self._cleanup_workspace_if_empty(scan_id)
315
+ logger.warning("Failed to destroy container %s: %s", container_id, e)
strix/runtime/runtime.py CHANGED
@@ -7,6 +7,7 @@ class SandboxInfo(TypedDict):
7
7
  api_url: str
8
8
  auth_token: str | None
9
9
  tool_server_port: int
10
+ agent_id: str
10
11
 
11
12
 
12
13
  class AbstractRuntime(ABC):
@@ -17,9 +18,9 @@ class AbstractRuntime(ABC):
17
18
  raise NotImplementedError
18
19
 
19
20
  @abstractmethod
20
- async def get_sandbox_url(self, sandbox_id: str, port: int) -> str:
21
+ async def get_sandbox_url(self, container_id: str, port: int) -> str:
21
22
  raise NotImplementedError
22
23
 
23
24
  @abstractmethod
24
- async def destroy_sandbox(self, sandbox_id: str) -> None:
25
+ async def destroy_sandbox(self, container_id: str) -> None:
25
26
  raise NotImplementedError