Skip to content

Commit b646bea

Browse files
committed
address PR review feedback
- Use sha256 hash of full image ref (+ optional CONTAINER_PERSIST_KEY) for deterministic container names, avoiding collisions on long prefixes - Add timeouts to all docker subprocess calls (_DOCKER_TIMEOUT = 30s) - Extract _remove_container() helper with failure logging - Use docker inspect --format json + json.loads for _is_running() - Handle TimeoutExpired and JSONDecodeError gracefully - Add CONTAINER_PERSIST_KEY env var to all toolbox YAMLs - Add 11 tests covering persistent container behavior: name hashing, reuse, no --rm flag, stop skip, error logging
1 parent 5e1a0b0 commit b646bea

File tree

6 files changed

+151
-16
lines changed

6 files changed

+151
-16
lines changed

src/seclab_taskflows/mcp_servers/container_shell.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
# SPDX-License-Identifier: MIT
33

44
import atexit
5+
import hashlib
6+
import json
57
import logging
68
import os
7-
import re
89
import subprocess
910
import uuid
1011
from typing import Annotated
@@ -28,24 +29,58 @@
2829
CONTAINER_WORKSPACE = os.environ.get("CONTAINER_WORKSPACE", "")
2930
CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30"))
3031
CONTAINER_PERSIST = os.environ.get("CONTAINER_PERSIST", "").lower() in ("1", "true", "yes")
32+
CONTAINER_PERSIST_KEY = os.environ.get("CONTAINER_PERSIST_KEY", "")
3133

3234
_DEFAULT_WORKDIR = "/workspace"
35+
_DOCKER_TIMEOUT = 30
3336

3437

3538
def _persistent_name() -> str:
36-
"""Derive a deterministic container name from the image for reuse across tasks."""
37-
slug = re.sub(r"[^a-zA-Z0-9]", "-", CONTAINER_IMAGE).strip("-")[:40]
38-
return f"seclab-persist-{slug}"
39+
"""Derive a deterministic container name from the image for reuse across tasks.
40+
41+
Incorporates a hash of the full image reference (and optional
42+
CONTAINER_PERSIST_KEY) to avoid collisions between long image names that
43+
share a common prefix, or between independent runs of the same image.
44+
"""
45+
key_material = CONTAINER_IMAGE
46+
if CONTAINER_PERSIST_KEY:
47+
key_material += f":{CONTAINER_PERSIST_KEY}"
48+
digest = hashlib.sha256(key_material.encode()).hexdigest()[:12]
49+
return f"seclab-persist-{digest}"
3950

4051

4152
def _is_running(name: str) -> bool:
4253
"""Check if a container with the given name is already running."""
43-
result = subprocess.run(
44-
["docker", "inspect", "-f", "{{.State.Running}}", name],
45-
capture_output=True,
46-
text=True,
47-
)
48-
return result.returncode == 0 and result.stdout.strip() == "true"
54+
try:
55+
result = subprocess.run(
56+
["docker", "inspect", "--format", "json", name],
57+
capture_output=True,
58+
text=True,
59+
timeout=_DOCKER_TIMEOUT,
60+
)
61+
if result.returncode != 0:
62+
return False
63+
data = json.loads(result.stdout)
64+
return bool(data and data[0].get("State", {}).get("Running"))
65+
except (subprocess.TimeoutExpired, json.JSONDecodeError, IndexError):
66+
return False
67+
68+
69+
def _remove_container(name: str) -> None:
70+
"""Remove a stopped container by name. Logs failures for diagnostics."""
71+
try:
72+
result = subprocess.run(
73+
["docker", "rm", "-f", name],
74+
capture_output=True,
75+
text=True,
76+
timeout=_DOCKER_TIMEOUT,
77+
)
78+
if result.returncode != 0:
79+
logging.warning(
80+
"docker rm -f failed for %s: %s", name, result.stderr.strip()
81+
)
82+
except subprocess.TimeoutExpired:
83+
logging.error("docker rm -f timed out for %s after %ds", name, _DOCKER_TIMEOUT)
4984

5085

5186
def _start_container() -> str:
@@ -62,12 +97,8 @@ def _start_container() -> str:
6297
if _is_running(name):
6398
logging.debug(f"Reusing persistent container: {name}")
6499
return name
65-
# Remove stopped leftover with the same name (ignore errors)
66-
subprocess.run(
67-
["docker", "rm", "-f", name],
68-
capture_output=True,
69-
text=True,
70-
)
100+
# Remove stopped leftover with the same name
101+
_remove_container(name)
71102
else:
72103
name = f"seclab-shell-{uuid.uuid4().hex[:8]}"
73104

src/seclab_taskflows/toolboxes/container_shell_base.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ server_params:
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
1616
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1718
LOG_DIR: "{{ env('LOG_DIR') }}"
1819

1920
confirm:

src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ server_params:
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
1616
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1718
LOG_DIR: "{{ env('LOG_DIR') }}"
1819

1920
confirm:

src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ server_params:
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
1616
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1718
LOG_DIR: "{{ env('LOG_DIR') }}"
1819

1920
confirm:

src/seclab_taskflows/toolboxes/container_shell_sast.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ server_params:
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
1616
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1718
LOG_DIR: "{{ env('LOG_DIR') }}"
1819

1920
confirm:

tests/test_container_shell.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,106 @@ def test_stop_container_clears_name_on_failure(self):
188188
assert cs_mod._container_name is None
189189

190190

191+
# ---------------------------------------------------------------------------
192+
# Persistent container tests
193+
# ---------------------------------------------------------------------------
194+
195+
class TestPersistentContainer:
196+
def setup_method(self):
197+
_reset_container()
198+
199+
def test_persistent_name_uses_hash(self):
200+
with patch.object(cs_mod, "CONTAINER_IMAGE", "myregistry.io/org/image:v1.2.3"):
201+
with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""):
202+
name = cs_mod._persistent_name()
203+
assert name.startswith("seclab-persist-")
204+
assert len(name) == len("seclab-persist-") + 12
205+
206+
def test_persistent_name_varies_with_key(self):
207+
with patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"):
208+
with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""):
209+
name_a = cs_mod._persistent_name()
210+
with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", "run-42"):
211+
name_b = cs_mod._persistent_name()
212+
assert name_a != name_b
213+
214+
def test_persistent_name_differs_for_different_images(self):
215+
with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""):
216+
with patch.object(cs_mod, "CONTAINER_IMAGE", "image-a:latest"):
217+
name_a = cs_mod._persistent_name()
218+
with patch.object(cs_mod, "CONTAINER_IMAGE", "image-b:latest"):
219+
name_b = cs_mod._persistent_name()
220+
assert name_a != name_b
221+
222+
def test_start_reuses_running_persistent_container(self):
223+
inspect_proc = _make_proc(
224+
returncode=0,
225+
stdout='[{"State":{"Running":true}}]',
226+
)
227+
with (
228+
patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"),
229+
patch.object(cs_mod, "CONTAINER_WORKSPACE", ""),
230+
patch.object(cs_mod, "CONTAINER_PERSIST", True),
231+
patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""),
232+
patch("subprocess.run", return_value=inspect_proc) as mock_run,
233+
):
234+
name = cs_mod._start_container()
235+
assert name.startswith("seclab-persist-")
236+
# Only docker inspect should be called, NOT docker run
237+
assert mock_run.call_count == 1
238+
assert "inspect" in mock_run.call_args[0][0]
239+
240+
def test_start_persistent_no_rm_flag(self):
241+
inspect_proc = _make_proc(
242+
returncode=1,
243+
stdout="",
244+
)
245+
rm_proc = _make_proc(returncode=0)
246+
run_proc = _make_proc(returncode=0)
247+
with (
248+
patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"),
249+
patch.object(cs_mod, "CONTAINER_WORKSPACE", ""),
250+
patch.object(cs_mod, "CONTAINER_PERSIST", True),
251+
patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""),
252+
patch("subprocess.run", side_effect=[inspect_proc, rm_proc, run_proc]) as mock_run,
253+
):
254+
name = cs_mod._start_container()
255+
assert name.startswith("seclab-persist-")
256+
# The docker run call is the third one
257+
run_cmd = mock_run.call_args_list[2][0][0]
258+
assert "--rm" not in run_cmd
259+
260+
def test_stop_skips_persistent_container(self):
261+
cs_mod._container_name = "seclab-persist-abc123"
262+
with (
263+
patch.object(cs_mod, "CONTAINER_PERSIST", True),
264+
patch("subprocess.run") as mock_run,
265+
):
266+
cs_mod._stop_container()
267+
mock_run.assert_not_called()
268+
assert cs_mod._container_name is None
269+
270+
def test_remove_container_logs_failure(self):
271+
with patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="conflict")):
272+
with patch.object(cs_mod.logging, "warning") as mock_warn:
273+
cs_mod._remove_container("test-name")
274+
mock_warn.assert_called_once()
275+
276+
def test_remove_container_logs_timeout(self):
277+
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)):
278+
with patch.object(cs_mod.logging, "error") as mock_err:
279+
cs_mod._remove_container("test-name")
280+
mock_err.assert_called_once()
281+
282+
def test_is_running_returns_false_on_timeout(self):
283+
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)):
284+
assert cs_mod._is_running("test-name") is False
285+
286+
def test_is_running_returns_false_on_bad_json(self):
287+
with patch("subprocess.run", return_value=_make_proc(returncode=0, stdout="not json")):
288+
assert cs_mod._is_running("test-name") is False
289+
290+
191291
# ---------------------------------------------------------------------------
192292
# Toolbox YAML validation
193293
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)