diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index 0997872c69fd..bdf8bc4ff336 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -370,6 +370,88 @@ def test_permission_error(self, monkeypatch, sample_wav): class TestTranscribeLocalCommand: + def test_command_provider_uses_sanitized_child_env(self, monkeypatch): + """Salvage of #56332: command STT must not inherit Hermes secrets.""" + monkeypatch.setenv("AUXILIARY_VISION_API_KEY", "sk-vision") + monkeypatch.setenv("GATEWAY_RELAY_SECRET", "relay-secret") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setenv("MY_SAFE_STT_VAR", "keep") + + captured = {} + + class Proc: + returncode = 0 + + def communicate(self, timeout=None): + return "", "" + + def fake_popen(command, **kwargs): + captured["env"] = kwargs["env"] + return Proc() + + monkeypatch.setattr("tools.transcription_tools.subprocess.Popen", fake_popen) + + from tools.transcription_tools import _run_command_stt + + result = _run_command_stt("echo hi", timeout=1) + + assert result.returncode == 0 + env = captured["env"] + assert "AUXILIARY_VISION_API_KEY" not in env + assert "GATEWAY_RELAY_SECRET" not in env + assert "OPENAI_API_KEY" not in env + assert env["MY_SAFE_STT_VAR"] == "keep" + + def test_local_whisper_subprocess_uses_sanitized_env( + self, monkeypatch, sample_wav, tmp_path + ): + """Sibling path: local whisper subprocess.run also scrubbed (#56332 gap).""" + monkeypatch.setenv("AUXILIARY_VISION_API_KEY", "sk-vision") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setenv("MY_SAFE_LOCAL_STT", "keep") + monkeypatch.setenv( + "HERMES_LOCAL_STT_COMMAND", + "whisper {input_path} --model {model} --output_dir {output_dir} --language {language}", + ) + + captured = {} + out_dir = tmp_path / "local-out" + out_dir.mkdir() + (out_dir / "transcript.txt").write_text("hello", encoding="utf-8") + + def fake_tempdir(prefix=None): + class _TempDir: + def __enter__(self_inner): + return str(out_dir) + + def __exit__(self_inner, *exc): + return False + + return _TempDir() + + def fake_run(*args, **kwargs): + captured["env"] = kwargs.get("env") + class R: + returncode = 0 + return R() + + monkeypatch.setattr("tools.transcription_tools.tempfile.TemporaryDirectory", fake_tempdir) + monkeypatch.setattr("tools.transcription_tools.subprocess.run", fake_run) + monkeypatch.setattr( + "tools.transcription_tools._prepare_local_audio", + lambda *a, **k: (str(sample_wav), None), + ) + + from tools.transcription_tools import _transcribe_local_command + + result = _transcribe_local_command(str(sample_wav), "base") + assert result["success"] is True + env = captured["env"] + assert env is not None + assert "AUXILIARY_VISION_API_KEY" not in env + assert "OPENAI_API_KEY" not in env + assert env["MY_SAFE_LOCAL_STT"] == "keep" + def test_auto_detects_local_whisper_binary(self, monkeypatch): monkeypatch.delenv("HERMES_LOCAL_STT_COMMAND", raising=False) monkeypatch.setattr("tools.transcription_tools._find_whisper_binary", lambda: "/opt/homebrew/bin/whisper") diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index 616a88d7b88d..8e95d00ae631 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -37,6 +37,7 @@ _render_command_tts_template, _resolve_command_provider_config, _resolve_max_text_length, + _run_command_tts, _shell_quote_context, check_tts_requirements, text_to_speech_tool, @@ -114,6 +115,38 @@ def test_native_piper_cannot_be_shadowed_by_command_entry(self): assert _resolve_command_provider_config("piper", cfg) is None +class TestCommandTtsEnv: + def test_command_provider_uses_sanitized_child_env(self, monkeypatch): + """Salvage of #56332: command TTS must not inherit Hermes secrets.""" + monkeypatch.setenv("AUXILIARY_VISION_API_KEY", "sk-vision") + monkeypatch.setenv("GATEWAY_RELAY_SECRET", "relay-secret") + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai") + monkeypatch.setenv("MY_SAFE_TTS_VAR", "keep") + + captured = {} + + class Proc: + returncode = 0 + + def communicate(self, timeout=None): + return "", "" + + def fake_popen(command, **kwargs): + captured["env"] = kwargs["env"] + return Proc() + + monkeypatch.setattr("tools.tts_tool.subprocess.Popen", fake_popen) + + result = _run_command_tts("echo hi", timeout=1) + + assert result.returncode == 0 + env = captured["env"] + assert "AUXILIARY_VISION_API_KEY" not in env + assert "GATEWAY_RELAY_SECRET" not in env + assert "OPENAI_API_KEY" not in env + assert env["MY_SAFE_TTS_VAR"] == "keep" + + class TestGetNamedProviderConfig: def test_providers_block_wins(self): cfg = {"providers": {"voxcpm": {"command": "new"}}, diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 6c34e714cd7b..8bd9018c4fea 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -548,15 +548,19 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces """Run a command-provider shell command with process-tree timeout cleanup. Mirrors ``tools.tts_tool._run_command_tts``. + Child env is scrubbed of Hermes secrets (salvage of #56332) while still + propagating delegated-child lineage markers when applicable. """ from agent.delegation_context import delegated_child_subprocess_env + from tools.environments.local import hermes_subprocess_env + scrubbed = hermes_subprocess_env(inherit_credentials=False) popen_kwargs: Dict[str, Any] = { "shell": True, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, - "env": delegated_child_subprocess_env(), + "env": delegated_child_subprocess_env(scrubbed), } if os.name == "nt": popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) @@ -1254,12 +1258,36 @@ def _transcribe_local_command(file_path: str, model_name: str) -> Dict[str, Any] model=shlex.quote(normalized_model), ) # User-provided templates (env var) may contain shell syntax; auto-detected commands are safe for list mode. + # Scrub Hermes secrets from the child env (sibling path to #56332 / + # _run_command_stt — this local-whisper path previously inherited + # the full process environment). + from tools.environments.local import hermes_subprocess_env + + child_env = hermes_subprocess_env(inherit_credentials=False) use_shell = bool(os.getenv(LOCAL_STT_COMMAND_ENV, "").strip()) if use_shell: - subprocess.run(command, shell=True, check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) + subprocess.run( + command, + shell=True, + check=True, + capture_output=True, + text=True, + timeout=300, + stdin=subprocess.DEVNULL, + env=child_env, + creationflags=windows_hide_flags(), + ) else: - subprocess.run(shlex.split(command), check=True, capture_output=True, text=True, timeout=300, stdin=subprocess.DEVNULL, creationflags=windows_hide_flags()) - + subprocess.run( + shlex.split(command), + check=True, + capture_output=True, + text=True, + timeout=300, + stdin=subprocess.DEVNULL, + env=child_env, + creationflags=windows_hide_flags(), + ) txt_files = sorted(Path(output_dir).glob("*.txt")) if not txt_files: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 16320974ae6d..9ae9624b0b0d 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -774,15 +774,21 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None: def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProcess: - """Run a command-provider shell command with process-tree timeout cleanup.""" + """Run a command-provider shell command with process-tree timeout cleanup. + + Child env is scrubbed of Hermes secrets (salvage of #56332) while still + propagating delegated-child lineage markers when applicable. + """ from agent.delegation_context import delegated_child_subprocess_env + from tools.environments.local import hermes_subprocess_env + scrubbed = hermes_subprocess_env(inherit_credentials=False) popen_kwargs: Dict[str, Any] = { "shell": True, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, - "env": delegated_child_subprocess_env(), + "env": delegated_child_subprocess_env(scrubbed), } if os.name == "nt": popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)