Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/seclab_taskflows/mcp_servers/container_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
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", "")
# Docker network mode for the container. Defaults to "none" so containers are
# egress-locked (no network access) unless a caller explicitly opts in by
# setting CONTAINER_NETWORK to a network name such as "bridge", "host", or a
# user-defined network. An empty or whitespace-only value falls back to "none"
# so the isolation default cannot be silently disabled by an unset variable.
CONTAINER_NETWORK = os.environ.get("CONTAINER_NETWORK", "none").strip() or "none"
Comment thread
anticomputer marked this conversation as resolved.

_DEFAULT_WORKDIR = "/workspace"
_DOCKER_TIMEOUT = 30
Expand Down Expand Up @@ -106,7 +112,7 @@ def _start_container() -> str:
else:
name = f"seclab-shell-{uuid.uuid4().hex[:8]}"

cmd = ["docker", "run", "-d", "--name", name]
cmd = ["docker", "run", "-d", "--name", name, "--network", CONTAINER_NETWORK]
if not CONTAINER_PERSIST:
Comment thread
anticomputer marked this conversation as resolved.
cmd.append("--rm")
if CONTAINER_WORKSPACE:
Expand Down
1 change: 1 addition & 0 deletions src/seclab_taskflows/toolboxes/container_shell_base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', required=False) }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', required=False) }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '30') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', 'bridge') }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
Expand Down
1 change: 1 addition & 0 deletions src/seclab_taskflows/toolboxes/container_shell_sast.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ server_params:
CONTAINER_TIMEOUT: "{{ env('CONTAINER_TIMEOUT', '60') }}"
CONTAINER_PERSIST: "{{ env('CONTAINER_PERSIST', required=False) }}"
CONTAINER_PERSIST_KEY: "{{ env('CONTAINER_PERSIST_KEY', required=False) }}"
CONTAINER_NETWORK: "{{ env('CONTAINER_NETWORK', required=False) }}"
LOG_DIR: "{{ env('LOG_DIR') }}"

confirm:
Expand Down
42 changes: 42 additions & 0 deletions tests/test_container_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# SPDX-License-Identifier: MIT

import subprocess
import importlib
from unittest.mock import MagicMock, patch

import pytest
Expand Down Expand Up @@ -89,6 +90,47 @@ def test_start_container_rejects_empty_image(self):
with pytest.raises(RuntimeError, match="CONTAINER_IMAGE is not set"):
cs_mod._start_container()

def test_start_container_default_network_none(self):
with (
patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"),
patch.object(cs_mod, "CONTAINER_WORKSPACE", ""),
patch.object(cs_mod, "CONTAINER_NETWORK", "none"),
patch("subprocess.run", return_value=_make_proc(returncode=0)) as mock_run,
):
cs_mod._start_container()
cmd = mock_run.call_args[0][0]
assert "--network" in cmd
assert cmd[cmd.index("--network") + 1] == "none"

def test_start_container_opt_in_network(self):
with (
patch.object(cs_mod, "CONTAINER_IMAGE", "test-image:latest"),
patch.object(cs_mod, "CONTAINER_WORKSPACE", ""),
patch.object(cs_mod, "CONTAINER_NETWORK", "bridge"),
patch("subprocess.run", return_value=_make_proc(returncode=0)) as mock_run,
):
cs_mod._start_container()
cmd = mock_run.call_args[0][0]
assert "--network" in cmd
assert cmd[cmd.index("--network") + 1] == "bridge"

def test_network_defaults_to_none_when_unset(self, monkeypatch):
monkeypatch.delenv("CONTAINER_NETWORK", raising=False)
reloaded = importlib.reload(cs_mod)
try:
assert reloaded.CONTAINER_NETWORK == "none"
Comment thread
anticomputer marked this conversation as resolved.
finally:
importlib.reload(cs_mod)

Comment thread
anticomputer marked this conversation as resolved.
def test_network_falls_back_to_none_when_blank(self, monkeypatch):
monkeypatch.setenv("CONTAINER_NETWORK", " ")
reloaded = importlib.reload(cs_mod)
try:
assert reloaded.CONTAINER_NETWORK == "none"
Comment thread
anticomputer marked this conversation as resolved.
finally:
monkeypatch.delenv("CONTAINER_NETWORK", raising=False)
importlib.reload(cs_mod)

Comment thread
anticomputer marked this conversation as resolved.
Outdated

# ---------------------------------------------------------------------------
# shell_exec tests
Expand Down
Loading