From f60902c72aa95b6bea046ae89bc766a66c9bb4d7 Mon Sep 17 00:00:00 2001 From: swang22 Date: Sun, 18 Oct 2026 17:35:13 -0400 Subject: [PATCH 1/2] Harden HOPE Julia subprocess arguments Validate debug solver selection, pass caller-controlled values through Julia ARGS, and add regression coverage. Responsibly reported by @hexisteme. (cherry picked from commit 808157da007d761e9b0a2bcaa605db7a6a38d810) --- HOPE/src/hope_mcp_server/core.py | 67 +++++++++++++++++++++++++------- HOPE/tests/test_core.py | 67 +++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 16 deletions(-) diff --git a/HOPE/src/hope_mcp_server/core.py b/HOPE/src/hope_mcp_server/core.py index 720d07f6..451b3dba 100644 --- a/HOPE/src/hope_mcp_server/core.py +++ b/HOPE/src/hope_mcp_server/core.py @@ -383,7 +383,12 @@ def looks_like_applocker_block(stdout: str, stderr: str) -> bool: def julia_string_literal(value: str | Path) -> str: - escaped = str(value).replace("\\", "\\\\").replace('"', '\\"') + escaped = ( + str(value) + .replace("\\", "\\\\") + .replace("$", "\\$") + .replace('"', '\\"') + ) return f'"{escaped}"' @@ -396,8 +401,13 @@ def build_project_julia_command(repo_root: Path, julia_bin: str) -> list[str]: def build_run_command(repo_root: Path, julia_bin: str, case_path: Path) -> list[str]: - julia_script = f"using HOPE; HOPE.run_hope({julia_string_literal(case_path)})" - return [*build_project_julia_command(repo_root, julia_bin), "-e", julia_script] + julia_script = "using HOPE; HOPE.run_hope(ARGS[1])" + return [ + *build_project_julia_command(repo_root, julia_bin), + "-e", + julia_script, + str(case_path), + ] def build_debug_solver_environment_command( @@ -421,8 +431,8 @@ def build_debug_solver_environment_command( "emit(\"STEP=using_HOPE_done\"); " "using JuMP; " "emit(\"STEP=using_JuMP_done\"); " - f"case = {julia_string_literal(case_path)}; " - f"solver = {julia_string_literal(solver)}; " + "case = ARGS[1]; " + "solver = ARGS[2]; " "emit(\"HOPE_PATH=\" * string(pathof(HOPE))); " "emit(\"STEP=initiate_solver_start\"); " "opt = HOPE.initiate_solver(case, solver); " @@ -436,6 +446,8 @@ def build_debug_solver_environment_command( "emit(\"MODEL_TYPE=\" * string(typeof(model))); " "emit(\"PROBE_STATUS=ok\")" ), + str(case_path), + solver, ] @@ -815,7 +827,16 @@ def hope_debug_solver_environment( return julia_error settings = read_yaml(settings_path_for_case(case_path)) - selected_solver = solver or str(settings.get("solver", "highs")) + selected_solver = str( + solver if solver is not None else settings.get("solver", "highs") + ).strip().lower() + if selected_solver not in _SOLVER_OPTIONS: + return error_result( + "invalid_solver", + f"Unsupported solver '{selected_solver}'.", + solver=selected_solver, + allowed_solvers=list(_SOLVER_OPTIONS), + ) command = build_debug_solver_environment_command( repo_root, julia_bin, @@ -902,7 +923,16 @@ def hope_debug_solver_environment_async( return julia_error settings = read_yaml(settings_path_for_case(case_path)) - selected_solver = solver or str(settings.get("solver", "highs")) + selected_solver = str( + solver if solver is not None else settings.get("solver", "highs") + ).strip().lower() + if selected_solver not in _SOLVER_OPTIONS: + return error_result( + "invalid_solver", + f"Unsupported solver '{selected_solver}'.", + solver=selected_solver, + allowed_solvers=list(_SOLVER_OPTIONS), + ) command = build_debug_solver_environment_command( repo_root, julia_bin, @@ -1501,12 +1531,14 @@ def hope_run_holistic( if julia_error is not None: return julia_error - julia_script = ( - "using HOPE; " - f"HOPE.run_hope_holistic({julia_string_literal(gtep_path)}, " - f"{julia_string_literal(pcm_path)})" - ) - command = [*build_project_julia_command(repo_root, julia_bin), "-e", julia_script] + julia_script = "using HOPE; HOPE.run_hope_holistic(ARGS[1], ARGS[2])" + command = [ + *build_project_julia_command(repo_root, julia_bin), + "-e", + julia_script, + str(gtep_path), + str(pcm_path), + ] # Tag job with both case IDs job_id = _launch_job( @@ -1579,9 +1611,14 @@ def hope_run_erec( julia_script = ( "using HOPE; " - f"HOPE.calculate_erec({julia_string_literal(case_path)}, overrides={overrides_str})" + f"HOPE.calculate_erec(ARGS[1], overrides={overrides_str})" ) - command = [*build_project_julia_command(repo_root, julia_bin), "-e", julia_script] + command = [ + *build_project_julia_command(repo_root, julia_bin), + "-e", + julia_script, + str(case_path), + ] job_id = _launch_job( command, diff --git a/HOPE/tests/test_core.py b/HOPE/tests/test_core.py index 1c3b1056..c98bbc21 100644 --- a/HOPE/tests/test_core.py +++ b/HOPE/tests/test_core.py @@ -14,6 +14,7 @@ from hope_mcp_server.core import ( _Job, _jobs, + build_debug_solver_environment_command, build_run_command, hope_cancel_job, hope_case_info, @@ -193,10 +194,30 @@ def test_build_run_command_uses_env_config(self) -> None: "--startup-file=no", f"--project={REPO_ROOT}", "-e", - f"using HOPE; HOPE.run_hope({julia_string_literal(case_path)})", + "using HOPE; HOPE.run_hope(ARGS[1])", + str(case_path), ], ) + def test_julia_string_literal_escapes_interpolation(self) -> None: + self.assertEqual(julia_string_literal("$solver"), '"\\$solver"') + + def test_debug_command_passes_dynamic_values_as_arguments(self) -> None: + case_path = REPO_ROOT / "ModelCases" / "$case" + command = build_debug_solver_environment_command( + REPO_ROOT, + JULIA_BIN, + case_path, + "$solver", + ) + script_index = command.index("-e") + 1 + script = command[script_index] + self.assertIn("case = ARGS[1]", script) + self.assertIn("solver = ARGS[2]", script) + self.assertNotIn(str(case_path), script) + self.assertNotIn("$solver", script) + self.assertEqual(command[script_index + 1 :], [str(case_path), "$solver"]) + def test_missing_julia_path_returns_structured_error(self) -> None: with mock.patch.dict( "os.environ", @@ -311,6 +332,37 @@ def test_debug_solver_environment_reports_probe_details(self) -> None: self.assertEqual(result["probe"]["PROBE_STATUS"], "ok") run_mock.assert_called_once() + def test_debug_solver_environment_rejects_invalid_solver(self) -> None: + with ( + mock.patch("hope_mcp_server.core.validate_julia_command", return_value=(JULIA_BIN, None)), + mock.patch("hope_mcp_server.core.subprocess.run") as run_mock, + ): + result = hope_debug_solver_environment( + case_id="MD_GTEP_clean_case", + solver="$unsupported", + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "invalid_solver") + self.assertEqual( + result["allowed_solvers"], + ["cbc", "clp", "highs", "scip", "gurobi", "cplex"], + ) + run_mock.assert_not_called() + + def test_debug_solver_environment_rejects_invalid_configured_solver(self) -> None: + with ( + mock.patch("hope_mcp_server.core.validate_julia_command", return_value=(JULIA_BIN, None)), + mock.patch( + "hope_mcp_server.core.read_yaml", + return_value={"solver": "$unsupported"}, + ), + mock.patch("hope_mcp_server.core.subprocess.run") as run_mock, + ): + result = hope_debug_solver_environment(case_id="MD_GTEP_clean_case") + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "invalid_solver") + run_mock.assert_not_called() + def test_debug_solver_environment_reports_timeout_progress(self) -> None: timeout_error = subprocess.TimeoutExpired( cmd=["julia"], @@ -342,6 +394,19 @@ def test_debug_solver_environment_async_launches_background_job(self) -> None: self.assertIn("--startup-file=no", result["command"]) launch_mock.assert_called_once() + def test_debug_solver_environment_async_rejects_invalid_solver(self) -> None: + with ( + mock.patch("hope_mcp_server.core.validate_julia_command", return_value=(JULIA_BIN, None)), + mock.patch("hope_mcp_server.core._launch_job") as launch_mock, + ): + result = hope_debug_solver_environment_async( + case_id="MD_GTEP_clean_case", + solver="$unsupported", + ) + self.assertFalse(result["ok"]) + self.assertEqual(result["error_type"], "invalid_solver") + launch_mock.assert_not_called() + def test_setup_command_disables_startup_file(self) -> None: command = setup_command(REPO_ROOT, JULIA_BIN) self.assertIn("--startup-file=no", command) From 8503b764ae288045143a07f9a2e99826640f93c6 Mon Sep 17 00:00:00 2001 From: Qian Zhang Date: Sun, 18 Oct 2026 21:04:49 -0400 Subject: [PATCH 2/2] Release 0.4.1 Patch release carrying only the HOPE Julia code-injection fix (#130) on top of 0.4.0. Co-Authored-By: Claude Opus 5.5 --- powermcp/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_registry.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/powermcp/__init__.py b/powermcp/__init__.py index 557268a2..893b21be 100644 --- a/powermcp/__init__.py +++ b/powermcp/__init__.py @@ -7,6 +7,6 @@ Configure and connect MCP clients with the `powermcp` CLI (`powermcp install`). """ -__version__ = "0.4.0" +__version__ = "0.4.1" __all__ = ["__version__"] diff --git a/pyproject.toml b/pyproject.toml index e8b4301c..45d29d02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" [project] name = "powermcp" -version = "0.4.0" +version = "0.4.1" description = "MCP servers for power-system software (PowerWorld, OpenDSS, PSS/E, pandapower, PyPSA, and more)" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_registry.py b/tests/test_registry.py index 04968391..28a7a5a8 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -17,7 +17,7 @@ def test_package_versions_match(): project = tomllib.loads((registry.REPO_ROOT / "pyproject.toml").read_text())["project"] - assert project["version"] == __version__ == "0.4.0" + assert project["version"] == __version__ == "0.4.1" def test_core_tools_present_and_have_no_extra():