From 5cc39ecae0fb1d8d5a012a838a16e38651be4a13 Mon Sep 17 00:00:00 2001 From: Stephen Shao Date: Sun, 3 May 2026 21:08:54 +0000 Subject: [PATCH 1/3] refactor(v2-review): shell injection hardening, bug fixes, test and config cleanup - Harden shell commands with shlex.quote() in docker.py, container_runner.py, docker_builder.py, and run_orchestrator.py to prevent injection via user-controlled values (image names, paths, container names) - Fix TypeError when kfd_renderDs is None on restricted ROCm < 6.4.1 systems - Add CANCELLED to deployment monitor terminal states to prevent infinite loop - Consolidate pytest config into pyproject.toml and delete redundant pytest.ini - Remove sys.path hack and duplicate marker registration from conftest.py - Fix global error handler state leak in test_error_handling.py - Add test_shell_quoting.py with 11 tests validating quoting behavior Co-Authored-By: Claude Opus 4 --- pyproject.toml | 23 +- pytest.ini | 85 ----- src/madengine/core/context.py | 6 + src/madengine/core/docker.py | 9 +- src/madengine/deployment/base.py | 2 +- src/madengine/execution/container_runner.py | 10 +- src/madengine/execution/docker_builder.py | 4 +- .../orchestration/run_orchestrator.py | 5 +- tests/conftest.py | 36 +- tests/unit/test_error_handling.py | 6 + tests/unit/test_shell_quoting.py | 344 ++++++++++++++++++ 11 files changed, 391 insertions(+), 139 deletions(-) delete mode 100644 pytest.ini create mode 100644 tests/unit/test_shell_quoting.py diff --git a/pyproject.toml b/pyproject.toml index 0c83f30a..c1a641b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -149,12 +149,25 @@ module = [ ignore_missing_imports = true [tool.pytest.ini_options] -testpaths = ["tests"] -python_paths = ["src"] -addopts = "-v --tb=short" +testpaths = ["tests/unit", "tests/integration", "tests/e2e"] +pythonpath = ["src"] +addopts = "-v --tb=short -ra --strict-markers -W default" +minversion = "3.8" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] markers = [ - "slow: marks tests as slow (deselect with '-m \"not slow\"')", - "integration: marks tests as integration tests", + "unit: Fast unit tests (no external dependencies)", + "integration: Integration tests (may be slower, test multiple components)", + "e2e: End-to-end tests (require full environment, Docker, may be very slow)", + "slow: Slow tests (can be skipped with -m \"not slow\")", + "gpu: Tests that require GPU hardware", + "amd: Tests specific to AMD GPUs", + "nvidia: Tests specific to NVIDIA GPUs", + "cpu: Tests for CPU-only execution", + "requires_docker: Tests that require Docker daemon", + "requires_models: Tests that require model fixtures", ] [tool.coverage.run] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 32821037..00000000 --- a/pytest.ini +++ /dev/null @@ -1,85 +0,0 @@ -[pytest] -# Pytest configuration for madengine - -# Test discovery -python_files = test_*.py -python_classes = Test* -python_functions = test_* -testpaths = tests/unit tests/integration tests/e2e - -# Output and reporting -addopts = - # Verbose output - -v - # Show local variables in tracebacks - --tb=short - # Show summary of all test outcomes - -ra - # Strict markers (fail on unknown markers) - --strict-markers - # Show warnings - -W default - # Coverage (if pytest-cov is installed) - # --cov=src/madengine - # --cov-report=term-missing - # --cov-report=html - -# Markers for test categorization -markers = - unit: Fast unit tests (no external dependencies) - integration: Integration tests (may be slower, test multiple components) - e2e: End-to-end tests (require full environment, Docker, may be very slow) - slow: Slow tests (can be skipped with -m "not slow") - gpu: Tests that require GPU hardware - amd: Tests specific to AMD GPUs - nvidia: Tests specific to NVIDIA GPUs - cpu: Tests for CPU-only execution - requires_docker: Tests that require Docker daemon - requires_models: Tests that require model fixtures - -# Test execution -# Skip slow tests by default (run with --runslow to include them) -# To run only unit tests: pytest -m unit -# To run integration tests: pytest -m integration -# To exclude GPU tests: pytest -m "not gpu" -# To run AMD-specific tests: pytest -m amd - -# Logging -log_cli = false -log_cli_level = INFO -log_cli_format = %(asctime)s [%(levelname)8s] %(message)s -log_cli_date_format = %Y-%m-%d %H:%M:%S - -# Test timeouts (requires pytest-timeout) -# timeout = 300 -# timeout_method = thread - -# Warnings -filterwarnings = - # Treat warnings as errors (strict mode) - # error - # Ignore specific warnings - ignore::DeprecationWarning - ignore::PendingDeprecationWarning - -# Minimum Python version -minversion = 3.8 - -# Coverage options (requires pytest-cov) -[coverage:run] -source = src/madengine -omit = - */tests/* - */test_*.py - */__pycache__/* - */site-packages/* - -[coverage:report] -exclude_lines = - pragma: no cover - def __repr__ - raise AssertionError - raise NotImplementedError - if __name__ == .__main__.: - if TYPE_CHECKING: - @abstractmethod diff --git a/src/madengine/core/context.py b/src/madengine/core/context.py index fb934483..eb129b82 100644 --- a/src/madengine/core/context.py +++ b/src/madengine/core/context.py @@ -758,6 +758,12 @@ def get_gpu_renderD_nodes(self) -> typing.Optional[typing.List[int]]: print(f"Warning: Failed to parse unique_id from line '{item}': {e}") continue + if kfd_renderDs is None: + raise RuntimeError( + "KFD topology not accessible and required for ROCm < 6.4.1 GPU mapping. " + "Check permissions on /sys/devices/virtual/kfd/kfd/topology/nodes" + ) + if len(kfd_unique_ids) != len(kfd_renderDs): raise RuntimeError( f"Mismatch between unique_ids count ({len(kfd_unique_ids)}) " diff --git a/src/madengine/core/docker.py b/src/madengine/core/docker.py index 115b9448..24f0e213 100644 --- a/src/madengine/core/docker.py +++ b/src/madengine/core/docker.py @@ -91,10 +91,11 @@ def __init__( # add mounts if mounts is not None: for mount in mounts: - command += "-v " + mount + ":" + mount + " " + quoted_mount = shlex.quote(mount) + command += "-v " + quoted_mount + ":" + quoted_mount + " " # add current working directory - command += "-v " + cwd + ":/myworkspace/ " + command += "-v " + shlex.quote(cwd) + ":/myworkspace/ " # add envVars _env_key_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") @@ -105,8 +106,8 @@ def __init__( command += "-e " + evar + "=" + shlex.quote(str(envVars[evar])) + " " command += "--workdir /myworkspace/ " - command += "--name " + container_name + " " - command += image + " " + command += "--name " + shlex.quote(container_name) + " " + command += shlex.quote(image) + " " # Use 'cat' to keep container alive (blocks waiting for stdin) # Works reliably across all deployment types (local, k8s, slurm) diff --git a/src/madengine/deployment/base.py b/src/madengine/deployment/base.py index a032c037..981931f0 100644 --- a/src/madengine/deployment/base.py +++ b/src/madengine/deployment/base.py @@ -239,7 +239,7 @@ def _monitor_until_complete(self, deployment_id: str) -> DeploymentResult: while True: status = self.monitor(deployment_id) - if status.status in [DeploymentStatus.SUCCESS, DeploymentStatus.FAILED, DeploymentStatus.UNKNOWN]: + if status.status in [DeploymentStatus.SUCCESS, DeploymentStatus.FAILED, DeploymentStatus.UNKNOWN, DeploymentStatus.CANCELLED]: return status # Still running, wait and check again diff --git a/src/madengine/execution/container_runner.py b/src/madengine/execution/container_runner.py index 2ffc8a31..c9481031 100644 --- a/src/madengine/execution/container_runner.py +++ b/src/madengine/execution/container_runner.py @@ -557,16 +557,16 @@ def pull_image( print(f"🔄 Using fresh pull policy for SLURM compute node (prevents cached layer corruption)") # Remove any existing cached image to force fresh pull try: - self.console.sh(f"docker rmi -f {registry_image} 2>/dev/null || true") + self.console.sh(f"docker rmi -f {shlex.quote(registry_image)} 2>/dev/null || true") print(f"✓ Removed cached image layers") except Exception: pass # It's okay if image doesn't exist try: - self.console.sh(f"docker pull {registry_image}") + self.console.sh(f"docker pull {shlex.quote(registry_image)}") if local_name: - self.console.sh(f"docker tag {registry_image} {local_name}") + self.console.sh(f"docker tag {shlex.quote(registry_image)} {shlex.quote(local_name)}") print(f"🏷️ Tagged as: {local_name}") self.rich_console.print(f"[bold green]✅ Successfully pulled and tagged image[/bold green]") self.rich_console.print(f"[dim]{'='*80}[/dim]") @@ -688,7 +688,7 @@ def get_mount_arg(self, mount_datapaths: typing.List) -> str: for mount_datapath in mount_datapaths: if mount_datapath: mount_args += ( - f"-v {mount_datapath['path']}:{mount_datapath['home']}" + f"-v {shlex.quote(mount_datapath['path'])}:{shlex.quote(mount_datapath['home'])}" ) if ( "readwrite" in mount_datapath @@ -702,7 +702,7 @@ def get_mount_arg(self, mount_datapaths: typing.List) -> str: if "docker_mounts" in self.context.ctx: for mount_arg in self.context.ctx["docker_mounts"].keys(): mount_args += ( - f"-v {self.context.ctx['docker_mounts'][mount_arg]}:{mount_arg} " + f"-v {shlex.quote(self.context.ctx['docker_mounts'][mount_arg])}:{shlex.quote(mount_arg)} " ) return mount_args diff --git a/src/madengine/execution/docker_builder.py b/src/madengine/execution/docker_builder.py index 56f33d6d..c78a0af9 100644 --- a/src/madengine/execution/docker_builder.py +++ b/src/madengine/execution/docker_builder.py @@ -179,8 +179,8 @@ def build_image( build_command = ( f"docker build {use_cache_str} --network=host " - f"-t {docker_image} --pull -f {dockerfile} " - f"{build_args} {docker_context}" + f"-t {shlex.quote(docker_image)} --pull -f {shlex.quote(dockerfile)} " + f"{build_args} {shlex.quote(docker_context)}" ) # Execute build with log redirection diff --git a/src/madengine/orchestration/run_orchestrator.py b/src/madengine/orchestration/run_orchestrator.py index 6742b2a5..78ef898f 100644 --- a/src/madengine/orchestration/run_orchestrator.py +++ b/src/madengine/orchestration/run_orchestrator.py @@ -13,6 +13,7 @@ import json import os +import shlex import subprocess from pathlib import Path from typing import Dict, Optional @@ -391,12 +392,12 @@ def _create_manifest_from_local_image( # Validate that the image exists locally or can be pulled try: - self.console.sh(f"docker image inspect {image_name} > /dev/null 2>&1") + self.console.sh(f"docker image inspect {shlex.quote(image_name)} > /dev/null 2>&1") self.rich_console.print(f"[green]✓ Image {image_name} found locally[/green]") except (subprocess.CalledProcessError, RuntimeError) as e: self.rich_console.print(f"[yellow]⚠️ Image {image_name} not found locally, attempting to pull...[/yellow]") try: - self.console.sh(f"docker pull {image_name}") + self.console.sh(f"docker pull {shlex.quote(image_name)}") self.rich_console.print(f"[green]✓ Successfully pulled {image_name}[/green]") except Exception as e: raise RuntimeError( diff --git a/tests/conftest.py b/tests/conftest.py index 91241e01..ed16cec9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,14 +9,9 @@ import json import os -import sys import tempfile -from pathlib import Path - -_SRC = Path(__file__).resolve().parents[1] / "src" -if _SRC.is_dir() and str(_SRC) not in sys.path: - sys.path.insert(0, str(_SRC)) from unittest.mock import MagicMock, patch + import pytest @@ -361,35 +356,6 @@ def integration_test_env(): yield env_vars -# ============================================================================ -# Pytest Configuration -# ============================================================================ - -def pytest_configure(config): - """Configure pytest with custom markers.""" - config.addinivalue_line( - "markers", "integration: marks tests as integration tests (may be slow)" - ) - config.addinivalue_line( - "markers", "unit: marks tests as fast unit tests" - ) - config.addinivalue_line( - "markers", "gpu: marks tests that require GPU hardware" - ) - config.addinivalue_line( - "markers", "amd: marks tests specific to AMD GPUs" - ) - config.addinivalue_line( - "markers", "nvidia: marks tests specific to NVIDIA GPUs" - ) - config.addinivalue_line( - "markers", "cpu: marks tests for CPU-only execution" - ) - config.addinivalue_line( - "markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')" - ) - - # ============================================================================ # Utility Functions for Tests # ============================================================================ diff --git a/tests/unit/test_error_handling.py b/tests/unit/test_error_handling.py index dc210a0b..b56270e7 100644 --- a/tests/unit/test_error_handling.py +++ b/tests/unit/test_error_handling.py @@ -149,6 +149,12 @@ def test_handle_generic_error(self): class TestGlobalErrorHandler: """Test global error handler functionality.""" + def setup_method(self): + set_error_handler(None) + + def teardown_method(self): + set_error_handler(None) + def test_set_and_get_error_handler(self): """Test setting and getting global error handler.""" mock_console = Mock(spec=Console) diff --git a/tests/unit/test_shell_quoting.py b/tests/unit/test_shell_quoting.py new file mode 100644 index 00000000..604ecdee --- /dev/null +++ b/tests/unit/test_shell_quoting.py @@ -0,0 +1,344 @@ +"""Unit tests for shell injection hardening via shlex.quote(). + +Validates that Docker, DockerBuilder, ContainerRunner, and RunOrchestrator +properly quote user-controlled values interpolated into shell commands. +""" + +import os +import shlex +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +from madengine.core.console import Console + + +MALICIOUS_IMAGE = "img:latest; rm -rf /" +MALICIOUS_PATH = "/data/path; echo pwned" +SAFE_IMAGE = "registry.io/org/model:ci-tag" + + +class TestDockerInitQuoting: + """Docker.__init__ quotes container_name, image, and mount paths.""" + + @patch.object(Console, "sh", return_value="") + def test_container_name_and_image_are_quoted(self, mock_sh): + from madengine.core.docker import Docker + + mock_sh.side_effect = self._make_sh_side_effect() + try: + Docker( + image=MALICIOUS_IMAGE, + container_name="evil;name", + dockerOpts="", + console=Console(shellVerbose=False), + ) + except Exception: + pass + + docker_run_calls = [ + c for c in mock_sh.call_args_list if "docker run" in str(c) + ] + assert docker_run_calls, "Expected at least one docker run call" + run_cmd = docker_run_calls[0].args[0] + assert shlex.quote("evil;name") in run_cmd + assert shlex.quote(MALICIOUS_IMAGE) in run_cmd + + @patch.object(Console, "sh", return_value="") + def test_mount_paths_are_quoted(self, mock_sh): + from madengine.core.docker import Docker + + mock_sh.side_effect = self._make_sh_side_effect() + try: + Docker( + image="ubuntu:22.04", + container_name="test-container", + dockerOpts="", + mounts=[MALICIOUS_PATH], + console=Console(shellVerbose=False), + ) + except Exception: + pass + + docker_run_calls = [ + c for c in mock_sh.call_args_list if "docker run" in str(c) + ] + assert docker_run_calls + run_cmd = docker_run_calls[0].args[0] + assert shlex.quote(MALICIOUS_PATH) in run_cmd + + @patch.object(Console, "sh", return_value="") + def test_cwd_is_quoted(self, mock_sh): + from madengine.core.docker import Docker + + mock_sh.side_effect = self._make_sh_side_effect() + try: + with patch("os.getcwd", return_value="/path with spaces/project"): + Docker( + image="ubuntu:22.04", + container_name="test", + dockerOpts="", + console=Console(shellVerbose=False), + ) + except Exception: + pass + + docker_run_calls = [ + c for c in mock_sh.call_args_list if "docker run" in str(c) + ] + assert docker_run_calls + run_cmd = docker_run_calls[0].args[0] + assert shlex.quote("/path with spaces/project") in run_cmd + + @staticmethod + def _make_sh_side_effect(): + def side_effect(cmd, **kwargs): + if "id -u" in cmd: + return "1000" + if "id -g" in cmd: + return "1000" + if "docker container ps" in cmd: + return "" + if "docker run" in cmd: + return "" + if "docker ps" in cmd: + return "abc123" + return "" + + return side_effect + + +class TestDockerBuilderQuoting: + """DockerBuilder.build_image quotes dockerfile, image, and context.""" + + def test_build_command_quotes_image_dockerfile_context(self): + from madengine.execution.docker_builder import DockerBuilder + + ctx = MagicMock() + ctx.ctx = {} + builder = DockerBuilder(ctx) + mock_console = MagicMock() + mock_console.sh = MagicMock(return_value="") + builder.console = mock_console + builder.rich_console = MagicMock() + builder.live_output = False + + dockerfile = "docker/evil;file.Dockerfile" + docker_image = "img:$(whoami)" + docker_context = "/ctx;path" + model_info = {"name": "test/model"} + + builder.get_context_path = MagicMock(return_value=docker_context) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".live.log", delete=False) as f: + log_path = f.name + + try: + with patch("builtins.open", create=True) as mock_open: + mock_open.return_value.__enter__ = MagicMock() + mock_open.return_value.__exit__ = MagicMock(return_value=False) + + builder.build_image( + model_info=model_info, + dockerfile=dockerfile, + override_image_name=docker_image, + ) + except Exception: + pass + + build_calls = [ + c for c in mock_console.sh.call_args_list + if "docker build" in str(c) + ] + assert build_calls, "Expected a docker build call" + build_cmd = build_calls[0].args[0] + assert shlex.quote(docker_image) in build_cmd + assert shlex.quote(dockerfile) in build_cmd + assert shlex.quote(docker_context) in build_cmd + + +class TestContainerRunnerPullQuoting: + """ContainerRunner.pull_image quotes registry_image and local_name.""" + + @patch.dict(os.environ, {"MAD_DEPLOYMENT_TYPE": "local"}, clear=False) + def test_pull_image_quotes_registry_and_local(self): + from madengine.execution.container_runner import ContainerRunner + + ctx = MagicMock() + ctx.ctx = {} + mock_console = MagicMock() + mock_console.sh = MagicMock(return_value="") + runner = ContainerRunner(context=ctx, console=mock_console) + runner.rich_console = MagicMock() + + registry_image = "registry/img:$(evil)" + local_name = "local;name" + + try: + runner.pull_image(registry_image, local_name=local_name) + except Exception: + pass + + all_cmds = [c.args[0] for c in mock_console.sh.call_args_list] + + pull_cmds = [c for c in all_cmds if "docker pull" in c] + assert pull_cmds + assert shlex.quote(registry_image) in pull_cmds[0] + + tag_cmds = [c for c in all_cmds if "docker tag" in c] + assert tag_cmds + assert shlex.quote(registry_image) in tag_cmds[0] + assert shlex.quote(local_name) in tag_cmds[0] + + @patch.dict(os.environ, {"MAD_DEPLOYMENT_TYPE": "local"}, clear=False) + def test_rmi_quotes_image_on_slurm(self): + from madengine.execution.container_runner import ContainerRunner + + ctx = MagicMock() + ctx.ctx = {} + mock_console = MagicMock() + mock_console.sh = MagicMock(return_value="") + runner = ContainerRunner(context=ctx, console=mock_console) + runner.rich_console = MagicMock() + + registry_image = "registry/img:$(evil)" + + with patch.dict( + os.environ, + {"MAD_DEPLOYMENT_TYPE": "slurm", "MAD_IN_SLURM_JOB": "1"}, + ): + try: + runner.pull_image(registry_image) + except Exception: + pass + + all_cmds = [c.args[0] for c in mock_console.sh.call_args_list] + rmi_cmds = [c for c in all_cmds if "docker rmi" in c] + assert rmi_cmds + assert shlex.quote(registry_image) in rmi_cmds[0] + + +class TestContainerRunnerMountQuoting: + """ContainerRunner.get_mount_arg quotes mount paths.""" + + def test_datapath_mounts_are_quoted(self): + from madengine.execution.container_runner import ContainerRunner + + ctx = MagicMock() + ctx.ctx = {} + runner = ContainerRunner(context=ctx, console=MagicMock()) + + mount_datapaths = [ + {"path": "/data;evil", "home": "/container;evil"}, + ] + + result = runner.get_mount_arg(mount_datapaths) + assert shlex.quote("/data;evil") in result + assert shlex.quote("/container;evil") in result + + def test_context_docker_mounts_are_quoted(self): + from madengine.execution.container_runner import ContainerRunner + + ctx = MagicMock() + ctx.ctx = { + "docker_mounts": {"/container;dst": "/host;src"}, + } + runner = ContainerRunner(context=ctx, console=MagicMock()) + + result = runner.get_mount_arg([]) + assert shlex.quote("/host;src") in result + assert shlex.quote("/container;dst") in result + + +class TestRunOrchestratorImageQuoting: + """RunOrchestrator quotes image_name in docker inspect and pull.""" + + @patch("madengine.orchestration.run_orchestrator.Context") + def test_image_inspect_is_quoted(self, mock_context): + from madengine.orchestration.run_orchestrator import RunOrchestrator + + mock_args = MagicMock() + mock_args.additional_context = None + mock_args.live_output = False + + orchestrator = RunOrchestrator(mock_args) + mock_console = MagicMock() + mock_console.sh = MagicMock(return_value="") + orchestrator.console = mock_console + orchestrator.rich_console = MagicMock() + + image_name = "img:$(whoami)" + + try: + orchestrator._create_manifest_from_local_image( + image_name=image_name, tags=["test"] + ) + except Exception: + pass + + all_cmds = [c.args[0] for c in mock_console.sh.call_args_list] + inspect_cmds = [c for c in all_cmds if "docker image inspect" in c] + assert inspect_cmds + assert shlex.quote(image_name) in inspect_cmds[0] + + @patch("madengine.orchestration.run_orchestrator.Context") + def test_docker_pull_is_quoted_on_fallback(self, mock_context): + from madengine.orchestration.run_orchestrator import RunOrchestrator + + mock_args = MagicMock() + mock_args.additional_context = None + mock_args.live_output = False + + orchestrator = RunOrchestrator(mock_args) + mock_console = MagicMock() + + call_count = [0] + def sh_side_effect(cmd, **kwargs): + call_count[0] += 1 + if "docker image inspect" in cmd: + raise RuntimeError("not found") + return "" + + mock_console.sh = MagicMock(side_effect=sh_side_effect) + orchestrator.console = mock_console + orchestrator.rich_console = MagicMock() + + image_name = "img:$(whoami)" + + try: + orchestrator._create_manifest_from_local_image( + image_name=image_name, tags=["test"] + ) + except Exception: + pass + + all_cmds = [c.args[0] for c in mock_console.sh.call_args_list] + pull_cmds = [c for c in all_cmds if "docker pull" in c] + assert pull_cmds + assert shlex.quote(image_name) in pull_cmds[0] + + +class TestSafeInputsUnchanged: + """Normal inputs (no metacharacters) produce working commands with the value present.""" + + @patch.dict(os.environ, {"MAD_DEPLOYMENT_TYPE": "local"}, clear=False) + def test_safe_image_name_still_works(self): + from madengine.execution.container_runner import ContainerRunner + + ctx = MagicMock() + ctx.ctx = {} + mock_console = MagicMock() + mock_console.sh = MagicMock(return_value="") + runner = ContainerRunner(context=ctx, console=mock_console) + runner.rich_console = MagicMock() + + try: + runner.pull_image(SAFE_IMAGE) + except Exception: + pass + + all_cmds = [c.args[0] for c in mock_console.sh.call_args_list] + pull_cmds = [c for c in all_cmds if "docker pull" in c] + assert pull_cmds + assert SAFE_IMAGE in pull_cmds[0] From f81168366e1c352401482f864a55bc904d56635f Mon Sep 17 00:00:00 2001 From: Stephen Shao Date: Mon, 11 May 2026 15:47:26 -0500 Subject: [PATCH 2/3] fix: complete shell injection hardening in docker_builder.py and clean up test imports Add shlex.quote() to remaining unquoted shell interpolations in docker_builder.py (grep, docker manifest inspect, docker tag, docker push, head commands). Remove unused pytest and tempfile imports and dead temp file block from test_shell_quoting.py. Co-Authored-By: Claude Opus 4 (1M context) --- src/madengine/execution/docker_builder.py | 10 +++++----- tests/unit/test_shell_quoting.py | 6 ------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/madengine/execution/docker_builder.py b/src/madengine/execution/docker_builder.py index c78a0af9..cd867667 100644 --- a/src/madengine/execution/docker_builder.py +++ b/src/madengine/execution/docker_builder.py @@ -207,7 +207,7 @@ def build_image( base_docker = self.context.ctx["docker_build_arg"]["BASE_DOCKER"] else: base_docker = self.console.sh( - f"grep '^ARG BASE_DOCKER=' {dockerfile} | sed -E 's/ARG BASE_DOCKER=//g'" + f"grep '^ARG BASE_DOCKER=' {shlex.quote(dockerfile)} | sed -E 's/ARG BASE_DOCKER=//g'" ) print(f"BASE DOCKER is {base_docker}") @@ -216,7 +216,7 @@ def build_image( docker_sha = "" try: docker_sha = self.console.sh( - f'docker manifest inspect {base_docker} | grep digest | head -n 1 | cut -d \\" -f 4' + f'docker manifest inspect {shlex.quote(base_docker)} | grep digest | head -n 1 | cut -d \\" -f 4' ) print(f"BASE DOCKER SHA is {docker_sha}") except Exception as e: @@ -297,7 +297,7 @@ def push_image( # Tag the image if different from local name if registry_image != docker_image: print(f"Tagging image: docker tag {docker_image} {registry_image}") - tag_command = f"docker tag {docker_image} {registry_image}" + tag_command = f"docker tag {shlex.quote(docker_image)} {shlex.quote(registry_image)}" self.console.sh(tag_command) else: print( @@ -305,7 +305,7 @@ def push_image( ) # Push the image - push_command = f"docker push {registry_image}" + push_command = f"docker push {shlex.quote(registry_image)}" self.rich_console.print(f"\n[bold blue]🚀 Starting docker push to registry...[/bold blue]") print(f"📤 Registry: {registry}") print(f"🏷️ Image: {registry_image}") @@ -559,7 +559,7 @@ def _get_dockerfiles_for_model(self, model_info: typing.Dict) -> typing.List[str for cur_docker_file in all_dockerfiles: # Get context of dockerfile dockerfiles[cur_docker_file] = self.console.sh( - f"head -n5 {cur_docker_file} | grep '# CONTEXT ' | sed 's/# CONTEXT //g'" + f"head -n5 {shlex.quote(cur_docker_file)} | grep '# CONTEXT ' | sed 's/# CONTEXT //g'" ) # Filter dockerfiles based on context diff --git a/tests/unit/test_shell_quoting.py b/tests/unit/test_shell_quoting.py index 604ecdee..122bc5cf 100644 --- a/tests/unit/test_shell_quoting.py +++ b/tests/unit/test_shell_quoting.py @@ -6,11 +6,8 @@ import os import shlex -import tempfile from unittest.mock import MagicMock, patch -import pytest - from madengine.core.console import Console @@ -131,9 +128,6 @@ def test_build_command_quotes_image_dockerfile_context(self): builder.get_context_path = MagicMock(return_value=docker_context) - with tempfile.NamedTemporaryFile(mode="w", suffix=".live.log", delete=False) as f: - log_path = f.name - try: with patch("builtins.open", create=True) as mock_open: mock_open.return_value.__enter__ = MagicMock() From a9ac8fabeb4ed7cdb4977dea5fb35af715f221e6 Mon Sep 17 00:00:00 2001 From: Stephen Shao Date: Wed, 13 May 2026 08:25:53 -0500 Subject: [PATCH 3/3] Added storage_class field with nfs pvc --- src/madengine/deployment/presets/k8s/defaults.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/madengine/deployment/presets/k8s/defaults.json b/src/madengine/deployment/presets/k8s/defaults.json index 36fc9f3e..fad3c31f 100644 --- a/src/madengine/deployment/presets/k8s/defaults.json +++ b/src/madengine/deployment/presets/k8s/defaults.json @@ -12,7 +12,7 @@ "ttl_seconds_after_finished": null, "allow_privileged_profiling": null, "nfs_storage_class": "nfs-banff", - "local_path_storage_class": "local-path", + "storage_class": "nfs-banff", "data_storage_class": "nfs-banff", "recreate_shared_data_pvc": false, "secrets": {