From 1ff911a7a31dc207c8a90319e7aae342a1fa7c86 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Fri, 10 Jul 2026 19:19:34 -0600 Subject: [PATCH] feat(transcribe): keep STT local on the confidential lane (raw audio never egresses) Force local backend selection when a services.confidential provenance block is present. Add a fail-closed ConfidentialAudioEgressError refusal at the audio-to-backend dispatch chokepoint so an explicit cloud override never egresses raw audio. Suppress the noise-to-Rev.ai upgrade and start the supervised parakeet server on the confidential lane; on refusal, honest-defer by holding raw audio with no failure event. Frames/text egress for thinking is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/settings/routes.py | 4 ++ .../tests/test_transcribe_resource_payload.py | 62 ++++++++++++++++++- solstone/apps/settings/transcribe_resource.py | 6 +- solstone/observe/transcribe/__init__.py | 18 ++++++ solstone/observe/transcribe/main.py | 17 +++++ solstone/observe/transcribe/resource.py | 4 ++ solstone/think/supervisor.py | 6 +- tests/test_no_implicit_cloud.py | 40 ++++++++++++ tests/test_supervisor_parakeet.py | 52 ++++++++++++---- tests/test_transcribe_noise_upgrade.py | 43 +++++++++++++ tests/test_transcribe_parakeet_cpp_retry.py | 20 ++++++ tests/test_transcribe_resource.py | 41 +++++++++--- 12 files changed, 288 insertions(+), 25 deletions(-) diff --git a/solstone/apps/settings/routes.py b/solstone/apps/settings/routes.py index 53ac15945..fe77f764c 100644 --- a/solstone/apps/settings/routes.py +++ b/solstone/apps/settings/routes.py @@ -590,9 +590,13 @@ def get_transcribe() -> Any: google_key_present = bool(api_keys.get("gemini")) configured_backend = transcribe_config.get("backend") try: + from solstone.think.services import spp + + confidential_lane_active = spp.confidential_provenance() is not None resource = transcribe_resource.get_transcribe_resource_payload( google_key_present=google_key_present, configured_backend=configured_backend, + confidential_lane_active=confidential_lane_active, ) except Exception: logger.exception("error loading transcribe resource payload") diff --git a/solstone/apps/settings/tests/test_transcribe_resource_payload.py b/solstone/apps/settings/tests/test_transcribe_resource_payload.py index bba9da4ab..b9082de61 100644 --- a/solstone/apps/settings/tests/test_transcribe_resource_payload.py +++ b/solstone/apps/settings/tests/test_transcribe_resource_payload.py @@ -27,7 +27,15 @@ RESOURCE_KEYS = { } -def _payload(monkeypatch, *, available_bytes, floor_bytes, google_key, configured): +def _payload( + monkeypatch, + *, + available_bytes, + floor_bytes, + google_key, + configured, + confidential=False, +): monkeypatch.setattr( transcribe_resource, "read_available_bytes", lambda: available_bytes ) @@ -38,6 +46,7 @@ def _payload(monkeypatch, *, available_bytes, floor_bytes, google_key, configure return transcribe_resource.get_transcribe_resource_payload( google_key_present=google_key, configured_backend=configured, + confidential_lane_active=confidential, ) @@ -64,6 +73,7 @@ def test_transcribe_resource_payload_shape(monkeypatch): floor_bytes=4 * 1024**3, google_key=False, configured=None, + confidential=False, ) assert set(payload) == RESOURCE_KEYS @@ -80,6 +90,7 @@ def test_transcribe_resource_unknown_memory(monkeypatch): floor_bytes=4 * 1024**3, google_key=True, configured=None, + confidential=False, ) assert payload["available_memory_gb"] is None @@ -94,6 +105,7 @@ def test_transcribe_resource_auto_switch_notice(monkeypatch): floor_bytes=4 * 1024**3, google_key=True, configured=None, + confidential=False, ) assert payload["auto_switched"] is True @@ -109,6 +121,7 @@ def test_transcribe_resource_no_key_recovery(monkeypatch): floor_bytes=4 * 1024**3, google_key=False, configured=None, + confidential=False, ) assert payload["auto_switched"] is False @@ -124,6 +137,7 @@ def test_transcribe_resource_configured_backend_has_no_auto_flags(monkeypatch): floor_bytes=4 * 1024**3, google_key=True, configured="parakeet", + confidential=False, ) assert payload["auto_switched"] is False @@ -139,6 +153,7 @@ def test_transcribe_resource_unsupported_platform(monkeypatch): floor_bytes=None, google_key=False, configured=None, + confidential=False, ) assert payload["min_ram_gb"] is None @@ -146,6 +161,22 @@ def test_transcribe_resource_unsupported_platform(monkeypatch): assert payload["needs_setup"] is True +def test_transcribe_resource_confidential_low_memory_stays_local(monkeypatch): + payload = _payload( + monkeypatch, + available_bytes=2 * 1024**3, + floor_bytes=4 * 1024**3, + google_key=True, + configured=None, + confidential=True, + ) + + assert payload["auto_switched"] is False + assert payload["needs_setup"] is False + assert payload["notice"] == "" + assert payload["force_local_hint"] == "" + + def test_transcribe_route_includes_resource_block(settings_env, monkeypatch): journal_path = _ready_journal(settings_env) monkeypatch.setattr( @@ -162,6 +193,35 @@ def test_transcribe_route_includes_resource_block(settings_env, monkeypatch): assert set(payload["resource"]) == RESOURCE_KEYS +def test_transcribe_route_passes_confidential_lane_flag(settings_env, monkeypatch): + journal_path, config = settings_env() + config["setup"] = {"completed_at": "2026-05-23T00:00:00Z"} + config.setdefault("services", {})["confidential"] = { + "enabled_at": "2026-05-24T00:00:00Z" + } + (journal_path / "config" / "journal.json").write_text( + json.dumps(config, indent=2) + "\n", + encoding="utf-8", + ) + captured = {} + + def capture_payload(**kwargs): + captured.update(kwargs) + return transcribe_resource.fallback_transcribe_resource_payload() + + monkeypatch.setattr( + routes.transcribe_resource, + "get_transcribe_resource_payload", + capture_payload, + ) + client = _client(journal_path) + + response = client.get("/app/settings/api/transcribe") + + assert response.status_code == 200 + assert captured["confidential_lane_active"] is True + + def test_transcribe_route_uses_resource_fallback_on_assembly_error( settings_env, monkeypatch ): diff --git a/solstone/apps/settings/transcribe_resource.py b/solstone/apps/settings/transcribe_resource.py index 66faa61b5..e0067a1ab 100644 --- a/solstone/apps/settings/transcribe_resource.py +++ b/solstone/apps/settings/transcribe_resource.py @@ -24,7 +24,10 @@ from solstone.think.providers.memory import gb, read_available_bytes def get_transcribe_resource_payload( - *, google_key_present: bool, configured_backend: str | None + *, + google_key_present: bool, + configured_backend: str | None, + confidential_lane_active: bool, ) -> dict[str, bool | float | int | str | None]: """Return the resource display payload for Settings transcription.""" available_bytes = read_available_bytes() @@ -37,6 +40,7 @@ def get_transcribe_resource_payload( google_key_present=google_key_present, floor_bytes=floor_bytes, local_backend=local_backend, + confidential_lane_active=confidential_lane_active, ) auto_switched = selected_backend == "gemini" needs_setup = selected_backend == STT_SURFACE diff --git a/solstone/observe/transcribe/__init__.py b/solstone/observe/transcribe/__init__.py index b8dfd9c41..942b47120 100644 --- a/solstone/observe/transcribe/__init__.py +++ b/solstone/observe/transcribe/__init__.py @@ -48,6 +48,7 @@ Backend Interface: from __future__ import annotations +import logging from importlib import import_module from types import ModuleType from typing import TYPE_CHECKING @@ -137,6 +138,10 @@ def get_backend_list() -> list[dict]: ] +class ConfidentialAudioEgressError(Exception): + """Raised when confidential processing refuses a cloud STT backend.""" + + def transcribe( backend: str, audio: "np.ndarray", @@ -157,6 +162,18 @@ def transcribe( Returns: List of statement dicts with id, start, end, text, and optionally words """ + from solstone.think.services import spp + + if spp.confidential_provenance() is not None and backend in {"gemini", "revai"}: + logging.warning( + "Confidential lane refused cloud STT backend %s; raw audio must stay local", + backend, + ) + raise ConfidentialAudioEgressError( + f"confidential lane blocks cloud STT backend {backend!r}; " + "raw audio must stay local" + ) + backend_mod = get_backend(backend) # Pass speech_segments to backends that support it (currently only Gemini) @@ -189,6 +206,7 @@ __all__ = [ "BACKEND_METADATA", "get_backend", "get_backend_list", + "ConfidentialAudioEgressError", "transcribe", # Utilities "SENTENCE_ENDINGS", diff --git a/solstone/observe/transcribe/main.py b/solstone/observe/transcribe/main.py index 5e696b8cd..fba7a3b56 100644 --- a/solstone/observe/transcribe/main.py +++ b/solstone/observe/transcribe/main.py @@ -79,6 +79,7 @@ from solstone.observe.processing_record import ( ) from solstone.observe.transcribe import ( BACKEND_REGISTRY, + ConfidentialAudioEgressError, get_backend, ) from solstone.observe.transcribe import transcribe as stt_transcribe @@ -169,11 +170,15 @@ def resolve_default_backend(args: argparse.Namespace, transcribe_config: dict) - explicit_backend = DEFAULT_BACKEND _warn_if_local_below_floor(explicit_backend, available_bytes, floor_bytes) return explicit_backend + from solstone.think.services import spp + + confidential_lane_active = spp.confidential_provenance() is not None backend = select_stt_backend( available_bytes, google_key_present=google_key_present, floor_bytes=floor_bytes, local_backend=local_backend, + confidential_lane_active=confidential_lane_active, ) if backend == STT_SURFACE: _surface_stt_requirement(available_bytes, floor_bytes) @@ -956,6 +961,14 @@ def process_audio( ) return + except ConfidentialAudioEgressError as e: + logging.warning( + "Confidential lane refused cloud STT for %s; holding audio for retry: %s", + raw_path, + e, + ) + return + except Exception as e: logging.error(f"Failed to transcribe {raw_path}: {e}", exc_info=True) try: @@ -1122,9 +1135,13 @@ def _process_one( # - Rev.ai token is available noise_upgrade = transcribe_config.get("noise_upgrade", True) min_ratio = transcribe_config.get("noise_upgrade_min_speech_ratio", 0.3) + from solstone.think.services import spp + + confidential_lane_active = spp.confidential_provenance() is not None if ( not args.backend and noise_upgrade + and not confidential_lane_active and backend != "revai" and vad_result.is_noisy() ): diff --git a/solstone/observe/transcribe/resource.py b/solstone/observe/transcribe/resource.py index cd0903b42..eddc5a345 100644 --- a/solstone/observe/transcribe/resource.py +++ b/solstone/observe/transcribe/resource.py @@ -40,8 +40,12 @@ def select_stt_backend( google_key_present: bool, floor_bytes: int | None, local_backend: str | None, + confidential_lane_active: bool, ) -> str: """Resolve the unset/default STT backend without reading machine state.""" + if confidential_lane_active: + return local_backend if local_backend is not None else STT_SURFACE + local_fits = ( floor_bytes is not None and available_bytes is not None diff --git a/solstone/think/supervisor.py b/solstone/think/supervisor.py index d7b0c8c96..d94c18e61 100644 --- a/solstone/think/supervisor.py +++ b/solstone/think/supervisor.py @@ -145,10 +145,13 @@ def linux_stt_uses_parakeet_cpp() -> bool: except RuntimeError: return False + from solstone.think.services import spp + + confidential = spp.confidential_provenance() is not None config = read_journal_config() transcribe = config.get("transcribe", {}) backend = transcribe.get("backend") if isinstance(transcribe, dict) else None - if isinstance(backend, str): + if not confidential and isinstance(backend, str): return backend not in {"revai", "gemini"} selected = select_stt_backend( @@ -156,6 +159,7 @@ def linux_stt_uses_parakeet_cpp() -> bool: google_key_present=bool(os.getenv("GOOGLE_API_KEY")), floor_bytes=stt_local_floor_bytes(), local_backend=local_stt_backend(), + confidential_lane_active=confidential, ) return selected in {"parakeet", "parakeet-cpp"} diff --git a/tests/test_no_implicit_cloud.py b/tests/test_no_implicit_cloud.py index d6e67698c..ca5b1aa2b 100644 --- a/tests/test_no_implicit_cloud.py +++ b/tests/test_no_implicit_cloud.py @@ -8,6 +8,7 @@ import json from pathlib import Path from unittest.mock import Mock +import numpy as np import pytest from solstone.think import models, talents @@ -255,6 +256,45 @@ def test_confidential_gate_keys_on_provenance_not_provider_resolution( httpx_post.assert_not_called() +def test_confidential_stt_chokepoint_blocks_cloud_audio_egress( + tmp_path, + monkeypatch, +): + _empty_journal(tmp_path, monkeypatch) + _write_journal_config(tmp_path, _confidential_config(provider_pins=False)) + audio = np.zeros(16000, dtype=np.float32) + gemini_transcribe = Mock(side_effect=AssertionError("audio egress attempted")) + revai_transcribe = Mock(side_effect=AssertionError("audio egress attempted")) + parakeet_transcribe = Mock(return_value=[]) + monkeypatch.setattr( + "solstone.observe.transcribe.gemini.transcribe", + gemini_transcribe, + ) + monkeypatch.setattr( + "solstone.observe.transcribe.revai.transcribe", + revai_transcribe, + ) + monkeypatch.setattr( + "solstone.observe.transcribe.parakeet.transcribe", + parakeet_transcribe, + ) + + from solstone.observe.transcribe import ( + ConfidentialAudioEgressError, + transcribe, + ) + + with pytest.raises(ConfidentialAudioEgressError): + transcribe("gemini", audio, 16000, {}) + with pytest.raises(ConfidentialAudioEgressError): + transcribe("revai", audio, 16000, {}) + + gemini_transcribe.assert_not_called() + revai_transcribe.assert_not_called() + assert transcribe("parakeet", audio, 16000, {}) == [] + parakeet_transcribe.assert_called_once() + + def test_none_provider_module_and_backup_fail_closed(tmp_path, monkeypatch): _empty_journal(tmp_path, monkeypatch) diff --git a/tests/test_supervisor_parakeet.py b/tests/test_supervisor_parakeet.py index e9e9aa240..790c96e9c 100644 --- a/tests/test_supervisor_parakeet.py +++ b/tests/test_supervisor_parakeet.py @@ -221,18 +221,40 @@ def test_start_parakeet_server_vulkan_crash_falls_back_to_cpu( @pytest.mark.parametrize( - ("sys_platform", "machine", "backend", "available_bytes", "google_key", "expected"), + ( + "sys_platform", + "machine", + "backend", + "available_bytes", + "google_key", + "confidential", + "local_backend", + "expected", + ), [ - ("linux", "x86_64", None, 5 * 1024**3, False, True), - ("linux", "x86_64", None, 3 * 1024**3, False, False), - ("linux", "x86_64", None, 3 * 1024**3, True, False), - ("linux", "x86_64", "parakeet", 3 * 1024**3, False, True), - ("linux", "x86_64", "parakeet-cpp", 3 * 1024**3, False, True), - ("linux", "x86_64", "revai", 5 * 1024**3, False, False), - ("linux", "x86_64", "gemini", 5 * 1024**3, False, False), - ("linux", "aarch64", None, 5 * 1024**3, False, True), - ("linux", "aarch64", "parakeet", 3 * 1024**3, False, True), - ("darwin", "arm64", None, 5 * 1024**3, False, False), + ("linux", "x86_64", None, 5 * 1024**3, False, False, "parakeet", True), + ("linux", "x86_64", None, 3 * 1024**3, False, False, "parakeet", False), + ("linux", "x86_64", None, 3 * 1024**3, True, False, "parakeet", False), + ("linux", "x86_64", "parakeet", 3 * 1024**3, False, False, "parakeet", True), + ( + "linux", + "x86_64", + "parakeet-cpp", + 3 * 1024**3, + False, + False, + "parakeet", + True, + ), + ("linux", "x86_64", "revai", 5 * 1024**3, False, False, "parakeet", False), + ("linux", "x86_64", "gemini", 5 * 1024**3, False, False, "parakeet", False), + ("linux", "aarch64", None, 5 * 1024**3, False, False, "parakeet", True), + ("linux", "aarch64", "parakeet", 3 * 1024**3, False, False, "parakeet", True), + ("darwin", "arm64", None, 5 * 1024**3, False, False, "parakeet", False), + ("linux", "x86_64", "gemini", 3 * 1024**3, True, True, "parakeet", True), + ("linux", "x86_64", "revai", 3 * 1024**3, False, True, "parakeet", True), + ("linux", "x86_64", None, 3 * 1024**3, True, True, "parakeet", True), + ("linux", "x86_64", None, 3 * 1024**3, True, True, None, False), ], ) def test_linux_stt_uses_parakeet_cpp_truth_table( @@ -242,6 +264,8 @@ def test_linux_stt_uses_parakeet_cpp_truth_table( backend: str | None, available_bytes: int, google_key: bool, + confidential: bool, + local_backend: str | None, expected: bool, ): monkeypatch.setattr(supervisor.sys, "platform", sys_platform) @@ -250,7 +274,11 @@ def test_linux_stt_uses_parakeet_cpp_truth_table( monkeypatch.setattr(supervisor, "read_journal_config", lambda: config) monkeypatch.setattr(supervisor, "read_available_bytes", lambda: available_bytes) monkeypatch.setattr(supervisor, "stt_local_floor_bytes", lambda: 4 * 1024**3) - monkeypatch.setattr(supervisor, "local_stt_backend", lambda: "parakeet") + monkeypatch.setattr(supervisor, "local_stt_backend", lambda: local_backend) + monkeypatch.setattr( + "solstone.think.services.spp.confidential_provenance", + lambda: {"enabled_at": "2026-05-24T00:00:00Z"} if confidential else None, + ) if google_key: monkeypatch.setenv("GOOGLE_API_KEY", "test-key") else: diff --git a/tests/test_transcribe_noise_upgrade.py b/tests/test_transcribe_noise_upgrade.py index 351fd785d..7c95c3f57 100644 --- a/tests/test_transcribe_noise_upgrade.py +++ b/tests/test_transcribe_noise_upgrade.py @@ -271,6 +271,49 @@ class TestNoiseUpgradeGate: assert mock_process_audio.call_args.kwargs["backend"] == "revai" + def test_confidential_lane_blocks_revai_upgrade( + self, audio_path, args, audio_buffer + ): + from solstone.observe.transcribe.main import _process_one + + vad = VadResult( + duration=10.0, + speech_duration=5.0, + has_speech=True, + speech_segments=[(1.0, 6.0)], + noisy_rms=0.02, + noisy_s=3.0, + loud_windows=100, + speech_loud_windows=90, + ) + transcribe_config = { + "noise_upgrade": True, + "noise_upgrade_min_speech_ratio": 0.3, + "parakeet": {}, + } + + with ( + patch( + "solstone.observe.transcribe.main.load_audio", return_value=audio_buffer + ), + patch("solstone.observe.vad.run_vad", return_value=vad), + patch( + "solstone.observe.vad.reduce_audio", + return_value=(None, None), + ), + patch( + "solstone.observe.transcribe.main.process_audio" + ) as mock_process_audio, + patch("solstone.observe.transcribe.revai.has_token", return_value=True), + patch( + "solstone.think.services.spp.confidential_provenance", + return_value={"enabled_at": "2026-05-24T00:00:00Z"}, + ), + ): + _process_one(audio_path, args, transcribe_config, "parakeet", []) + + assert mock_process_audio.call_args.kwargs["backend"] == "parakeet" + def test_gate_fallback_when_ratio_none(self, audio_path, args, audio_buffer): from solstone.observe.transcribe.main import _process_one diff --git a/tests/test_transcribe_parakeet_cpp_retry.py b/tests/test_transcribe_parakeet_cpp_retry.py index 7a2c6c0bb..863d2be6d 100644 --- a/tests/test_transcribe_parakeet_cpp_retry.py +++ b/tests/test_transcribe_parakeet_cpp_retry.py @@ -58,6 +58,26 @@ def test_process_audio_parakeet_server_not_ready_is_clean_retry( mock_send.assert_not_called() +def test_process_audio_confidential_cloud_refusal_is_clean_retry( + raw_path: Path, audio_buffer: np.ndarray, vad_result: VadResult +) -> None: + from solstone.observe.transcribe import ConfidentialAudioEgressError + from solstone.observe.transcribe.main import process_audio + + with ( + patch( + "solstone.observe.transcribe.main.stt_transcribe", + side_effect=ConfidentialAudioEgressError("blocked"), + ), + patch("solstone.observe.transcribe.main.callosum_send") as mock_send, + ): + process_audio(raw_path, audio_buffer, vad_result, {}, backend="gemini") + + assert raw_path.exists() + assert not raw_path.with_suffix(".jsonl").exists() + mock_send.assert_not_called() + + def test_process_audio_parakeet_provider_error_uses_existing_failure_path( raw_path: Path, audio_buffer: np.ndarray, vad_result: VadResult ) -> None: diff --git a/tests/test_transcribe_resource.py b/tests/test_transcribe_resource.py index f6fb4f38d..5e89b5401 100644 --- a/tests/test_transcribe_resource.py +++ b/tests/test_transcribe_resource.py @@ -63,19 +63,37 @@ def test_local_stt_backend_platform_mapping( "google_key_present", "floor_bytes", "local_backend", + "confidential_lane_active", "expected", ), [ - (4 * 1024**3, False, 4 * 1024**3, "parakeet", "parakeet"), - (5 * 1024**3, True, 4 * 1024**3, "parakeet", "parakeet"), - (3 * 1024**3, True, 4 * 1024**3, "parakeet", "gemini"), - (None, True, 4 * 1024**3, "parakeet", "gemini"), - (3 * 1024**3, True, None, None, "gemini"), - (3 * 1024**3, False, 4 * 1024**3, "parakeet", resource.STT_SURFACE), - (None, False, 4 * 1024**3, "parakeet", resource.STT_SURFACE), - (3 * 1024**3, False, None, None, resource.STT_SURFACE), - (4 * 1024**3, False, 4 * 1024**3, "parakeet", "parakeet"), - (3 * 1024**3, True, 4 * 1024**3, "parakeet", "gemini"), + (4 * 1024**3, False, 4 * 1024**3, "parakeet", False, "parakeet"), + (5 * 1024**3, True, 4 * 1024**3, "parakeet", False, "parakeet"), + (3 * 1024**3, True, 4 * 1024**3, "parakeet", False, "gemini"), + (None, True, 4 * 1024**3, "parakeet", False, "gemini"), + (3 * 1024**3, True, None, None, False, "gemini"), + ( + 3 * 1024**3, + False, + 4 * 1024**3, + "parakeet", + False, + resource.STT_SURFACE, + ), + ( + None, + False, + 4 * 1024**3, + "parakeet", + False, + resource.STT_SURFACE, + ), + (3 * 1024**3, False, None, None, False, resource.STT_SURFACE), + (4 * 1024**3, False, 4 * 1024**3, "parakeet", False, "parakeet"), + (3 * 1024**3, True, 4 * 1024**3, "parakeet", False, "gemini"), + (3 * 1024**3, True, 4 * 1024**3, "parakeet", True, "parakeet"), + (3 * 1024**3, True, 4 * 1024**3, None, True, resource.STT_SURFACE), + (5 * 1024**3, True, 4 * 1024**3, "parakeet", True, "parakeet"), ], ) def test_select_stt_backend_matrix( @@ -83,6 +101,7 @@ def test_select_stt_backend_matrix( google_key_present: bool, floor_bytes: int | None, local_backend: str | None, + confidential_lane_active: bool, expected: str, ) -> None: assert ( @@ -91,6 +110,7 @@ def test_select_stt_backend_matrix( google_key_present=google_key_present, floor_bytes=floor_bytes, local_backend=local_backend, + confidential_lane_active=confidential_lane_active, ) == expected ) @@ -102,6 +122,7 @@ def test_select_stt_backend_is_deterministic() -> None: "google_key_present": True, "floor_bytes": 4 * 1024**3, "local_backend": "parakeet", + "confidential_lane_active": False, } assert resource.select_stt_backend(**args) == resource.select_stt_backend(**args) -- 2.51.2