mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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:
parent
59fc68e93f
commit
e251e78df9
4 changed files with 115 additions and 4 deletions
|
|
@ -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"}) == []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue