diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8fcbcf3b8ce2f..76dc7572b6b90 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -3819,6 +3819,10 @@ def _probe_audio_duration_seconds(self, audio_path: str) -> Optional[float]: pass try: + # OS media helper — scrub credentials, same rule as the voice + # TTS/STT and playback subprocesses (#56332 / #70342). + from tools.environments.local import hermes_subprocess_env + proc = subprocess.run( [ "ffprobe", @@ -3832,6 +3836,7 @@ def _probe_audio_duration_seconds(self, audio_path: str) -> Optional[float]: text=True, timeout=5, stdin=subprocess.DEVNULL, + env=hermes_subprocess_env(inherit_credentials=False), ) if proc.returncode == 0: raw = (proc.stdout or "").strip() diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index f0d0dfc0998e9..97204811e6a5f 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -362,10 +362,15 @@ def _probe_voice_duration_seconds(path: str) -> Optional[int]: import subprocess if shutil.which("ffprobe"): + # OS media helper — scrub credentials, same rule as the voice + # TTS/STT and playback subprocesses (#56332 / #70342). + from tools.environments.local import hermes_subprocess_env + proc = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, + env=hermes_subprocess_env(inherit_credentials=False), ) if proc.returncode == 0: return _coerce_duration_seconds(proc.stdout.strip()) diff --git a/tests/tools/test_media_helper_env_scrub.py b/tests/tools/test_media_helper_env_scrub.py new file mode 100644 index 0000000000000..f834a41e06784 --- /dev/null +++ b/tests/tools/test_media_helper_env_scrub.py @@ -0,0 +1,107 @@ +"""OS media helpers must not inherit Hermes credentials. + +Established by the TTS/STT command scrub (#56332 / #70342) and extended to +voice-mode playback: ``ffplay``/``afplay``/``aplay`` are spawned with +``hermes_subprocess_env(inherit_credentials=False)`` so provider API keys and +gateway tokens never reach an OS media helper. + +``ffmpeg`` / ``ffprobe`` are the same kind of process — third-party binaries +Hermes shells out to for transcoding and duration probes — and they run on the +same voice/attachment paths. +""" + +from unittest.mock import MagicMock, patch + + +_SECRETS = { + "OPENAI_API_KEY": "sk-test", + "TELEGRAM_BOT_TOKEN": "secret-token", + "ANTHROPIC_API_KEY": "sk-ant-test", +} + + +def _seed_secrets(monkeypatch): + for key, value in _SECRETS.items(): + monkeypatch.setenv(key, value) + + +def _assert_scrubbed(env): + assert env is not None, "media helper inherited the full process environment" + for key in _SECRETS: + assert key not in env, f"{key} leaked into the media helper env" + + +def test_tts_ffmpeg_transcode_scrubs_credentials(tmp_path, monkeypatch): + """The voice-note OGG transcode on the TTS path.""" + _seed_secrets(monkeypatch) + src = tmp_path / "in.wav" + src.write_bytes(b"RIFFfake") + out = tmp_path / "out.ogg" + + captured = {} + + def fake_run(cmd, **kwargs): + captured["env"] = kwargs.get("env") + out.write_bytes(b"OggSfake") + result = MagicMock() + result.returncode = 0 + result.stderr = b"" + return result + + import tools.tts_tool as tts + + with patch.object(tts, "_has_ffmpeg", return_value=True), patch.object( + tts.subprocess, "run", side_effect=fake_run + ): + tts._ffmpeg_transcode_to_opus(str(src), str(out)) + + _assert_scrubbed(captured.get("env")) + + +def test_telegram_ffprobe_duration_scrubs_credentials(tmp_path, monkeypatch): + """Inbound voice-note duration probe.""" + _seed_secrets(monkeypatch) + media = tmp_path / "voice.ogg" + media.write_bytes(b"OggSfake") + + captured = {} + + def fake_run(cmd, **kwargs): + captured["env"] = kwargs.get("env") + result = MagicMock() + result.returncode = 0 + result.stdout = "3.5" + return result + + from plugins.platforms.telegram import adapter as tg + + with patch("shutil.which", return_value="/usr/bin/ffprobe"), patch( + "subprocess.run", side_effect=fake_run + ): + tg._probe_voice_duration_seconds(str(media)) + + _assert_scrubbed(captured.get("env")) + + +def test_discord_ffprobe_duration_scrubs_credentials(tmp_path, monkeypatch): + """Discord's audio duration probe — same helper, same exposure.""" + _seed_secrets(monkeypatch) + media = tmp_path / "clip.mp3" + media.write_bytes(b"ID3fake") + + captured = {} + + def fake_run(cmd, **kwargs): + captured["env"] = kwargs.get("env") + result = MagicMock() + result.returncode = 0 + result.stdout = "2.0" + return result + + from plugins.platforms.discord import adapter as dc + + adapter = dc.DiscordAdapter.__new__(dc.DiscordAdapter) + with patch.object(dc.subprocess, "run", side_effect=fake_run): + adapter._probe_audio_duration_seconds(str(media)) + + _assert_scrubbed(captured.get("env")) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index c1febc274db2f..13775cb518347 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1269,6 +1269,11 @@ def _ffmpeg_transcode_to_opus(input_path: str, ogg_path: str) -> Optional[str]: in_place = os.path.abspath(input_path) == os.path.abspath(ogg_path) work_path = ogg_path + ".tmp.ogg" if in_place else ogg_path try: + # Sibling of the TTS/STT command scrub (#56332 / #70342) and the + # voice-mode playback scrub: ffmpeg is an OS media helper and has no + # business seeing provider API keys or gateway tokens. + from tools.environments.local import hermes_subprocess_env + result = subprocess.run( ["ffmpeg", "-i", input_path, "-acodec", "libopus", "-ac", "1", "-b:a", "48k", "-vbr", "on", @@ -1276,6 +1281,7 @@ def _ffmpeg_transcode_to_opus(input_path: str, ogg_path: str) -> Optional[str]: work_path, "-y"], capture_output=True, timeout=30, stdin=subprocess.DEVNULL, + env=hermes_subprocess_env(inherit_credentials=False), creationflags=windows_hide_flags(), ) if result.returncode != 0: