From 5e1a0b0e75b47e7fe4034e30a5dad43a482f1573 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Wed, 18 Mar 2026 14:38:39 -0400 Subject: [PATCH 1/5] feat: add persistent container support via CONTAINER_PERSIST env var When CONTAINER_PERSIST is set to 'true', containers are: - Named deterministically (seclab-persist-{image-slug}) instead of randomly - Started without --rm so they survive MCP server process exit - Reused if already running (new MCP server attaches to existing container) - Not stopped on atexit (container persists for the next task) This enables multi-task taskflows to share state across tasks without rebuilding the environment each time. Set CONTAINER_PERSIST in the taskflow env or toolbox YAML to enable. All container_shell toolbox YAMLs updated to expose CONTAINER_PERSIST. --- .../mcp_servers/container_shell.py | 46 +++++++++++++++++-- .../toolboxes/container_shell_base.yaml | 1 + .../container_shell_malware_analysis.yaml | 1 + .../container_shell_network_analysis.yaml | 1 + .../toolboxes/container_shell_sast.yaml | 1 + 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py index 409f310..6c34b98 100644 --- a/src/seclab_taskflows/mcp_servers/container_shell.py +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -4,6 +4,7 @@ import atexit import logging import os +import re import subprocess import uuid from typing import Annotated @@ -26,10 +27,27 @@ CONTAINER_IMAGE = os.environ.get("CONTAINER_IMAGE", "") CONTAINER_WORKSPACE = os.environ.get("CONTAINER_WORKSPACE", "") CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30")) +CONTAINER_PERSIST = os.environ.get("CONTAINER_PERSIST", "").lower() in ("1", "true", "yes") _DEFAULT_WORKDIR = "/workspace" +def _persistent_name() -> str: + """Derive a deterministic container name from the image for reuse across tasks.""" + slug = re.sub(r"[^a-zA-Z0-9]", "-", CONTAINER_IMAGE).strip("-")[:40] + return f"seclab-persist-{slug}" + + +def _is_running(name: str) -> bool: + """Check if a container with the given name is already running.""" + result = subprocess.run( + ["docker", "inspect", "-f", "{{.State.Running}}", name], + capture_output=True, + text=True, + ) + return result.returncode == 0 and result.stdout.strip() == "true" + + def _start_container() -> str: """Start the Docker container and return its name.""" if not CONTAINER_IMAGE: @@ -38,8 +56,24 @@ def _start_container() -> str: if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE: msg = f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}" raise RuntimeError(msg) - name = f"seclab-shell-{uuid.uuid4().hex[:8]}" - cmd = ["docker", "run", "-d", "--rm", "--name", name] + + if CONTAINER_PERSIST: + name = _persistent_name() + if _is_running(name): + logging.debug(f"Reusing persistent container: {name}") + return name + # Remove stopped leftover with the same name (ignore errors) + subprocess.run( + ["docker", "rm", "-f", name], + capture_output=True, + text=True, + ) + else: + name = f"seclab-shell-{uuid.uuid4().hex[:8]}" + + cmd = ["docker", "run", "-d", "--name", name] + if not CONTAINER_PERSIST: + cmd.append("--rm") if CONTAINER_WORKSPACE: cmd += ["-v", f"{CONTAINER_WORKSPACE}:/workspace"] cmd += [CONTAINER_IMAGE, "tail", "-f", "/dev/null"] @@ -48,15 +82,19 @@ def _start_container() -> str: if result.returncode != 0: msg = f"docker run failed: {result.stderr.strip()}" raise RuntimeError(msg) - logging.debug(f"Container started: {name}") + logging.debug(f"Container started: {name} (persist={CONTAINER_PERSIST})") return name def _stop_container() -> None: - """Stop the running container.""" + """Stop the running container (skipped for persistent containers).""" global _container_name if _container_name is None: return + if CONTAINER_PERSIST: + logging.debug(f"Leaving persistent container running: {_container_name}") + _container_name = None + return logging.debug(f"Stopping container: {_container_name}") result = subprocess.run( ["docker", "stop", "--time", "5", _container_name], diff --git a/src/seclab_taskflows/toolboxes/container_shell_base.yaml b/src/seclab_taskflows/toolboxes/container_shell_base.yaml index 206acfd..f8adc4b 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_base.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_base.yaml @@ -13,6 +13,7 @@ server_params: CONTAINER_IMAGE: "seclab-shell-base:latest" CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}" + CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: diff --git a/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml index 332eb87..d32a345 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml @@ -13,6 +13,7 @@ server_params: CONTAINER_IMAGE: "seclab-shell-malware-analysis:latest" CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}" + CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: diff --git a/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml b/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml index 81923e4..ad2afd8 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml @@ -13,6 +13,7 @@ server_params: CONTAINER_IMAGE: "seclab-shell-network-analysis:latest" CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}" + CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: diff --git a/src/seclab_taskflows/toolboxes/container_shell_sast.yaml b/src/seclab_taskflows/toolboxes/container_shell_sast.yaml index 9a2ec79..573c54c 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_sast.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_sast.yaml @@ -13,6 +13,7 @@ server_params: CONTAINER_IMAGE: "seclab-shell-sast:latest" CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}" + CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: From b646bea6b324b209b7604a57c8737fc8f2bda989 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Wed, 18 Mar 2026 15:46:00 -0400 Subject: [PATCH 2/5] 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 --- .../mcp_servers/container_shell.py | 63 ++++++++--- .../toolboxes/container_shell_base.yaml | 1 + .../container_shell_malware_analysis.yaml | 1 + .../container_shell_network_analysis.yaml | 1 + .../toolboxes/container_shell_sast.yaml | 1 + tests/test_container_shell.py | 100 ++++++++++++++++++ 6 files changed, 151 insertions(+), 16 deletions(-) diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py index 6c34b98..3e6fd57 100644 --- a/src/seclab_taskflows/mcp_servers/container_shell.py +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -2,9 +2,10 @@ # SPDX-License-Identifier: MIT import atexit +import hashlib +import json import logging import os -import re import subprocess import uuid from typing import Annotated @@ -28,24 +29,58 @@ CONTAINER_WORKSPACE = os.environ.get("CONTAINER_WORKSPACE", "") CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30")) CONTAINER_PERSIST = os.environ.get("CONTAINER_PERSIST", "").lower() in ("1", "true", "yes") +CONTAINER_PERSIST_KEY = os.environ.get("CONTAINER_PERSIST_KEY", "") _DEFAULT_WORKDIR = "/workspace" +_DOCKER_TIMEOUT = 30 def _persistent_name() -> str: - """Derive a deterministic container name from the image for reuse across tasks.""" - slug = re.sub(r"[^a-zA-Z0-9]", "-", CONTAINER_IMAGE).strip("-")[:40] - return f"seclab-persist-{slug}" + """Derive a deterministic container name from the image for reuse across tasks. + + Incorporates a hash of the full image reference (and optional + CONTAINER_PERSIST_KEY) to avoid collisions between long image names that + share a common prefix, or between independent runs of the same image. + """ + key_material = CONTAINER_IMAGE + if CONTAINER_PERSIST_KEY: + key_material += f":{CONTAINER_PERSIST_KEY}" + digest = hashlib.sha256(key_material.encode()).hexdigest()[:12] + return f"seclab-persist-{digest}" def _is_running(name: str) -> bool: """Check if a container with the given name is already running.""" - result = subprocess.run( - ["docker", "inspect", "-f", "{{.State.Running}}", name], - capture_output=True, - text=True, - ) - return result.returncode == 0 and result.stdout.strip() == "true" + try: + result = subprocess.run( + ["docker", "inspect", "--format", "json", name], + capture_output=True, + text=True, + timeout=_DOCKER_TIMEOUT, + ) + if result.returncode != 0: + return False + data = json.loads(result.stdout) + return bool(data and data[0].get("State", {}).get("Running")) + except (subprocess.TimeoutExpired, json.JSONDecodeError, IndexError): + return False + + +def _remove_container(name: str) -> None: + """Remove a stopped container by name. Logs failures for diagnostics.""" + try: + result = subprocess.run( + ["docker", "rm", "-f", name], + capture_output=True, + text=True, + timeout=_DOCKER_TIMEOUT, + ) + if result.returncode != 0: + logging.warning( + "docker rm -f failed for %s: %s", name, result.stderr.strip() + ) + except subprocess.TimeoutExpired: + logging.error("docker rm -f timed out for %s after %ds", name, _DOCKER_TIMEOUT) def _start_container() -> str: @@ -62,12 +97,8 @@ def _start_container() -> str: if _is_running(name): logging.debug(f"Reusing persistent container: {name}") return name - # Remove stopped leftover with the same name (ignore errors) - subprocess.run( - ["docker", "rm", "-f", name], - capture_output=True, - text=True, - ) + # Remove stopped leftover with the same name + _remove_container(name) else: name = f"seclab-shell-{uuid.uuid4().hex[:8]}" diff --git a/src/seclab_taskflows/toolboxes/container_shell_base.yaml b/src/seclab_taskflows/toolboxes/container_shell_base.yaml index f8adc4b..d5c1bf3 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_base.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_base.yaml @@ -14,6 +14,7 @@ server_params: CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}" CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" + CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: diff --git a/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml index d32a345..0ed439b 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml @@ -14,6 +14,7 @@ server_params: CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}" CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" + CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: diff --git a/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml b/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml index ad2afd8..3d27a26 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_network_analysis.yaml @@ -14,6 +14,7 @@ server_params: CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}" CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" + CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: diff --git a/src/seclab_taskflows/toolboxes/container_shell_sast.yaml b/src/seclab_taskflows/toolboxes/container_shell_sast.yaml index 573c54c..d61a330 100644 --- a/src/seclab_taskflows/toolboxes/container_shell_sast.yaml +++ b/src/seclab_taskflows/toolboxes/container_shell_sast.yaml @@ -14,6 +14,7 @@ server_params: CONTAINER_WORKSPACE: "{{ env('CONTAINER_WORKSPACE', required=False) }}" CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}" CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}" + CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}" LOG_DIR: "{{ env('LOG_DIR') }}" confirm: diff --git a/tests/test_container_shell.py b/tests/test_container_shell.py index 8be2716..dc10ade 100644 --- a/tests/test_container_shell.py +++ b/tests/test_container_shell.py @@ -188,6 +188,106 @@ def test_stop_container_clears_name_on_failure(self): assert cs_mod._container_name is None +# --------------------------------------------------------------------------- +# Persistent container tests +# --------------------------------------------------------------------------- + +class TestPersistentContainer: + def setup_method(self): + _reset_container() + + def test_persistent_name_uses_hash(self): + with patch.object(cs_mod, "CONTAINER_IMAGE", "myregistry.io/org/image:v1.2.3"): + with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""): + name = cs_mod._persistent_name() + assert name.startswith("seclab-persist-") + assert len(name) == len("seclab-persist-") + 12 + + def test_persistent_name_varies_with_key(self): + with patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"): + with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""): + name_a = cs_mod._persistent_name() + with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", "run-42"): + name_b = cs_mod._persistent_name() + assert name_a != name_b + + def test_persistent_name_differs_for_different_images(self): + with patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""): + with patch.object(cs_mod, "CONTAINER_IMAGE", "image-a:latest"): + name_a = cs_mod._persistent_name() + with patch.object(cs_mod, "CONTAINER_IMAGE", "image-b:latest"): + name_b = cs_mod._persistent_name() + assert name_a != name_b + + def test_start_reuses_running_persistent_container(self): + inspect_proc = _make_proc( + returncode=0, + stdout='[{"State":{"Running":true}}]', + ) + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), + patch.object(cs_mod, "CONTAINER_PERSIST", True), + patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""), + patch("subprocess.run", return_value=inspect_proc) as mock_run, + ): + name = cs_mod._start_container() + assert name.startswith("seclab-persist-") + # Only docker inspect should be called, NOT docker run + assert mock_run.call_count == 1 + assert "inspect" in mock_run.call_args[0][0] + + def test_start_persistent_no_rm_flag(self): + inspect_proc = _make_proc( + returncode=1, + stdout="", + ) + rm_proc = _make_proc(returncode=0) + run_proc = _make_proc(returncode=0) + with ( + patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"), + patch.object(cs_mod, "CONTAINER_WORKSPACE", ""), + patch.object(cs_mod, "CONTAINER_PERSIST", True), + patch.object(cs_mod, "CONTAINER_PERSIST_KEY", ""), + patch("subprocess.run", side_effect=[inspect_proc, rm_proc, run_proc]) as mock_run, + ): + name = cs_mod._start_container() + assert name.startswith("seclab-persist-") + # The docker run call is the third one + run_cmd = mock_run.call_args_list[2][0][0] + assert "--rm" not in run_cmd + + def test_stop_skips_persistent_container(self): + cs_mod._container_name = "seclab-persist-abc123" + with ( + patch.object(cs_mod, "CONTAINER_PERSIST", True), + patch("subprocess.run") as mock_run, + ): + cs_mod._stop_container() + mock_run.assert_not_called() + assert cs_mod._container_name is None + + def test_remove_container_logs_failure(self): + with patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="conflict")): + with patch.object(cs_mod.logging, "warning") as mock_warn: + cs_mod._remove_container("test-name") + mock_warn.assert_called_once() + + def test_remove_container_logs_timeout(self): + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)): + with patch.object(cs_mod.logging, "error") as mock_err: + cs_mod._remove_container("test-name") + mock_err.assert_called_once() + + def test_is_running_returns_false_on_timeout(self): + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)): + assert cs_mod._is_running("test-name") is False + + def test_is_running_returns_false_on_bad_json(self): + with patch("subprocess.run", return_value=_make_proc(returncode=0, stdout="not json")): + assert cs_mod._is_running("test-name") is False + + # --------------------------------------------------------------------------- # Toolbox YAML validation # --------------------------------------------------------------------------- From d908abf6d0e70773f981bf833e7962e98314aeae Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Wed, 18 Mar 2026 16:00:38 -0400 Subject: [PATCH 3/5] address second round of review feedback - docker rm without -f to avoid killing running containers on transient failures - use _DOCKER_TIMEOUT constant for docker run - assert full docker inspect argv in reuse test --- .../mcp_servers/container_shell.py | 16 ++++++++++------ tests/test_container_shell.py | 7 ++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py index 3e6fd57..6e5abf3 100644 --- a/src/seclab_taskflows/mcp_servers/container_shell.py +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -67,20 +67,24 @@ def _is_running(name: str) -> bool: def _remove_container(name: str) -> None: - """Remove a stopped container by name. Logs failures for diagnostics.""" + """Remove a stopped container by name. Logs failures for diagnostics. + + Uses ``docker rm`` (without -f) so that running containers are NOT + killed — only genuinely stopped leftovers are cleaned up. + """ try: result = subprocess.run( - ["docker", "rm", "-f", name], + ["docker", "rm", name], capture_output=True, text=True, timeout=_DOCKER_TIMEOUT, ) if result.returncode != 0: - logging.warning( - "docker rm -f failed for %s: %s", name, result.stderr.strip() + logging.debug( + "docker rm skipped for %s: %s", name, result.stderr.strip() ) except subprocess.TimeoutExpired: - logging.error("docker rm -f timed out for %s after %ds", name, _DOCKER_TIMEOUT) + logging.error("docker rm timed out for %s after %ds", name, _DOCKER_TIMEOUT) def _start_container() -> str: @@ -109,7 +113,7 @@ def _start_container() -> str: cmd += ["-v", f"{CONTAINER_WORKSPACE}:/workspace"] cmd += [CONTAINER_IMAGE, "tail", "-f", "/dev/null"] logging.debug(f"Starting container: {' '.join(cmd)}") - result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=_DOCKER_TIMEOUT) if result.returncode != 0: msg = f"docker run failed: {result.stderr.strip()}" raise RuntimeError(msg) diff --git a/tests/test_container_shell.py b/tests/test_container_shell.py index dc10ade..177a077 100644 --- a/tests/test_container_shell.py +++ b/tests/test_container_shell.py @@ -235,7 +235,8 @@ def test_start_reuses_running_persistent_container(self): assert name.startswith("seclab-persist-") # Only docker inspect should be called, NOT docker run assert mock_run.call_count == 1 - assert "inspect" in mock_run.call_args[0][0] + cmd = mock_run.call_args[0][0] + assert cmd == ["docker", "inspect", "--format", "json", name] def test_start_persistent_no_rm_flag(self): inspect_proc = _make_proc( @@ -269,9 +270,9 @@ def test_stop_skips_persistent_container(self): def test_remove_container_logs_failure(self): with patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="conflict")): - with patch.object(cs_mod.logging, "warning") as mock_warn: + with patch.object(cs_mod.logging, "debug") as mock_debug: cs_mod._remove_container("test-name") - mock_warn.assert_called_once() + mock_debug.assert_called_once() def test_remove_container_logs_timeout(self): with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)): From 2604fba9f70afb09c40d64b45cac3a44b6b6dd30 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Wed, 18 Mar 2026 16:32:35 -0400 Subject: [PATCH 4/5] fix: use logging.exception in except block to satisfy TRY400 lint --- src/seclab_taskflows/mcp_servers/container_shell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seclab_taskflows/mcp_servers/container_shell.py b/src/seclab_taskflows/mcp_servers/container_shell.py index 6e5abf3..8039299 100644 --- a/src/seclab_taskflows/mcp_servers/container_shell.py +++ b/src/seclab_taskflows/mcp_servers/container_shell.py @@ -84,7 +84,7 @@ def _remove_container(name: str) -> None: "docker rm skipped for %s: %s", name, result.stderr.strip() ) except subprocess.TimeoutExpired: - logging.error("docker rm timed out for %s after %ds", name, _DOCKER_TIMEOUT) + logging.exception("docker rm timed out for %s after %ds", name, _DOCKER_TIMEOUT) def _start_container() -> str: From cdb43f3cb7cd2337deb46ddbf56220bca5725140 Mon Sep 17 00:00:00 2001 From: Bas Alberts Date: Wed, 18 Mar 2026 16:49:51 -0400 Subject: [PATCH 5/5] fix: align test with logging.exception change from TRY400 fix --- tests/test_container_shell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_container_shell.py b/tests/test_container_shell.py index 177a077..561854f 100644 --- a/tests/test_container_shell.py +++ b/tests/test_container_shell.py @@ -276,7 +276,7 @@ def test_remove_container_logs_failure(self): def test_remove_container_logs_timeout(self): with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=30)): - with patch.object(cs_mod.logging, "error") as mock_err: + with patch.object(cs_mod.logging, "exception") as mock_err: cs_mod._remove_container("test-name") mock_err.assert_called_once()