#6260·openhuman

Voice STT has no EgressDescriptor, so local_only does not block microphone audio leaving the device

Author: YellowSnnowmannCreated Sep 15, 2026Updated Sep 15, 2026

Voice STT ships microphone audio off-device with no EgressDescriptor, so it is invisible to the S2 disclosure layer and unenforced by the S7 local_only block.

Found while correcting the Privacy Mode docs in #6258; CodeRabbit asked for it to be tracked separately from that docs PR.

What happens today

inference/voice/cloud_transcribe.rs::transcribe_cloud POSTs the recorded audio to the backend's /openai/v1/audio/transcriptions, and voice/factory/entry.rs::create_stt_provider routes any non-cloud/openhuman/backend string through create_stt_provider_by_slug to an ExternalSttProvider at whatever endpoint that config.voice_providers entry names. Either way the user's raw microphone audio leaves the machine.

Neither path builds an EgressDescriptor:

bash
grep -rn "EgressDescriptor|enforce_egress|emit_external_transfer|local_only_tool_block" \
  crates/openhuman-core/src/voice/ crates/openhuman-core/src/inference/voice/
# no matches

Every other data-carrying egress point does have one — inference/embeddings/cloud_adapter.rs, integrations/composio/client/execute.rs, integrations/client/requests.rs, search/tools/{exa,tavily}, and the three network tools under tools/impl/network/. Voice is the gap.

Consequences

S2 (#4436) cannot disclose it. The external-transfer event that every other egress emits never fires for voice, so nothing in the UI or the activity surface says the audio went anywhere, or to whom.

S7 (#4441) cannot enforce it. security/egress/enforce.rs only blocks transfers that reach it through enforce_egress / local_only_tool_block. With no descriptor, PrivacyMode::LocalOnly has no effect on voice: a user who sets local_only specifically to keep data on the machine still uploads their microphone audio on every dictation.

That last one is the part that matters. Privacy Mode's whole claim is that enforcement is structural rather than prompt-level, so a silent exception is worse than a documented limitation. #6258 documents it in the meantime, but the doc is a workaround for a missing chokepoint.

Suggested shape

Mirror what the embeddings adapter does — build a descriptor and pass it through both gates at the point of transfer:

  • In transcribe_cloud, and in ExternalSttProvider's request path, construct an EgressDescriptor naming the actual destination (the backend endpoint, or the third-party provider's host for a slug route).
  • Call enforce_egress(&desc)? before sending and emit_external_transfer(desc) after it validates, matching cloud_adapter.rs:160-161.
  • DataKind should reflect that the payload is raw audio, not text — worth deciding whether the existing kinds cover it or a new one is needed.
  • The same question applies to TTS (reply_speech.rs POSTs to /openai/v1/audio/speech), which ships the text to be spoken. Probably the same treatment; calling it out so it is a deliberate decision rather than an oversight.

Decision needed

Does local_only blocking voice outright give the right behaviour, given there is no local STT engine to fall back to? Blocking means dictation simply fails in that mode. That is defensible — it is what local_only means, and it is what the network tools already do — but it is a UX change, so worth agreeing before implementing. The alternative, disclose-but-allow, would be S2-only and leave the enforcement hole open.

Docs for the current behaviour landed in #6258 (gitbooks/features/privacy-mode.md); they should be updated again once this ships.