Skip to content

Commit 7f2dbdb

Browse files
committed
Address PR review feedback on container shell toolbox
- Rename _container_id → _container_name throughout (it stores the name set via --name, not the Docker-assigned container ID) - Add empty-image guard in _start_container: raise clear RuntimeError when CONTAINER_IMAGE is not set rather than passing an empty string to docker run - Add 30s timeout to docker run subprocess call in _start_container - Log warning in _stop_container when docker stop fails instead of silently ignoring a non-zero returncode - Default _DEFAULT_WORKDIR to /workspace unconditionally (all images set WORKDIR /workspace; the previous "/" fallback when no workspace was mounted was inconsistent with the container image defaults) - Add SPDX headers to container_shell.py, test_container_shell.py, and all three Dockerfiles that were missing them - Remove unused importlib import from test_container_shell.py - Fix dead sast workspace existence check in run_container_shell_demo.sh (mkdir -p always creates workspace so the old condition was never true; now checks the actual target path when a specific target is provided) - Update build_container_images.sh usage comment to include sast - Clarify malware analysis toolbox prompt: /workspace is bind-mounted RW from the host, not an isolated environment - Update README CONTAINER_TIMEOUT defaults to mention sast profile (60s) - Add test_start_container_rejects_empty_image and test_stop_container_clears_name_on_failure test cases
1 parent b8aafc6 commit 7f2dbdb

9 files changed

Lines changed: 69 additions & 32 deletions

File tree

scripts/build_container_images.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# Must be run from the root of the seclab-taskflows repository.
77
# Images must be rebuilt whenever a Dockerfile changes.
88
#
9-
# Usage: ./scripts/build_container_images.sh [base|malware|network|all]
9+
# Usage: ./scripts/build_container_images.sh [base|malware|network|sast|all]
1010
# default: all
1111

1212
set -euo pipefail

scripts/run_container_shell_demo.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ case "$demo" in
7575
;;
7676
sast)
7777
target="${3:-.}"
78-
if [ ! -d "$workspace" ] && [ ! -f "${workspace}/${target}" ]; then
79-
echo "No source found at ${workspace}/${target}" >&2
78+
target_path="${workspace}/${target}"
79+
if [ "$target" != "." ] && [ ! -d "$target_path" ] && [ ! -f "$target_path" ]; then
80+
echo "No source found at ${target_path}" >&2
8081
echo "Provide a source directory or file in workspace_dir." >&2
8182
exit 1
8283
fi

src/seclab_taskflows/containers/base/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
14
FROM debian:bookworm-slim
25
RUN apt-get update && apt-get install -y --no-install-recommends \
36
bash coreutils python3 python3-pip curl wget git ca-certificates \

src/seclab_taskflows/containers/malware_analysis/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
14
FROM seclab-shell-base:latest
25
RUN apt-get update && apt-get install -y --no-install-recommends \
36
binwalk yara libimage-exiftool-perl checksec \

src/seclab_taskflows/containers/network_analysis/Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
14
FROM seclab-shell-base:latest
25
RUN apt-get update && apt-get install -y --no-install-recommends \
36
nmap tcpdump tshark netcat-openbsd dnsutils curl jq httpie \

src/seclab_taskflows/mcp_servers/container_shell.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
14
import atexit
25
import logging
36
import os
@@ -18,15 +21,19 @@
1821

1922
mcp = FastMCP("ContainerShell")
2023

21-
_container_id: str | None = None
24+
_container_name: str | None = None
2225

2326
CONTAINER_IMAGE = os.environ.get("CONTAINER_IMAGE", "")
2427
CONTAINER_WORKSPACE = os.environ.get("CONTAINER_WORKSPACE", "")
2528
CONTAINER_TIMEOUT = int(os.environ.get("CONTAINER_TIMEOUT", "30"))
2629

30+
_DEFAULT_WORKDIR = "/workspace"
31+
2732

2833
def _start_container() -> str:
2934
"""Start the Docker container and return its name."""
35+
if not CONTAINER_IMAGE:
36+
raise RuntimeError("CONTAINER_IMAGE is not set — cannot start container")
3037
if CONTAINER_WORKSPACE and ":" in CONTAINER_WORKSPACE:
3138
raise RuntimeError(f"CONTAINER_WORKSPACE must not contain a colon: {CONTAINER_WORKSPACE!r}")
3239
name = f"seclab-shell-{uuid.uuid4().hex[:8]}"
@@ -35,7 +42,7 @@ def _start_container() -> str:
3542
cmd += ["-v", f"{CONTAINER_WORKSPACE}:/workspace"]
3643
cmd += [CONTAINER_IMAGE, "tail", "-f", "/dev/null"]
3744
logging.debug(f"Starting container: {' '.join(cmd)}")
38-
result = subprocess.run(cmd, capture_output=True, text=True)
45+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
3946
if result.returncode != 0:
4047
raise RuntimeError(f"docker run failed: {result.stderr.strip()}")
4148
logging.debug(f"Container started: {name}")
@@ -44,39 +51,42 @@ def _start_container() -> str:
4451

4552
def _stop_container() -> None:
4653
"""Stop the running container."""
47-
global _container_id
48-
if _container_id is None:
54+
global _container_name
55+
if _container_name is None:
4956
return
50-
logging.debug(f"Stopping container: {_container_id}")
51-
subprocess.run(
52-
["docker", "stop", "--time", "5", _container_id],
57+
logging.debug(f"Stopping container: {_container_name}")
58+
result = subprocess.run(
59+
["docker", "stop", "--time", "5", _container_name],
5360
capture_output=True,
5461
text=True,
5562
)
56-
_container_id = None
63+
if result.returncode != 0:
64+
logging.warning(
65+
"docker stop failed for container %s: %s",
66+
_container_name,
67+
result.stderr.strip(),
68+
)
69+
_container_name = None
5770

5871

5972
atexit.register(_stop_container)
6073

6174

62-
_DEFAULT_WORKDIR = "/workspace" if CONTAINER_WORKSPACE else "/"
63-
64-
6575
@mcp.tool()
6676
def shell_exec(
6777
command: Annotated[str, Field(description="Shell command to execute inside the container")],
6878
timeout: Annotated[int, Field(description="Timeout in seconds")] = CONTAINER_TIMEOUT,
6979
workdir: Annotated[str, Field(description="Working directory inside the container")] = _DEFAULT_WORKDIR,
7080
) -> str:
7181
"""Execute a shell command inside the managed Docker container."""
72-
global _container_id
73-
if _container_id is None:
82+
global _container_name
83+
if _container_name is None:
7484
try:
75-
_container_id = _start_container()
85+
_container_name = _start_container()
7686
except RuntimeError as e:
7787
return f"Failed to start container: {e}"
7888

79-
cmd = ["docker", "exec", "-w", workdir, _container_id, "bash", "-c", command]
89+
cmd = ["docker", "exec", "-w", workdir, _container_name, "bash", "-c", command]
8090
logging.debug(f"Executing: {' '.join(cmd)}")
8191
try:
8292
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)

src/seclab_taskflows/taskflows/container_shell/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ Images only need to be rebuilt when a Dockerfile changes.
5050
you do not need to pass files into the container.
5151

5252
`CONTAINER_TIMEOUT` — default command timeout in seconds. Defaults to 30 (base
53-
and network) or 60 (malware analysis).
53+
and network) or 60 (malware analysis and sast).
5454

5555
`LOG_DIR` — where to write `container_shell.log`.
5656

src/seclab_taskflows/toolboxes/container_shell_malware_analysis.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ server_prompt: |
2222
## Container Shell (malware analysis)
2323
You have access to an isolated Docker container for binary and malware analysis.
2424
The working directory is /workspace — place samples here before analysis.
25-
ALL tooling runs inside the container, keeping the host safe.
25+
ALL tooling runs inside the container. Note: /workspace is bind-mounted from
26+
the host, so files written there are visible on the host side as well.
2627
2728
Available tools:
2829
- file, strings, xxd, hexdump — initial triage / string extraction

tests/test_container_shell.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import importlib
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
24
import subprocess
35
from unittest.mock import MagicMock, patch
46

@@ -22,7 +24,7 @@ def _make_proc(returncode=0, stdout="", stderr=""):
2224

2325
def _reset_container():
2426
"""Reset global container state between tests."""
25-
cs_mod._container_id = None
27+
cs_mod._container_name = None
2628

2729

2830
# ---------------------------------------------------------------------------
@@ -78,6 +80,14 @@ def test_start_container_rejects_colon_in_workspace(self):
7880
with pytest.raises(RuntimeError, match="CONTAINER_WORKSPACE must not contain a colon"):
7981
cs_mod._start_container()
8082

83+
def test_start_container_rejects_empty_image(self):
84+
with (
85+
patch.object(cs_mod, "CONTAINER_IMAGE", ""),
86+
patch.object(cs_mod, "CONTAINER_WORKSPACE", ""),
87+
):
88+
with pytest.raises(RuntimeError, match="CONTAINER_IMAGE is not set"):
89+
cs_mod._start_container()
90+
8191

8292
# ---------------------------------------------------------------------------
8393
# shell_exec tests
@@ -95,13 +105,13 @@ def test_shell_exec_lazy_start(self):
95105
patch.object(cs_mod, "CONTAINER_WORKSPACE", ""),
96106
patch("subprocess.run", side_effect=[start_proc, exec_proc]),
97107
):
98-
assert cs_mod._container_id is None
108+
assert cs_mod._container_name is None
99109
result = cs_mod.shell_exec.fn(command="echo hello")
100-
assert cs_mod._container_id is not None
110+
assert cs_mod._container_name is not None
101111
assert "hello" in result
102112

103113
def test_shell_exec_runs_command(self):
104-
cs_mod._container_id = "seclab-shell-testtest"
114+
cs_mod._container_name = "seclab-shell-testtest"
105115
exec_proc = _make_proc(returncode=0, stdout="output\n")
106116
with patch("subprocess.run", return_value=exec_proc) as mock_run:
107117
result = cs_mod.shell_exec.fn(command="echo output", workdir="/workspace")
@@ -115,22 +125,22 @@ def test_shell_exec_runs_command(self):
115125
assert "output" in result
116126

117127
def test_shell_exec_includes_exit_code(self):
118-
cs_mod._container_id = "seclab-shell-testtest"
128+
cs_mod._container_name = "seclab-shell-testtest"
119129
exec_proc = _make_proc(returncode=0, stdout="done\n")
120130
with patch("subprocess.run", return_value=exec_proc):
121131
result = cs_mod.shell_exec.fn(command="true")
122132
assert "[exit code: 0]" in result
123133

124134
def test_shell_exec_nonzero_exit(self):
125-
cs_mod._container_id = "seclab-shell-testtest"
135+
cs_mod._container_name = "seclab-shell-testtest"
126136
exec_proc = _make_proc(returncode=1, stdout="", stderr="error\n")
127137
with patch("subprocess.run", return_value=exec_proc):
128138
result = cs_mod.shell_exec.fn(command="false")
129139
assert "[exit code: 1]" in result
130140
assert "error" in result
131141

132142
def test_shell_exec_timeout(self):
133-
cs_mod._container_id = "seclab-shell-testtest"
143+
cs_mod._container_name = "seclab-shell-testtest"
134144
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="docker", timeout=5)):
135145
result = cs_mod.shell_exec.fn(command="sleep 999", timeout=5)
136146
assert "timeout" in result
@@ -144,7 +154,7 @@ def test_shell_exec_start_failure_returns_error(self):
144154
):
145155
result = cs_mod.shell_exec.fn(command="echo hi")
146156
assert "Failed to start container" in result
147-
assert cs_mod._container_id is None
157+
assert cs_mod._container_name is None
148158

149159

150160
# ---------------------------------------------------------------------------
@@ -156,21 +166,27 @@ def setup_method(self):
156166
_reset_container()
157167

158168
def test_stop_container_called_on_atexit(self):
159-
cs_mod._container_id = "seclab-shell-tostop"
169+
cs_mod._container_name = "seclab-shell-tostop"
160170
with patch("subprocess.run", return_value=_make_proc(returncode=0)) as mock_run:
161171
cs_mod._stop_container()
162172
cmd = mock_run.call_args[0][0]
163173
assert "docker" in cmd
164174
assert "stop" in cmd
165175
assert "seclab-shell-tostop" in cmd
166-
assert cs_mod._container_id is None
176+
assert cs_mod._container_name is None
167177

168178
def test_stop_container_no_op_when_none(self):
169-
cs_mod._container_id = None
179+
cs_mod._container_name = None
170180
with patch("subprocess.run") as mock_run:
171181
cs_mod._stop_container()
172182
mock_run.assert_not_called()
173183

184+
def test_stop_container_clears_name_on_failure(self):
185+
cs_mod._container_name = "seclab-shell-tostop"
186+
with patch("subprocess.run", return_value=_make_proc(returncode=1, stderr="not found")):
187+
cs_mod._stop_container()
188+
assert cs_mod._container_name is None
189+
174190

175191
# ---------------------------------------------------------------------------
176192
# Toolbox YAML validation

0 commit comments

Comments
 (0)