diff --git a/sdk/ml/azure-ai-ml/CHANGELOG.md b/sdk/ml/azure-ai-ml/CHANGELOG.md index bd975fafa7ad..02afd1fe6fb4 100644 --- a/sdk/ml/azure-ai-ml/CHANGELOG.md +++ b/sdk/ml/azure-ai-ml/CHANGELOG.md @@ -6,6 +6,7 @@ ### Bugs Fixed +- Quote each argument in the local-endpoint `run_cli_command` helper before running it under `shell=True`, so a path containing spaces or shell metacharacters is passed through literally instead of being re-interpreted by the shell. - Fixed cross-tenant registry endpoint resolution for deployment template operations by using the registry discovery API instead of ARM calls. - Fixed deployment template update failing with immutable field errors by ensuring `allowedInstanceType` and `allowedEnvironmentVariableOverrides` are properly round-tripped during serialization. diff --git a/sdk/ml/azure-ai-ml/azure/ai/ml/_local_endpoints/utilities/commandline_utility.py b/sdk/ml/azure-ai-ml/azure/ai/ml/_local_endpoints/utilities/commandline_utility.py index 3f41e5f0ffab..45b7d95a3515 100644 --- a/sdk/ml/azure-ai-ml/azure/ai/ml/_local_endpoints/utilities/commandline_utility.py +++ b/sdk/ml/azure-ai-ml/azure/ai/ml/_local_endpoints/utilities/commandline_utility.py @@ -4,6 +4,7 @@ import json import os +import shlex import subprocess import sys import time @@ -27,9 +28,14 @@ def run_cli_command( if not custom_environment: custom_environment = os.environ - # We do this join to construct a command because "shell=True" flag, used below, doesn't work with the vector - # argv form on a mac OS. - command_to_execute = " ".join(cmd_arguments) + # "shell=True" (used below) is needed so the platform "az"/"code" wrappers resolve, and it doesn't work with + # the vector argv form on macOS, so the arguments are collapsed into a single command string. Quote each + # argument while joining so a value containing spaces or shell metacharacters is passed through literally + # instead of being re-interpreted by the shell. + if os.name == "nt": + command_to_execute = subprocess.list2cmdline(cmd_arguments) + else: + command_to_execute = shlex.join(cmd_arguments) if not do_not_print: # Avoid printing the az login service principal password, for example print("Preparing to run CLI command: \n{}\n".format(command_to_execute)) diff --git a/sdk/ml/azure-ai-ml/tests/local_endpoint/unittests/test_commandline_utility.py b/sdk/ml/azure-ai-ml/tests/local_endpoint/unittests/test_commandline_utility.py new file mode 100644 index 000000000000..b2921d56862e --- /dev/null +++ b/sdk/ml/azure-ai-ml/tests/local_endpoint/unittests/test_commandline_utility.py @@ -0,0 +1,50 @@ +# --------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# --------------------------------------------------------- + + +import os +import shlex +import subprocess +from unittest.mock import patch + +import pytest + +from azure.ai.ml._local_endpoints.utilities.commandline_utility import run_cli_command + + +def _expected_command(args): + return subprocess.list2cmdline(args) if os.name == "nt" else shlex.join(args) + + +@pytest.mark.unittest +class TestRunCliCommand: + def test_arguments_are_quoted_before_shell_execution(self): + # An argument carrying shell metacharacters (e.g. a deployment-derived path component). + args = ["echo", "x$(touch pwned)"] + + with patch( + "azure.ai.ml._local_endpoints.utilities.commandline_utility.subprocess.check_output", + return_value=b"", + ) as mock_check_output: + run_cli_command(args) + + command_to_execute = mock_check_output.call_args[0][0] + # The raw space-join would let the shell evaluate the $(...) substitution. + assert command_to_execute != " ".join(args) + assert command_to_execute == _expected_command(args) + + def test_plain_uri_argument_is_unchanged(self): + uri = "vscode-remote://dev-container+deadbeef/var/azureml-app/onlinescoring" + args = ["code", "--folder-uri", uri] + + with patch( + "azure.ai.ml._local_endpoints.utilities.commandline_utility.subprocess.check_output", + return_value=b"", + ) as mock_check_output: + run_cli_command(args) + + command_to_execute = mock_check_output.call_args[0][0] + assert command_to_execute == _expected_command(args) + if os.name != "nt": + assert command_to_execute == f"code --folder-uri {uri}"