Skip to content

Commit 4cecfe9

Browse files
authored
Merge pull request #67 from GitHubSecurityLab/anticomputer/container-improvements
Add persistent container support
2 parents e530d14 + cdb43f3 commit 4cecfe9

File tree

6 files changed

+187
-5
lines changed

6 files changed

+187
-5
lines changed

src/seclab_taskflows/mcp_servers/container_shell.py

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# SPDX-License-Identifier: MIT
33

44
import atexit
5+
import hashlib
6+
import json
57
import logging
68
import os
79
import subprocess
@@ -26,8 +28,63 @@
2628
CONTAINER_IMAGE = os.environ.get("CONTAINER_IMAGE", "")
2729
CONTAINER_WORKSPACE = os.environ.get("CONTAINER_WORKSPACE", "")
2830
CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30"))
31+
CONTAINER_PERSIST = os.environ.get("CONTAINER_PERSIST", "").lower() in ("1", "true", "yes")
32+
CONTAINER_PERSIST_KEY = os.environ.get("CONTAINER_PERSIST_KEY", "")
2933

3034
_DEFAULT_WORKDIR = "/workspace"
35+
_DOCKER_TIMEOUT = 30
36+
37+
38+
def _persistent_name() -> str:
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}"
50+
51+
52+
def _is_running(name: str) -> bool:
53+
"""Check if a container with the given name is already running."""
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+
72+
Uses ``docker rm`` (without -f) so that running containers are NOT
73+
killed — only genuinely stopped leftovers are cleaned up.
74+
"""
75+
try:
76+
result = subprocess.run(
77+
["docker", "rm", name],
78+
capture_output=True,
79+
text=True,
80+
timeout=_DOCKER_TIMEOUT,
81+
)
82+
if result.returncode != 0:
83+
logging.debug(
84+
"docker rm skipped for %s: %s", name, result.stderr.strip()
85+
)
86+
except subprocess.TimeoutExpired:
87+
logging.exception("docker rm timed out for %s after %ds", name, _DOCKER_TIMEOUT)
3188

3289

3390
def _start_container() -> str:
@@ -38,25 +95,41 @@ def _start_container() -> str:
3895
if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE:
3996
msg = f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}"
4097
raise RuntimeError(msg)
41-
name = f"seclab-shell-{uuid.uuid4().hex[:8]}"
42-
cmd = ["docker", "run", "-d", "--rm", "--name", name]
98+
99+
if CONTAINER_PERSIST:
100+
name = _persistent_name()
101+
if _is_running(name):
102+
logging.debug(f"Reusing persistent container: {name}")
103+
return name
104+
# Remove stopped leftover with the same name
105+
_remove_container(name)
106+
else:
107+
name = f"seclab-shell-{uuid.uuid4().hex[:8]}"
108+
109+
cmd = ["docker", "run", "-d", "--name", name]
110+
if not CONTAINER_PERSIST:
111+
cmd.append("--rm")
43112
if CONTAINER_WORKSPACE:
44113
cmd += ["-v", f"{CONTAINER_WORKSPACE}:/workspace"]
45114
cmd += [CONTAINER_IMAGE, "tail", "-f", "/dev/null"]
46115
logging.debug(f"Starting container: {' '.join(cmd)}")
47-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
116+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=_DOCKER_TIMEOUT)
48117
if result.returncode != 0:
49118
msg = f"docker run failed: {result.stderr.strip()}"
50119
raise RuntimeError(msg)
51-
logging.debug(f"Container started: {name}")
120+
logging.debug(f"Container started: {name} (persist={CONTAINER_PERSIST})")
52121
return name
53122

54123

55124
def _stop_container() -> None:
56-
"""Stop the running container."""
125+
"""Stop the running container (skipped for persistent containers)."""
57126
global _container_name
58127
if _container_name is None:
59128
return
129+
if CONTAINER_PERSIST:
130+
logging.debug(f"Leaving persistent container running: {_container_name}")
131+
_container_name = None
132+
return
60133
logging.debug(f"Stopping container: {_container_name}")
61134
result = subprocess.run(
62135
["docker", "stop", "--time", "5", _container_name],

src/seclab_taskflows/toolboxes/container_shell_base.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ server_params:
1313
CONTAINER_IMAGE: "seclab-shell-base:latest"
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
16+
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1618
LOG_DIR: "{{ env('LOG_DIR') }}"
1719

1820
confirm:

src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ server_params:
1313
CONTAINER_IMAGE: "seclab-shell-malware-analysis:latest"
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
16+
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1618
LOG_DIR: "{{ env('LOG_DIR') }}"
1719

1820
confirm:

src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ server_params:
1313
CONTAINER_IMAGE: "seclab-shell-network-analysis:latest"
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
16+
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1618
LOG_DIR: "{{ env('LOG_DIR') }}"
1719

1820
confirm:

src/seclab_taskflows/toolboxes/container_shell_sast.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ server_params:
1313
CONTAINER_IMAGE: "seclab-shell-sast:latest"
1414
CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}"
1515
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
16+
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
17+
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
1618
LOG_DIR: "{{ env('LOG_DIR') }}"
1719

1820
confirm:

tests/test_container_shell.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,107 @@ 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+
cmd = mock_run.call_args[0][0]
239+
assert cmd == ["docker", "inspect", "--format", "json", name]
240+
241+
def test_start_persistent_no_rm_flag(self):
242+
inspect_proc = _make_proc(
243+
returncode=1,
244+
stdout="",
245+
)
246+
rm_proc = _make_proc(returncode=0)
247+
run_proc = _make_proc(returncode=0)
248+
with (
249+
patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"),
250+
patch.object(cs_mod, "CONTAINER_WORKSPACE", ""),
251+
patch.object(cs_mod, "CONTAINER_PERSIST", True),
252+
patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""),
253+
patch("subprocess.run", side_effect=[inspect_proc, rm_proc, run_proc]) as mock_run,
254+
):
255+
name = cs_mod._start_container()
256+
assert name.startswith("seclab-persist-")
257+
# The docker run call is the third one
258+
run_cmd = mock_run.call_args_list[2][0][0]
259+
assert "--rm" not in run_cmd
260+
261+
def test_stop_skips_persistent_container(self):
262+
cs_mod._container_name = "seclab-persist-abc123"
263+
with (
264+
patch.object(cs_mod, "CONTAINER_PERSIST", True),
265+
patch("subprocess.run") as mock_run,
266+
):
267+
cs_mod._stop_container()
268+
mock_run.assert_not_called()
269+
assert cs_mod._container_name is None
270+
271+
def test_remove_container_logs_failure(self):
272+
with patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="conflict")):
273+
with patch.object(cs_mod.logging, "debug") as mock_debug:
274+
cs_mod._remove_container("test-name")
275+
mock_debug.assert_called_once()
276+
277+
def test_remove_container_logs_timeout(self):
278+
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)):
279+
with patch.object(cs_mod.logging, "exception") as mock_err:
280+
cs_mod._remove_container("test-name")
281+
mock_err.assert_called_once()
282+
283+
def test_is_running_returns_false_on_timeout(self):
284+
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)):
285+
assert cs_mod._is_running("test-name") is False
286+
287+
def test_is_running_returns_false_on_bad_json(self):
288+
with patch("subprocess.run", return_value=_make_proc(returncode=0, stdout="not json")):
289+
assert cs_mod._is_running("test-name") is False
290+
291+
191292
# ---------------------------------------------------------------------------
192293
# Toolbox YAML validation
193294
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)