diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 028ef0771e34..deb8a59f15ad 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -404,3 +404,62 @@ def fake_satisfied(spec): result = ld.refresh_active_features() assert result["a.ok"] == "current" assert result["b.fail"].startswith("failed:") + + +# --------------------------------------------------------------------------- +# mistralai_unlock_status — quarantine opt-in gate (#34503) +# --------------------------------------------------------------------------- + +class TestMistralaiUnlockStatus: + """The blanket mistralai ban is lifted only behind an explicit opt-in + env var AND a version floor (2.4.6 was malicious; >= 2.4.8 is clean).""" + + @staticmethod + def _patch_version(monkeypatch, *, installed=None, missing=False): + import importlib.metadata as md + + def fake_version(pkg): + if missing: + raise md.PackageNotFoundError(pkg) + return installed + + monkeypatch.setattr(md, "version", fake_version) + + def test_locked_by_default(self, monkeypatch): + monkeypatch.delenv(ld.MISTRALAI_UNLOCK_ENV, raising=False) + allowed, reason = ld.mistralai_unlock_status() + assert allowed is False + assert ld.MISTRALAI_UNLOCK_ENV in reason + + def test_opt_in_but_not_installed(self, monkeypatch): + monkeypatch.setenv(ld.MISTRALAI_UNLOCK_ENV, "1") + self._patch_version(monkeypatch, missing=True) + allowed, reason = ld.mistralai_unlock_status() + assert allowed is False + assert "not installed" in reason + + def test_opt_in_below_floor_refused(self, monkeypatch): + """The known-malicious 2.4.6 is refused even with opt-in.""" + monkeypatch.setenv(ld.MISTRALAI_UNLOCK_ENV, "1") + self._patch_version(monkeypatch, installed="2.4.6") + allowed, reason = ld.mistralai_unlock_status() + assert allowed is False + assert "2.4.6" in reason or "below the safe floor" in reason + + def test_opt_in_clean_version_allowed(self, monkeypatch): + monkeypatch.setenv(ld.MISTRALAI_UNLOCK_ENV, "1") + self._patch_version(monkeypatch, installed="2.4.8") + allowed, reason = ld.mistralai_unlock_status() + assert allowed is True + assert "2.4.8" in reason + + def test_opt_in_newer_version_allowed(self, monkeypatch): + monkeypatch.setenv(ld.MISTRALAI_UNLOCK_ENV, "1") + self._patch_version(monkeypatch, installed="2.5.0") + allowed, _ = ld.mistralai_unlock_status() + assert allowed is True + + def test_falsey_env_value_stays_locked(self, monkeypatch): + monkeypatch.setenv(ld.MISTRALAI_UNLOCK_ENV, "0") + allowed, _ = ld.mistralai_unlock_status() + assert allowed is False diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index 0e1c0ef78f11..b6d9912d716c 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -1012,29 +1012,51 @@ def test_permission_error(self, monkeypatch, sample_ogg, mock_mistral_module): class TestGetProviderMistral: """Mistral-specific provider selection tests. - Mistral STT is intentionally disabled in 2026-05-12+ while the - `mistralai` PyPI package is quarantined. These tests document that - explicit `provider: mistral` always returns "none" with a warning, and - that auto-detect skips mistral entirely. + The `mistralai` PyPI package was quarantined on 2026-05-12 (malicious + 2.4.6 release). The blanket ban is lifted only behind an explicit + opt-in (HERMES_ALLOW_MISTRALAI) plus a version floor enforced by + tools.lazy_deps.mistralai_unlock_status(). Without the opt-in, explicit + `provider: mistral` still returns "none"; auto-detect always skips it. """ - def test_mistral_when_key_and_sdk_available(self, monkeypatch): - """Even with key + SDK, explicit mistral returns 'none' (disabled).""" + def test_mistral_locked_by_default_returns_none(self, monkeypatch): + """Without the opt-in env var, explicit mistral returns 'none'.""" monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + monkeypatch.delenv("HERMES_ALLOW_MISTRALAI", raising=False) with patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider assert _get_provider({"provider": "mistral"}) == "none" + def test_mistral_unlocked_with_clean_version_and_key(self, monkeypatch): + """Opt-in + clean SDK version + key returns 'mistral'.""" + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + monkeypatch.setenv("HERMES_ALLOW_MISTRALAI", "1") + with patch("tools.lazy_deps.mistralai_unlock_status", + return_value=(True, "unlocked")): + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "mistral"}) == "mistral" + + def test_mistral_unlocked_but_no_key_returns_none(self, monkeypatch): + """Opt-in + clean SDK but missing key returns 'none'.""" + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.setenv("HERMES_ALLOW_MISTRALAI", "1") + with patch("tools.lazy_deps.mistralai_unlock_status", + return_value=(True, "unlocked")): + from tools.transcription_tools import _get_provider + assert _get_provider({"provider": "mistral"}) == "none" + def test_mistral_explicit_no_key_returns_none(self, monkeypatch): - """Explicit mistral with no key returns none.""" + """Explicit mistral with no key returns none (locked).""" monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + monkeypatch.delenv("HERMES_ALLOW_MISTRALAI", raising=False) with patch("tools.transcription_tools._HAS_MISTRAL", True): from tools.transcription_tools import _get_provider assert _get_provider({"provider": "mistral"}) == "none" def test_mistral_explicit_no_sdk_returns_none(self, monkeypatch): - """Explicit mistral with key but no SDK returns none.""" + """Explicit mistral with key but no SDK returns none (locked).""" monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + monkeypatch.delenv("HERMES_ALLOW_MISTRALAI", raising=False) with patch("tools.transcription_tools._HAS_MISTRAL", False): from tools.transcription_tools import _get_provider assert _get_provider({"provider": "mistral"}) == "none" diff --git a/tests/tools/test_tts_mistral.py b/tests/tools/test_tts_mistral.py index 818a6c1d1174..a40659eb6476 100644 --- a/tests/tools/test_tts_mistral.py +++ b/tests/tools/test_tts_mistral.py @@ -162,39 +162,64 @@ def test_model_from_config_overrides_default( class TestTtsDispatcherMistral: - def test_dispatcher_returns_disabled_error( + def test_dispatcher_returns_disabled_error_when_locked( self, tmp_path, mock_mistral_module, monkeypatch ): - """Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12). - - The dispatcher must short-circuit with a clear status message before - attempting any SDK import, even when MISTRAL_API_KEY is set and a - mock SDK is wired in. Restore routing once `mistralai` is - un-quarantined on PyPI. + """Without the HERMES_ALLOW_MISTRALAI opt-in, the dispatcher must + short-circuit with a clear status message before attempting any SDK + import — even when MISTRAL_API_KEY is set and a mock SDK is wired in. """ import json from tools.tts_tool import text_to_speech_tool monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + monkeypatch.delenv("HERMES_ALLOW_MISTRALAI", raising=False) output_path = str(tmp_path / "out.mp3") with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}): result = json.loads(text_to_speech_tool("Hello", output_path=output_path)) assert result["success"] is False - assert "temporarily disabled" in result["error"] + assert "disabled" in result["error"] assert "quarantined" in result["error"] # SDK must not have been called. mock_mistral_module.audio.speech.complete.assert_not_called() + def test_dispatcher_routes_to_generator_when_unlocked( + self, tmp_path, mock_mistral_module, monkeypatch + ): + """With the opt-in + a clean SDK, the dispatcher routes to the + Mistral generator instead of short-circuiting. + """ + import base64 + import json + + from tools.tts_tool import text_to_speech_tool + + monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + monkeypatch.setenv("HERMES_ALLOW_MISTRALAI", "1") + mock_mistral_module.audio.speech.complete.return_value = MagicMock( + audio_data=base64.b64encode(b"audio").decode() + ) + + output_path = str(tmp_path / "out.mp3") + with patch("tools.lazy_deps.mistralai_unlock_status", + return_value=(True, "unlocked")), \ + patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}): + result = json.loads(text_to_speech_tool("Hello", output_path=output_path)) + + assert result["success"] is True + mock_mistral_module.audio.speech.complete.assert_called_once() + def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch): - """Same disabled message regardless of SDK presence.""" + """Locked-by-default message regardless of SDK presence.""" import json from tools.tts_tool import text_to_speech_tool monkeypatch.setenv("MISTRAL_API_KEY", "test-key") + monkeypatch.delenv("HERMES_ALLOW_MISTRALAI", raising=False) with patch( "tools.tts_tool._import_mistral_client", side_effect=ImportError("no module") ), patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}): @@ -203,7 +228,7 @@ def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeyp ) assert result["success"] is False - assert "temporarily disabled" in result["error"] + assert "disabled" in result["error"] class TestCheckTtsRequirementsMistral: diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 393397349d81..778303f18ff3 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -504,6 +504,102 @@ def is_available(feature: str) -> bool: return not feature_missing(feature) +# --------------------------------------------------------------------------- +# mistralai quarantine unlock +# --------------------------------------------------------------------------- +# +# The ``mistralai`` PyPI project was quarantined on 2026-05-12 after a +# malicious **2.4.6** release (the "Mini Shai-Hulud" supply-chain worm). +# The quarantine was version-specific: 2.4.8 and later are clean. Because +# the malicious version is still installable from caches/mirrors, Hermes +# blanket-banned the ``mistral`` STT/TTS providers at runtime. +# +# That blanket ban left users who have *manually verified* a clean install +# (>=2.4.8) with no escape hatch. This helper lifts the ban behind an +# explicit opt-in env var (mirroring ``HERMES_ALLOW_PRIVATE_URLS`` in +# ``tools/url_safety.py``) AND a hard version floor, so the known-malicious +# 2.4.6 can never be re-enabled even if the user opts in. + +#: Lowest ``mistralai`` version considered safe (2.4.6 was the malicious +#: release; 2.4.8 is the first clean post-quarantine release per #34503). +MISTRALAI_MIN_SAFE_VERSION = "2.4.8" + +#: Opt-in env var. Set to a truthy value (``1``/``true``/``yes``/``on``) to +#: allow the ``mistral`` STT/TTS providers once a clean SDK is installed. +MISTRALAI_UNLOCK_ENV = "HERMES_ALLOW_MISTRALAI" + + +def _env_truthy(name: str) -> bool: + return os.getenv(name, "").strip().lower() in ("1", "true", "yes", "on") + + +def mistralai_unlock_status() -> tuple[bool, str]: + """Decide whether the quarantined ``mistralai`` SDK may be used. + + Returns a ``(allowed, reason)`` tuple: + + * ``allowed`` — True only when the user has explicitly opted in via + :data:`MISTRALAI_UNLOCK_ENV` AND an installed ``mistralai`` is at or + above :data:`MISTRALAI_MIN_SAFE_VERSION`. + * ``reason`` — a short, user-facing explanation suitable for logging or + surfacing in an error message. + + The version floor is enforced even when the user opts in, so the + known-malicious 2.4.6 can never be re-enabled by this path. + """ + if not _env_truthy(MISTRALAI_UNLOCK_ENV): + return ( + False, + "`mistralai` is quarantined (malicious 2.4.6 release, 2026-05-12). " + f"Set {MISTRALAI_UNLOCK_ENV}=1 to re-enable it after confirming you " + f"have a clean install (>= {MISTRALAI_MIN_SAFE_VERSION}).", + ) + + try: + from importlib.metadata import PackageNotFoundError, version + except ImportError: # pragma: no cover - importlib.metadata always present + return (False, "Cannot determine installed `mistralai` version.") + + try: + installed = version("mistralai") + except PackageNotFoundError: + return ( + False, + f"{MISTRALAI_UNLOCK_ENV} is set but the `mistralai` package is not " + "installed.", + ) + except Exception: + return (False, "Cannot determine installed `mistralai` version.") + + try: + from packaging.version import InvalidVersion, Version + except ImportError: + # packaging unavailable — refuse rather than guess, since the whole + # point of this gate is the version floor. + return ( + False, + "`packaging` unavailable; cannot verify the installed `mistralai` " + "version meets the safety floor.", + ) + + try: + if Version(installed) < Version(MISTRALAI_MIN_SAFE_VERSION): + return ( + False, + f"Installed `mistralai` {installed} is below the safe floor " + f"{MISTRALAI_MIN_SAFE_VERSION} (2.4.6 was malicious). Upgrade " + "to a clean release.", + ) + except (InvalidVersion, Exception): + return ( + False, + f"Installed `mistralai` version {installed!r} is unparseable; " + "refusing to enable.", + ) + + return (True, f"`mistralai` {installed} unlocked via {MISTRALAI_UNLOCK_ENV}.") + + def feature_install_command(feature: str) -> Optional[str]: """Return the ``pip install`` command a user could run manually, or None.""" if feature not in LAZY_DEPS: diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 92dbf59f3081..fa2afccf1731 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -793,17 +793,26 @@ def _get_provider(stt_config: dict) -> str: if provider == "mistral": # `mistralai` PyPI package was quarantined on 2026-05-12 after a - # malicious 2.4.6 release. Refuse to use this provider until it's - # available again so we surface a clear message instead of an - # opaque ImportError mid-call. - logger.warning( - "STT provider 'mistral' (Voxtral Transcribe) is temporarily " - "disabled — `mistralai` PyPI package is quarantined " - "(malicious 2.4.6 release on 2026-05-12). Falling back to " - "another provider. Set stt.provider in config.yaml to 'local' " - "or 'openai' to silence this warning." - ) - return "none" + # malicious 2.4.6 release. The ban is lifted only behind an + # explicit opt-in (HERMES_ALLOW_MISTRALAI) + a version floor; see + # tools/lazy_deps.mistralai_unlock_status(). + from tools.lazy_deps import mistralai_unlock_status + + allowed, reason = mistralai_unlock_status() + if not allowed: + logger.warning( + "STT provider 'mistral' (Voxtral Transcribe) unavailable: " + "%s Falling back to another provider.", + reason, + ) + return "none" + if not get_env_value("MISTRAL_API_KEY"): + logger.warning( + "STT provider 'mistral' configured but MISTRAL_API_KEY " + "is not set. Falling back to another provider." + ) + return "none" + return "mistral" if provider == "xai": from tools.xai_http import resolve_xai_http_credentials diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 95507bfdf1d1..b9aa0eae0c54 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -1975,20 +1975,24 @@ def text_to_speech_tool( elif provider == "mistral": # `mistralai` PyPI package was quarantined on 2026-05-12 after a - # malicious 2.4.6 release. Surface a clear status message instead - # of attempting an import that would either fail or pull a stale - # cached package. - return json.dumps({ - "success": False, - "error": ( - "Mistral Voxtral TTS is temporarily disabled. The " - "`mistralai` PyPI package was quarantined on 2026-05-12 " - "after a malicious 2.4.6 release. Switch tts.provider in " - "config.yaml to 'edge', 'elevenlabs', 'openai', 'minimax', " - "'gemini', 'xai', 'neutts', or 'kittentts'. Mistral " - "support will return once PyPI un-quarantines the package." - ), - }, ensure_ascii=False) + # malicious 2.4.6 release. The ban is lifted only behind an + # explicit opt-in (HERMES_ALLOW_MISTRALAI) + a version floor; see + # tools/lazy_deps.mistralai_unlock_status(). + from tools.lazy_deps import mistralai_unlock_status + + allowed, reason = mistralai_unlock_status() + if not allowed: + return json.dumps({ + "success": False, + "error": ( + "Mistral Voxtral TTS is disabled. " + reason + " " + "Alternatively switch tts.provider in config.yaml to " + "'edge', 'elevenlabs', 'openai', 'minimax', 'gemini', " + "'xai', 'neutts', or 'kittentts'." + ), + }, ensure_ascii=False) + logger.info("Generating speech with Mistral Voxtral TTS...") + _generate_mistral_tts(text, file_str, tts_config) elif provider == "gemini": logger.info("Generating speech with Google Gemini TTS...")