feat(tools): env_passthrough allowlist for command-provider secret scrub

Command providers legitimately reference their own API keys in shell
templates (curl one-liners). The #70342 scrub removes ALL provider keys,
which would break such setups. Add a per-provider env_passthrough list
(TTS + STT) that copies named variables back from the parent env, plus
docs and tests. Scrub stays the default; passthrough is explicit opt-in.
This commit is contained in:
Teknium 2026-07-28 09:34:41 -07:00
parent 59fc68e93f
commit e251e78df9
4 changed files with 115 additions and 4 deletions

View file

@ -663,3 +663,49 @@ class TestCheckTtsRequirements:
}
with patch("tools.tts_tool._load_tts_config", return_value=cfg):
assert check_tts_requirements() is True
class TestCommandTtsEnvPassthrough:
def test_env_passthrough_restores_named_keys(self, monkeypatch):
"""A provider's env_passthrough allowlist re-adds its own API key
without unscrubbing everything else."""
monkeypatch.setenv("MY_TTS_API_KEY", "sk-provider")
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
captured = {}
class _Stream:
def read(self, size):
return ""
class Proc:
returncode = 0
stdout = _Stream()
stderr = _Stream()
def wait(self, timeout=None):
return 0
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, env_passthrough=["MY_TTS_API_KEY"]
)
assert result.returncode == 0
env = captured["env"]
assert env["MY_TTS_API_KEY"] == "sk-provider"
assert "OPENAI_API_KEY" not in env
def test_allowlist_parsed_from_provider_config(self):
from tools.tts_tool import _command_provider_env_passthrough
assert _command_provider_env_passthrough(
{"env_passthrough": ["A_KEY", " B_KEY ", ""]}
) == ["A_KEY", "B_KEY"]
assert _command_provider_env_passthrough({}) == []
assert _command_provider_env_passthrough({"env_passthrough": "A_KEY"}) == []

View file

@ -654,7 +654,26 @@ def _terminate_command_stt_process_tree(proc: subprocess.Popen) -> None:
proc.kill()
def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProcess:
def _command_stt_env_passthrough(config: Dict[str, Any]) -> list:
"""Return the provider's ``env_passthrough`` allowlist (opt-out of scrub).
Command providers legitimately reference their own API keys in the shell
template (curl one-liners). The child env is scrubbed of Hermes secrets by
default; ``env_passthrough: [MY_API_KEY, ...]`` copies the named variables
back from the parent environment so a trusted template keeps working.
Mirrors ``tools.tts_tool._command_provider_env_passthrough``.
"""
raw = config.get("env_passthrough")
if not isinstance(raw, (list, tuple)):
return []
return [str(item).strip() for item in raw if str(item).strip()]
def _run_command_stt(
command: str,
timeout: float,
env_passthrough: Optional[list] = None,
) -> subprocess.CompletedProcess:
"""Run a command-provider shell command with process-tree idle cleanup.
Mirrors ``tools.tts_tool._run_command_tts``: ``timeout`` is an IDLE
@ -668,6 +687,10 @@ def _run_command_stt(command: str, timeout: float) -> subprocess.CompletedProces
from tools.environments.local import hermes_subprocess_env
scrubbed = hermes_subprocess_env(inherit_credentials=False)
for key in env_passthrough or []:
value = os.environ.get(key)
if value is not None:
scrubbed[key] = value
popen_kwargs: Dict[str, Any] = {
"shell": True,
"stdout": subprocess.PIPE,
@ -879,7 +902,11 @@ def _transcribe_command_stt(
audio.name, provider_name,
)
try:
result = _run_command_stt(command, timeout)
result = _run_command_stt(
command,
timeout,
env_passthrough=_command_stt_env_passthrough(config),
)
except subprocess.TimeoutExpired:
return {
"success": False,

View file

@ -1001,7 +1001,25 @@ def _terminate_command_tts_process_tree(proc: subprocess.Popen) -> None:
proc.kill()
def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProcess:
def _command_provider_env_passthrough(config: Dict[str, Any]) -> list:
"""Return the provider's ``env_passthrough`` allowlist (opt-out of scrub).
Command providers legitimately reference their own API keys in the shell
template (curl one-liners). The child env is scrubbed of Hermes secrets by
default; ``env_passthrough: [MY_API_KEY, ...]`` copies the named variables
back from the parent environment so a trusted template keeps working.
"""
raw = config.get("env_passthrough")
if not isinstance(raw, (list, tuple)):
return []
return [str(item).strip() for item in raw if str(item).strip()]
def _run_command_tts(
command: str,
timeout: float,
env_passthrough: Optional[list] = None,
) -> subprocess.CompletedProcess:
"""Run a command-provider shell command with process-tree idle cleanup.
Child env is scrubbed of Hermes secrets (salvage of #56332) while still
@ -1011,6 +1029,10 @@ def _run_command_tts(command: str, timeout: float) -> subprocess.CompletedProces
from tools.environments.local import hermes_subprocess_env
scrubbed = hermes_subprocess_env(inherit_credentials=False)
for key in env_passthrough or []:
value = os.environ.get(key)
if value is not None:
scrubbed[key] = value
popen_kwargs: Dict[str, Any] = {
"shell": True,
"stdout": subprocess.PIPE,
@ -1172,7 +1194,11 @@ def _generate_command_tts(
command = _render_command_tts_template(command_template, placeholders)
try:
_run_command_tts(command, timeout)
_run_command_tts(
command,
timeout,
env_passthrough=_command_provider_env_passthrough(config),
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"TTS provider '{provider_name}' timed out after {timeout:g}s"

View file

@ -286,6 +286,18 @@ tts:
**Supported `output_format` values:** `mp3` (default), `wav`, `ogg`, `flac`, `m4a`, `aac`, `amr`, `opus`. Your command must actually produce that format (e.g. via `ffmpeg`); Hermes only validates the declared value and names the output file accordingly. An unknown value falls back to `mp3`. The chosen format is also exposed to the command as the `{format}` placeholder.
**Subprocess environment:** command providers (TTS and STT) run with Hermes secrets scrubbed from the child environment — gateway bot tokens, LLM provider API keys, and internal relay credentials are removed; `PATH`, `HOME`, locale, and other normal variables are kept. If your command template needs its own API key from the environment (e.g. a `curl` one-liner), list the variable names under `env_passthrough` in the provider config:
```yaml
tts:
providers:
mycloud:
type: command
command: 'curl -s -H "Authorization: Bearer $MYCLOUD_API_KEY" ... -o {output_path}'
env_passthrough: [MYCLOUD_API_KEY]
```
#### Example: Doubao (Chinese seed-tts-2.0)
For high-quality Chinese TTS via ByteDance's [seed-tts-2.0](https://www.volcengine.com/docs/6561/1257544) bidirectional-streaming API, install the [`doubao-speech`](https://pypi.org/project/doubao-speech/) PyPI package and wire it in as a command provider: