diff --git a/openhands-sdk/openhands/sdk/utils/command.py b/openhands-sdk/openhands/sdk/utils/command.py index 9c6c102eb2..80acd47b23 100644 --- a/openhands-sdk/openhands/sdk/utils/command.py +++ b/openhands-sdk/openhands/sdk/utils/command.py @@ -6,6 +6,7 @@ from collections.abc import Mapping from openhands.sdk.logger import get_logger +from openhands.sdk.utils.redact import redact_text_secrets logger = get_logger(__name__) @@ -61,11 +62,14 @@ def execute_command( if isinstance(cmd, str): cmd_to_run = cmd use_shell = True - logger.info("$ %s", cmd) + cmd_str = cmd else: cmd_to_run = cmd use_shell = False - logger.info("$ %s", " ".join(shlex.quote(c) for c in cmd)) + cmd_str = " ".join(shlex.quote(c) for c in cmd) + + # Log the command with sensitive values redacted + logger.info("$ %s", redact_text_secrets(cmd_str)) proc = subprocess.Popen( cmd_to_run, diff --git a/tests/sdk/utils/test_command.py b/tests/sdk/utils/test_command.py index bfca3f86a3..ddd41d0f52 100644 --- a/tests/sdk/utils/test_command.py +++ b/tests/sdk/utils/test_command.py @@ -1,8 +1,9 @@ from collections import OrderedDict +from unittest.mock import patch import pytest -from openhands.sdk.utils.command import sanitized_env +from openhands.sdk.utils.command import execute_command, sanitized_env def test_sanitized_env_returns_copy(): @@ -47,3 +48,81 @@ def test_sanitized_env_removes_ld_library_path_when_orig_empty(): """When LD_LIBRARY_PATH_ORIG is empty, removes LD_LIBRARY_PATH.""" env = {"LD_LIBRARY_PATH": "/pyinstaller", "LD_LIBRARY_PATH_ORIG": ""} assert "LD_LIBRARY_PATH" not in sanitized_env(env) + + +# --------------------------------------------------------------------------- +# execute_command logging redaction +# --------------------------------------------------------------------------- + + +class TestExecuteCommandLoggingRedaction: + """Tests for sensitive value redaction in execute_command logging.""" + + def test_logs_command_without_errors(self, caplog): + """Command logging with redaction doesn't raise errors.""" + with patch("subprocess.Popen") as mock_popen: + mock_process = mock_popen.return_value + mock_process.stdout = None + mock_process.stderr = None + + cmd = ["docker", "run", "-e", "LMNR_PROJECT_API_KEY=secret123", "image"] + + try: + execute_command(cmd) + except RuntimeError: + # Logging should happen even if subprocess fails + pass + + # Command should be logged + assert "docker" in caplog.text + assert "run" in caplog.text + assert "image" in caplog.text + + def test_redacts_api_key_from_string_command(self): + """API keys in string commands are properly redacted.""" + from openhands.sdk.utils.redact import redact_text_secrets + + # Test the redaction function directly + # Valid Anthropic key format: sk-ant-api[2 digits]-[20+ chars] + cmd_str = "curl -H 'Authorization: sk-ant-api00-abcd1234567890abcdefghijklmnop' https://api.anthropic.com" + redacted = redact_text_secrets(cmd_str) + + # The secret should be redacted in the output of the function + assert "sk-ant-api00-abcd1234567890abcdefghijklmnop" not in redacted + assert "" in redacted + # Command structure should be preserved + assert "curl" in redacted + assert "https://api.anthropic.com" in redacted + + def test_redacts_key_value_env_format(self): + """KEY=VALUE environment variable format is redacted.""" + from openhands.sdk.utils.redact import redact_text_secrets + + cmd_str = "docker run -e api_key='secretvalue123456789' -e DEBUG=true image" + redacted = redact_text_secrets(cmd_str) + + # api_key value should be redacted + assert "secretvalue123456789" not in redacted + # But non-sensitive DEBUG value should be present + assert "DEBUG" in redacted + # Command structure preserved + assert "docker" in redacted + + def test_preserves_non_sensitive_args(self, caplog): + """Non-sensitive arguments are preserved in logs.""" + with patch("subprocess.Popen") as mock_popen: + mock_process = mock_popen.return_value + mock_process.stdout = None + mock_process.stderr = None + + cmd = ["docker", "run", "-e", "DEBUG=true", "image:latest"] + + try: + execute_command(cmd) + except RuntimeError: + pass + + # Non-sensitive values should be visible + assert "DEBUG=true" in caplog.text + assert "image:latest" in caplog.text + assert "docker" in caplog.text