30da5d0a89
many tests patched sys.platform or a module's _IS_WINDOWS flag, then ran on linux ci. the patch selects the branch under test, but the host does not have the behavior the branch exists for. the test proves the patch, not the platform. some gated assertions never ran on any host. this commit adds three markers: linux_only, macos_only, windows_only. a conftest hook skips a marked test on the other hosts, with a clear reason. no test fakes a host now. two documented fakes remain (android/termux, freebsd) because no ci runner exists for them. each fake site got one of four treatments: - gate it: the real host supplies the platform; mocks cover real dependencies only, never host identity - patch the module's own probe when the subject is the probe's consumer - assert against the real host when the fake stood in for any non-x host - delete the patch when it set the value the host already has bare skipif(sys.platform != ...) guards became markers too. the lane model skips these on linux and never imports them on windows, so they ran on no host. platform parametrize tables are now one marked test per os. running on real hosts found real errors: a chrome-sandbox failure in test_gui_command that main hides, and two windows failures fixed here. the agents.md testing section now documents the policy.
735 lines
32 KiB
Python
735 lines
32 KiB
Python
"""Tests for subprocess env sanitization in LocalEnvironment.
|
|
|
|
Verifies that Hermes-managed provider, tool, and gateway env vars are
|
|
stripped from subprocess environments so external CLIs are not silently
|
|
misrouted or handed Hermes secrets.
|
|
|
|
See: https://github.com/NousResearch/hermes-agent/issues/1002
|
|
See: https://github.com/NousResearch/hermes-agent/issues/1264
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tools.environments.local import (
|
|
LocalEnvironment,
|
|
_HERMES_PROVIDER_ENV_BLOCKLIST,
|
|
_HERMES_PROVIDER_ENV_FORCE_PREFIX,
|
|
)
|
|
|
|
|
|
def _make_fake_popen(captured: dict):
|
|
"""Return a fake Popen constructor that records the env kwarg."""
|
|
def fake_popen(cmd, **kwargs):
|
|
captured["env"] = kwargs.get("env", {})
|
|
proc = MagicMock()
|
|
proc.poll.return_value = 0
|
|
proc.returncode = 0
|
|
proc.stdout = MagicMock(__iter__=lambda s: iter([]), __next__=lambda s: (_ for _ in ()).throw(StopIteration))
|
|
proc.stdin = MagicMock()
|
|
return proc
|
|
return fake_popen
|
|
|
|
|
|
def _run_with_env(extra_os_env=None, self_env=None):
|
|
"""Execute a command via LocalEnvironment with mocked Popen
|
|
and return the env dict passed to the subprocess."""
|
|
captured = {}
|
|
fake_interrupt = threading.Event()
|
|
test_environ = {
|
|
"PATH": "/usr/bin:/bin",
|
|
"HOME": "/home/user",
|
|
"USER": "testuser",
|
|
}
|
|
if extra_os_env:
|
|
test_environ.update(extra_os_env)
|
|
|
|
env = LocalEnvironment(cwd="/tmp", timeout=10, env=self_env)
|
|
|
|
with patch("tools.environments.local._find_bash", return_value="/bin/bash"), \
|
|
patch("subprocess.Popen", side_effect=_make_fake_popen(captured)), \
|
|
patch("tools.terminal_tool._interrupt_event", fake_interrupt), \
|
|
patch.dict(os.environ, test_environ, clear=True):
|
|
env.execute("echo hello")
|
|
|
|
return captured.get("env", {})
|
|
|
|
|
|
class TestProviderEnvBlocklist:
|
|
"""Provider env vars loaded from ~/.hermes/.env must not leak."""
|
|
|
|
def test_blocked_vars_are_stripped(self):
|
|
"""OPENAI_BASE_URL and other provider vars must not appear in subprocess env."""
|
|
leaked_vars = {
|
|
"OPENAI_BASE_URL": "http://localhost:8000/v1",
|
|
"OPENAI_API_KEY": "sk-fake-key",
|
|
"OPENROUTER_API_KEY": "or-fake-key",
|
|
"ANTHROPIC_API_KEY": "ant-fake-key",
|
|
"LLM_MODEL": "anthropic/claude-opus-4-6",
|
|
}
|
|
result_env = _run_with_env(extra_os_env=leaked_vars)
|
|
|
|
for var in leaked_vars:
|
|
assert var not in result_env, f"{var} leaked into subprocess env"
|
|
|
|
def test_registry_derived_vars_are_stripped(self):
|
|
"""Vars from the provider registry (ANTHROPIC_TOKEN, ZAI_API_KEY, etc.)
|
|
must also be blocked — not just the hand-written extras."""
|
|
registry_vars = {
|
|
"ANTHROPIC_TOKEN": "ant-tok",
|
|
"ZAI_API_KEY": "zai-key",
|
|
"Z_AI_API_KEY": "z-ai-key",
|
|
"GLM_API_KEY": "glm-key",
|
|
"KIMI_API_KEY": "kimi-key",
|
|
"MINIMAX_API_KEY": "mm-key",
|
|
"MINIMAX_CN_API_KEY": "mmcn-key",
|
|
"DEEPSEEK_API_KEY": "deepseek-key",
|
|
"NVIDIA_API_KEY": "nvidia-key",
|
|
}
|
|
result_env = _run_with_env(extra_os_env=registry_vars)
|
|
|
|
for var in registry_vars:
|
|
assert var not in result_env, f"{var} leaked into subprocess env"
|
|
|
|
def test_bedrock_bearer_token_is_stripped(self):
|
|
"""The Bedrock-specific bearer token is a Hermes inference secret
|
|
(analogous to OPENAI_API_KEY) and must not leak into subprocesses.
|
|
|
|
Regression for #32314: AWS_BEARER_TOKEN_BEDROCK leaked into terminal /
|
|
execute_code children because the ``bedrock`` ProviderConfig declares
|
|
``api_key_env_vars=()`` (auth_type="aws_sdk") and the blocklist builder
|
|
only consulted that field. The reporter caught it when ``opencode
|
|
models`` run inside a Hermes terminal enumerated the entire Bedrock
|
|
catalog off the leaked bearer token.
|
|
"""
|
|
result_env = _run_with_env(extra_os_env={
|
|
"AWS_BEARER_TOKEN_BEDROCK": "bedrock-bearer-secret",
|
|
})
|
|
|
|
assert "AWS_BEARER_TOKEN_BEDROCK" not in result_env, (
|
|
"AWS_BEARER_TOKEN_BEDROCK leaked into subprocess env (see #32314)"
|
|
)
|
|
|
|
def test_vertex_credentials_path_is_stripped(self):
|
|
"""The Vertex AI service-account JSON path must not leak into
|
|
subprocesses, even though it is filesystem path metadata rather
|
|
than a bare API key.
|
|
|
|
Regression: ``vertex`` authenticates via OAuth2 (service-account
|
|
JSON / ADC), not PROVIDER_REGISTRY, and OPTIONAL_ENV_VARS marks
|
|
VERTEX_CREDENTIALS_PATH as ``password=False`` (it's a path, not a
|
|
secret string) with ``category="provider"`` — a category the
|
|
registry-derived loop above never checks — so it fell through both
|
|
blocklist sources. GOOGLE_APPLICATION_CREDENTIALS (the ADC fallback
|
|
the adapter also reads) had the same gap. A leaked path discloses
|
|
the on-disk location of a GCP service-account key to every spawned
|
|
subprocess (terminal, codex/copilot app-server, browser workers).
|
|
"""
|
|
result_env = _run_with_env(extra_os_env={
|
|
"VERTEX_CREDENTIALS_PATH": "/home/user/.config/gcloud/sa-key.json",
|
|
"GOOGLE_APPLICATION_CREDENTIALS": "/home/user/.config/gcloud/adc.json",
|
|
})
|
|
|
|
assert "VERTEX_CREDENTIALS_PATH" not in result_env
|
|
assert "GOOGLE_APPLICATION_CREDENTIALS" not in result_env
|
|
|
|
def test_general_aws_credential_chain_is_preserved(self):
|
|
"""The GENERAL AWS credential chain must STILL pass through to
|
|
subprocesses — this is the no-regression guard for #32314.
|
|
|
|
Per SECURITY.md §3.2 the local terminal is the user's trusted operator
|
|
shell. A user running ``aws``/``terraform``/``cdk``/``boto3`` in the
|
|
agent terminal must keep the same AWS access their own shell has.
|
|
Stripping these would (a) break every user who does AWS work in the
|
|
agent terminal — not just Bedrock users, since the registry is iterated
|
|
unconditionally — and (b) be unrecoverable, because env_passthrough.py
|
|
refuses to re-allow anything in _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
(GHSA-rhgp-j443-p4rf). Only the Bedrock inference bearer token is
|
|
Hermes-managed; the rest belongs to the user.
|
|
"""
|
|
general_chain = {
|
|
"AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE",
|
|
"AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
"AWS_SESSION_TOKEN": "session-token",
|
|
"AWS_PROFILE": "production",
|
|
"AWS_DEFAULT_REGION": "us-east-1",
|
|
"AWS_REGION": "us-east-1",
|
|
"AWS_SHARED_CREDENTIALS_FILE": "/home/user/.aws/credentials",
|
|
"AWS_CONFIG_FILE": "/home/user/.aws/config",
|
|
"AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token",
|
|
"AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/example",
|
|
}
|
|
result_env = _run_with_env(extra_os_env=general_chain)
|
|
|
|
for var, value in general_chain.items():
|
|
assert result_env.get(var) == value, (
|
|
f"{var} was stripped from subprocess env — this is a "
|
|
f"capability regression (see #32314 discussion)"
|
|
)
|
|
|
|
def test_non_registry_provider_vars_are_stripped(self):
|
|
"""Extra provider vars not in PROVIDER_REGISTRY must also be blocked."""
|
|
extra_provider_vars = {
|
|
"GOOGLE_API_KEY": "google-key",
|
|
"MISTRAL_API_KEY": "mistral-key",
|
|
"GROQ_API_KEY": "groq-key",
|
|
"TOGETHER_API_KEY": "together-key",
|
|
"PERPLEXITY_API_KEY": "perplexity-key",
|
|
"COHERE_API_KEY": "cohere-key",
|
|
"FIREWORKS_API_KEY": "fireworks-key",
|
|
"XAI_API_KEY": "xai-key",
|
|
"HELICONE_API_KEY": "helicone-key",
|
|
}
|
|
result_env = _run_with_env(extra_os_env=extra_provider_vars)
|
|
|
|
for var in extra_provider_vars:
|
|
assert var not in result_env, f"{var} leaked into subprocess env"
|
|
|
|
def test_tool_and_gateway_vars_are_stripped(self):
|
|
"""Tool and gateway secrets/config must not leak into subprocess env."""
|
|
leaked_vars = {
|
|
"TELEGRAM_BOT_TOKEN": "bot-token",
|
|
"TELEGRAM_HOME_CHANNEL": "12345",
|
|
"DISCORD_HOME_CHANNEL": "67890",
|
|
"SLACK_APP_TOKEN": "xapp-secret",
|
|
"WHATSAPP_ALLOWED_USERS": "+15555550123",
|
|
"SIGNAL_ACCOUNT": "+15555550124",
|
|
"HASS_TOKEN": "ha-secret",
|
|
"EMAIL_PASSWORD": "email-secret",
|
|
"FIRECRAWL_API_KEY": "fc-secret",
|
|
"HERMES_DASHBOARD_SESSION_TOKEN": "dashboard-session-secret",
|
|
"BROWSERBASE_PROJECT_ID": "bb-project",
|
|
"ELEVENLABS_API_KEY": "el-secret",
|
|
"GITHUB_TOKEN": "ghp_secret",
|
|
"GH_TOKEN": "gh_alias_secret",
|
|
"GATEWAY_ALLOW_ALL_USERS": "true",
|
|
"GATEWAY_ALLOWED_USERS": "alice,bob",
|
|
"MODAL_TOKEN_ID": "modal-id",
|
|
"MODAL_TOKEN_SECRET": "modal-secret",
|
|
"DAYTONA_API_KEY": "daytona-key",
|
|
"VERCEL_OIDC_TOKEN": "vercel-oidc-token",
|
|
"VERCEL_TOKEN": "vercel-token",
|
|
"VERCEL_PROJECT_ID": "vercel-project",
|
|
"VERCEL_TEAM_ID": "vercel-team",
|
|
}
|
|
result_env = _run_with_env(extra_os_env=leaked_vars)
|
|
|
|
for var in leaked_vars:
|
|
assert var not in result_env, f"{var} leaked into subprocess env"
|
|
|
|
def test_safe_vars_are_preserved(self):
|
|
"""Standard env vars (PATH, HOME, USER) must still be passed through."""
|
|
result_env = _run_with_env()
|
|
|
|
assert "HOME" in result_env
|
|
assert result_env["HOME"] == "/home/user"
|
|
assert "USER" in result_env
|
|
assert "PATH" in result_env
|
|
|
|
def test_self_env_blocked_vars_also_stripped(self):
|
|
"""Blocked vars in self.env are stripped; non-blocked vars pass through."""
|
|
result_env = _run_with_env(self_env={
|
|
"OPENAI_BASE_URL": "http://custom:9999/v1",
|
|
"MY_CUSTOM_VAR": "keep-this",
|
|
})
|
|
|
|
assert "OPENAI_BASE_URL" not in result_env
|
|
assert "MY_CUSTOM_VAR" in result_env
|
|
assert result_env["MY_CUSTOM_VAR"] == "keep-this"
|
|
|
|
|
|
class TestForceEnvOptIn:
|
|
"""Callers can opt in to passing a blocked var via _HERMES_FORCE_ prefix."""
|
|
|
|
def test_force_prefix_passes_blocked_var(self):
|
|
"""_HERMES_FORCE_OPENAI_API_KEY in self.env should inject OPENAI_API_KEY."""
|
|
result_env = _run_with_env(self_env={
|
|
f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY": "sk-explicit",
|
|
})
|
|
|
|
assert "OPENAI_API_KEY" in result_env
|
|
assert result_env["OPENAI_API_KEY"] == "sk-explicit"
|
|
# The force-prefixed key itself must not appear
|
|
assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_API_KEY" not in result_env
|
|
|
|
def test_force_prefix_overrides_os_environ_block(self):
|
|
"""Force-prefix in self.env wins even when os.environ has the blocked var."""
|
|
result_env = _run_with_env(
|
|
extra_os_env={"OPENAI_BASE_URL": "http://leaked/v1"},
|
|
self_env={f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}OPENAI_BASE_URL": "http://intended/v1"},
|
|
)
|
|
|
|
assert result_env["OPENAI_BASE_URL"] == "http://intended/v1"
|
|
|
|
|
|
class TestActiveVenvMarkerStripping:
|
|
"""Active-virtualenv markers must not leak into terminal subprocesses (#23473).
|
|
|
|
The gateway runs inside its own venv, so its process environment carries
|
|
VIRTUAL_ENV (and possibly CONDA_PREFIX). If those leak into commands the
|
|
agent runs against ANOTHER Python project, ``uv``/``poetry`` treat the
|
|
inherited value as the active environment and build that project's deps
|
|
into the Hermes venv path instead of the project's own ``.venv`` —
|
|
silently clobbering the Hermes environment (and, when the other project
|
|
pins a different Python, breaking the gateway outright). The Hermes venv
|
|
stays reachable via PATH, so stripping the markers is safe.
|
|
"""
|
|
|
|
def test_virtualenv_marker_stripped_end_to_end(self):
|
|
result_env = _run_with_env(extra_os_env={
|
|
"VIRTUAL_ENV": "/home/user/.hermes/hermes-agent/venv",
|
|
})
|
|
assert "VIRTUAL_ENV" not in result_env
|
|
|
|
def test_conda_prefix_marker_stripped_end_to_end(self):
|
|
result_env = _run_with_env(extra_os_env={
|
|
"CONDA_PREFIX": "/opt/conda/envs/hermes",
|
|
})
|
|
assert "CONDA_PREFIX" not in result_env
|
|
|
|
def test_make_run_env_strips_markers(self):
|
|
from tools.environments.local import _make_run_env
|
|
poison = {"VIRTUAL_ENV": "/venv", "CONDA_PREFIX": "/conda", "PATH": "/usr/bin"}
|
|
with patch.dict(os.environ, poison, clear=True):
|
|
result = _make_run_env({})
|
|
assert "VIRTUAL_ENV" not in result
|
|
assert "CONDA_PREFIX" not in result
|
|
|
|
def test_sanitize_subprocess_env_strips_markers(self):
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
base = {"VIRTUAL_ENV": "/venv", "CONDA_PREFIX": "/conda", "HOME": "/home/user"}
|
|
# Even an explicitly-passed extra marker is stripped.
|
|
result = _sanitize_subprocess_env(base, {"VIRTUAL_ENV": "/also/venv"})
|
|
assert "VIRTUAL_ENV" not in result
|
|
assert "CONDA_PREFIX" not in result
|
|
assert result.get("HOME") == "/home/user"
|
|
|
|
def test_markers_constant_contents(self):
|
|
from tools.environments.local import _ACTIVE_VENV_MARKER_VARS
|
|
assert "VIRTUAL_ENV" in _ACTIVE_VENV_MARKER_VARS
|
|
assert "CONDA_PREFIX" in _ACTIVE_VENV_MARKER_VARS
|
|
|
|
|
|
class TestProfileScopedPassthrough:
|
|
def test_make_run_env_uses_active_profile_for_passthrough(self, monkeypatch):
|
|
"""Allowlisted values must come from the routed profile, not os.environ."""
|
|
from agent import secret_scope as ss
|
|
from tools.env_passthrough import clear_env_passthrough, register_env_passthrough
|
|
from tools.environments.local import _make_run_env
|
|
|
|
clear_env_passthrough()
|
|
register_env_passthrough(["SERVICE_TOKEN"])
|
|
monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({"SERVICE_TOKEN": "token-for-routed-profile"})
|
|
try:
|
|
result = _make_run_env({})
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
clear_env_passthrough()
|
|
|
|
assert result["SERVICE_TOKEN"] == "token-for-routed-profile"
|
|
|
|
def test_make_run_env_omits_missing_scoped_passthrough(self, monkeypatch):
|
|
"""A missing routed secret must not fall back to the default profile."""
|
|
from agent import secret_scope as ss
|
|
from tools.env_passthrough import clear_env_passthrough, register_env_passthrough
|
|
from tools.environments.local import _make_run_env
|
|
|
|
clear_env_passthrough()
|
|
register_env_passthrough(["SERVICE_TOKEN"])
|
|
monkeypatch.setenv("SERVICE_TOKEN", "token-for-default")
|
|
ss.set_multiplex_active(True)
|
|
token = ss.set_secret_scope({})
|
|
try:
|
|
result = _make_run_env({})
|
|
finally:
|
|
ss.reset_secret_scope(token)
|
|
ss.set_multiplex_active(False)
|
|
clear_env_passthrough()
|
|
|
|
assert "SERVICE_TOKEN" not in result
|
|
|
|
|
|
class TestBlocklistCoverage:
|
|
"""Sanity checks that the blocklist covers all known providers."""
|
|
|
|
def test_issue_1002_offenders(self):
|
|
"""Blocklist includes the main offenders from issue #1002."""
|
|
must_block = {
|
|
"OPENAI_BASE_URL",
|
|
"OPENAI_API_KEY",
|
|
"OPENROUTER_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"LLM_MODEL",
|
|
}
|
|
assert must_block.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
|
|
|
|
def test_registry_vars_are_in_blocklist(self):
|
|
"""Every api_key_env_var and base_url_env_var from PROVIDER_REGISTRY
|
|
must appear in the blocklist — ensures no drift.
|
|
|
|
CLAUDE_CODE_OAUTH_TOKEN is the one deliberate exemption: it is owned
|
|
by the user's Claude Code install, not Hermes (#55878).
|
|
"""
|
|
from hermes_cli.auth import PROVIDER_REGISTRY
|
|
|
|
exempt = {"CLAUDE_CODE_OAUTH_TOKEN"}
|
|
for pconfig in PROVIDER_REGISTRY.values():
|
|
for var in pconfig.api_key_env_vars:
|
|
if var in exempt:
|
|
continue
|
|
assert var in _HERMES_PROVIDER_ENV_BLOCKLIST, (
|
|
f"Registry var {var} (provider={pconfig.id}) missing from blocklist"
|
|
)
|
|
if pconfig.base_url_env_var:
|
|
assert pconfig.base_url_env_var in _HERMES_PROVIDER_ENV_BLOCKLIST, (
|
|
f"Registry base_url_env_var {pconfig.base_url_env_var} "
|
|
f"(provider={pconfig.id}) missing from blocklist"
|
|
)
|
|
|
|
def test_bedrock_bearer_token_is_in_blocklist(self):
|
|
"""auth_type='aws_sdk' providers contribute their Hermes-managed
|
|
inference token (the Bedrock bearer) to the blocklist, keyed off
|
|
auth_type so any future SDK-cred provider is covered automatically."""
|
|
assert "AWS_BEARER_TOKEN_BEDROCK" in _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
|
|
def test_general_aws_chain_not_in_blocklist(self):
|
|
"""The general AWS credential chain must NOT be in the blocklist —
|
|
no-regression guard for #32314. These belong to the user's trusted
|
|
operator shell (SECURITY.md §3.2), not to Hermes, and blocklisting
|
|
them would be unrecoverable via env_passthrough (GHSA-rhgp-j443-p4rf).
|
|
"""
|
|
general_chain = {
|
|
"AWS_ACCESS_KEY_ID",
|
|
"AWS_SECRET_ACCESS_KEY",
|
|
"AWS_SESSION_TOKEN",
|
|
"AWS_PROFILE",
|
|
"AWS_DEFAULT_REGION",
|
|
"AWS_REGION",
|
|
"AWS_SHARED_CREDENTIALS_FILE",
|
|
"AWS_CONFIG_FILE",
|
|
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
|
"AWS_ROLE_ARN",
|
|
}
|
|
leaked_block = general_chain & _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
assert not leaked_block, (
|
|
f"General AWS chain vars must stay inheritable, but these are "
|
|
f"blocklisted: {sorted(leaked_block)} (capability regression, #32314)"
|
|
)
|
|
|
|
def test_extra_auth_vars_covered(self):
|
|
"""Non-registry auth vars (ANTHROPIC_TOKEN) must also be in the
|
|
blocklist."""
|
|
extras = {"ANTHROPIC_TOKEN"}
|
|
assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
|
|
|
|
def test_claude_code_oauth_token_is_inheritable(self):
|
|
"""CLAUDE_CODE_OAUTH_TOKEN is owned by the user's Claude Code install
|
|
(subscription OAuth), not a Hermes inference credential. Stripping it
|
|
made agent-spawned ``claude`` fall through to the shared Keychain /
|
|
~/.claude credential store and clobber the user's interactive login
|
|
on auth failure (#55878). It must stay inheritable."""
|
|
assert "CLAUDE_CODE_OAUTH_TOKEN" not in _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
|
|
def test_non_registry_provider_vars_are_in_blocklist(self):
|
|
extras = {
|
|
"GOOGLE_API_KEY",
|
|
"DEEPSEEK_API_KEY",
|
|
"MISTRAL_API_KEY",
|
|
"GROQ_API_KEY",
|
|
"TOGETHER_API_KEY",
|
|
"PERPLEXITY_API_KEY",
|
|
"COHERE_API_KEY",
|
|
"FIREWORKS_API_KEY",
|
|
"XAI_API_KEY",
|
|
"HELICONE_API_KEY",
|
|
}
|
|
assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
|
|
|
|
def test_optional_tool_and_messaging_vars_are_in_blocklist(self):
|
|
"""Tool/messaging vars from OPTIONAL_ENV_VARS should stay covered."""
|
|
from hermes_cli.config import OPTIONAL_ENV_VARS
|
|
|
|
for name, metadata in OPTIONAL_ENV_VARS.items():
|
|
category = metadata.get("category")
|
|
if category in {"tool", "messaging"}:
|
|
assert name in _HERMES_PROVIDER_ENV_BLOCKLIST, (
|
|
f"Optional env var {name} (category={category}) missing from blocklist"
|
|
)
|
|
elif category == "setting" and metadata.get("password"):
|
|
assert name in _HERMES_PROVIDER_ENV_BLOCKLIST, (
|
|
f"Secret setting env var {name} missing from blocklist"
|
|
)
|
|
|
|
def test_gateway_runtime_vars_are_in_blocklist(self):
|
|
extras = {
|
|
"TELEGRAM_HOME_CHANNEL",
|
|
"TELEGRAM_HOME_CHANNEL_NAME",
|
|
"DISCORD_HOME_CHANNEL",
|
|
"DISCORD_HOME_CHANNEL_NAME",
|
|
"DISCORD_REQUIRE_MENTION",
|
|
"DISCORD_FREE_RESPONSE_CHANNELS",
|
|
"DISCORD_AUTO_THREAD",
|
|
"SLACK_HOME_CHANNEL",
|
|
"SLACK_HOME_CHANNEL_NAME",
|
|
"SLACK_ALLOWED_USERS",
|
|
"WHATSAPP_ENABLED",
|
|
"WHATSAPP_MODE",
|
|
"WHATSAPP_ALLOWED_USERS",
|
|
"SIGNAL_HTTP_URL",
|
|
"SIGNAL_ACCOUNT",
|
|
"SIGNAL_ALLOWED_USERS",
|
|
"SIGNAL_GROUP_ALLOWED_USERS",
|
|
"SIGNAL_HOME_CHANNEL",
|
|
"SIGNAL_HOME_CHANNEL_NAME",
|
|
"SIGNAL_IGNORE_STORIES",
|
|
"HASS_TOKEN",
|
|
"HASS_URL",
|
|
"EMAIL_ADDRESS",
|
|
"EMAIL_PASSWORD",
|
|
"EMAIL_IMAP_HOST",
|
|
"EMAIL_SMTP_HOST",
|
|
"EMAIL_HOME_ADDRESS",
|
|
"EMAIL_HOME_ADDRESS_NAME",
|
|
"HERMES_DASHBOARD_SESSION_TOKEN",
|
|
"GATEWAY_ALLOWED_USERS",
|
|
"GH_TOKEN",
|
|
"GITHUB_APP_ID",
|
|
"GITHUB_APP_PRIVATE_KEY_PATH",
|
|
"GITHUB_APP_INSTALLATION_ID",
|
|
"MODAL_TOKEN_ID",
|
|
"MODAL_TOKEN_SECRET",
|
|
"DAYTONA_API_KEY",
|
|
"VERCEL_OIDC_TOKEN",
|
|
"VERCEL_TOKEN",
|
|
"VERCEL_PROJECT_ID",
|
|
"VERCEL_TEAM_ID",
|
|
}
|
|
assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST)
|
|
|
|
|
|
class TestSanePathIncludesHomebrew:
|
|
"""Verify _SANE_PATH includes macOS Homebrew directories."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _disable_hermes_bin_injection(self):
|
|
"""These tests assert the sane-path merge in isolation. Disable the
|
|
hermes-install-dir prepend (a separate concern, covered by
|
|
TestHermesBinDirOnPath) so a real ``hermes`` on the test runner's PATH
|
|
doesn't shift the asserted PATH layout."""
|
|
from tools.environments import local as local_mod
|
|
saved = local_mod._HERMES_BIN_DIR
|
|
local_mod._HERMES_BIN_DIR = None # resolved -> no dir to inject
|
|
yield
|
|
local_mod._HERMES_BIN_DIR = saved
|
|
|
|
def test_sane_path_includes_homebrew_bin(self):
|
|
from tools.environments.local import _SANE_PATH
|
|
assert "/opt/homebrew/bin" in _SANE_PATH
|
|
|
|
|
|
def test_make_run_env_appends_homebrew_on_minimal_path(self):
|
|
"""When PATH is minimal, _make_run_env appends missing sane entries."""
|
|
from tools.environments.local import _SANE_PATH, _make_run_env
|
|
minimal_env = {"PATH": "/some/custom/bin"}
|
|
with patch.dict(os.environ, minimal_env, clear=True):
|
|
result = _make_run_env({})
|
|
path_entries = result["PATH"].split(":")
|
|
assert path_entries[0] == "/some/custom/bin"
|
|
for entry in _SANE_PATH.split(":"):
|
|
assert entry in path_entries
|
|
|
|
|
|
def test_make_run_env_real_launchd_path_gains_homebrew(self):
|
|
"""The literal macOS launchd PATH is the production trigger for #35613."""
|
|
from tools.environments.local import _make_run_env
|
|
launchd_env = {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}
|
|
with patch.dict(os.environ, launchd_env, clear=True):
|
|
result = _make_run_env({})
|
|
path_entries = result["PATH"].split(":")
|
|
assert "/opt/homebrew/bin" in path_entries
|
|
assert "/opt/homebrew/sbin" in path_entries
|
|
# Original entries keep their leading precedence.
|
|
assert path_entries[:4] == ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
|
|
|
|
|
@pytest.mark.windows_only
|
|
def test_make_run_env_preserves_windows_mixed_case_path_key(self, monkeypatch):
|
|
"""Windows-only: ``_path_env_key`` looks for a case-insensitive PATH
|
|
key only on Windows, so the mixed-case ``Path`` preservation this
|
|
asserts is a genuinely Windows-native behaviour.
|
|
|
|
The Git Bash dir prepend is neutralised so the assertion is about the
|
|
key casing alone (a real Windows box has those dirs).
|
|
"""
|
|
from tools.environments import local as local_mod
|
|
from tools.environments.local import _make_run_env
|
|
windows_env = {"Path": r"C:\Windows\System32;C:\Program Files\Git\bin"}
|
|
monkeypatch.setattr(local_mod, "_git_bash_bin_dirs", lambda: [])
|
|
with patch.object(local_mod.os, "environ", windows_env):
|
|
result = _make_run_env({})
|
|
assert result["Path"] == windows_env["Path"]
|
|
assert "PATH" not in result
|
|
|
|
|
|
class TestHermesBinDirOnPath:
|
|
"""The hermes install dir is reachable in the terminal subshell PATH.
|
|
|
|
Plugins shelling out to bare ``hermes`` via the terminal tool must work
|
|
even when the gateway was launched without the hermes install dir on
|
|
PATH (systemd, service managers, cron). See the discussion that motivated
|
|
_resolve_hermes_bin_dir / _prepend_hermes_bin_dir.
|
|
"""
|
|
|
|
def _reset_cache(self):
|
|
from tools.environments import local as local_mod
|
|
local_mod._HERMES_BIN_DIR = local_mod._SENTINEL
|
|
|
|
def test_resolves_via_which(self, monkeypatch):
|
|
from tools.environments import local as local_mod
|
|
self._reset_cache()
|
|
monkeypatch.setattr(local_mod.shutil, "which",
|
|
lambda name: "/opt/hermes/bin/hermes" if name == "hermes" else None)
|
|
monkeypatch.setattr(local_mod.os.path, "isdir", lambda p: p == "/opt/hermes/bin")
|
|
assert local_mod._resolve_hermes_bin_dir() == "/opt/hermes/bin"
|
|
|
|
|
|
def test_prepend_noop_when_unresolved(self, monkeypatch):
|
|
from tools.environments import local as local_mod
|
|
self._reset_cache()
|
|
local_mod._HERMES_BIN_DIR = None
|
|
assert local_mod._prepend_hermes_bin_dir("/usr/bin:/bin") == "/usr/bin:/bin"
|
|
|
|
def test_make_run_env_injects_hermes_bin_dir(self):
|
|
"""A gateway env missing the hermes dir gets it back in the subshell PATH.
|
|
|
|
Platform-agnostic: ``_prepend_hermes_bin_dir`` uses ``os.pathsep`` on
|
|
every host, so no platform flag is faked here."""
|
|
from tools.environments import local as local_mod
|
|
from tools.environments.local import _make_run_env
|
|
self._reset_cache()
|
|
local_mod._HERMES_BIN_DIR = "/opt/hermes/bin"
|
|
with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}, clear=True):
|
|
result = _make_run_env({})
|
|
entries = result["PATH"].split(os.pathsep)
|
|
assert entries[0] == "/opt/hermes/bin"
|
|
assert "/usr/bin" in entries
|
|
|
|
|
|
class TestHermesInternalDynamicSecrets:
|
|
"""Dynamically-named Hermes secrets injected at gateway/CLI startup must
|
|
not leak into terminal subprocesses.
|
|
|
|
The static ``_HERMES_PROVIDER_ENV_BLOCKLIST`` is name-based and derived
|
|
from provider/tool registries, so it cannot enumerate:
|
|
|
|
- ``AUXILIARY_<TASK>_API_KEY`` / ``AUXILIARY_<TASK>_BASE_URL`` — per-task
|
|
side-LLM credentials bridged from ``config.yaml[auxiliary]`` by
|
|
``gateway/run.py`` and ``cli.py``.
|
|
- ``GATEWAY_RELAY_*_SECRET`` / ``_KEY`` / ``_TOKEN`` — relay-auth material
|
|
provisioned by ``gateway/relay``.
|
|
|
|
``_is_hermes_internal_secret`` is the single source of truth; every spawn
|
|
path (``_sanitize_subprocess_env``, ``_make_run_env``,
|
|
``hermes_subprocess_env``, Docker forward filter, ``env_passthrough``)
|
|
consults it. These tests exercise the terminal execute path + predicate.
|
|
"""
|
|
|
|
def test_predicate_matches_auxiliary_api_key(self):
|
|
from tools.environments.local import _is_hermes_internal_secret
|
|
assert _is_hermes_internal_secret("AUXILIARY_VISION_API_KEY")
|
|
assert _is_hermes_internal_secret("AUXILIARY_WEB_EXTRACT_API_KEY")
|
|
assert _is_hermes_internal_secret("AUXILIARY_APPROVAL_API_KEY")
|
|
# plugin-registered task names are covered by the pattern
|
|
assert _is_hermes_internal_secret("AUXILIARY_MY_PLUGIN_TASK_API_KEY")
|
|
|
|
def test_predicate_matches_auxiliary_base_url(self):
|
|
from tools.environments.local import _is_hermes_internal_secret
|
|
assert _is_hermes_internal_secret("AUXILIARY_VISION_BASE_URL")
|
|
assert _is_hermes_internal_secret("AUXILIARY_COMPRESSION_BASE_URL")
|
|
|
|
def test_predicate_matches_gateway_relay_auth(self):
|
|
from tools.environments.local import _is_hermes_internal_secret
|
|
assert _is_hermes_internal_secret("GATEWAY_RELAY_SECRET")
|
|
assert _is_hermes_internal_secret("GATEWAY_RELAY_DELIVERY_KEY")
|
|
assert _is_hermes_internal_secret("GATEWAY_RELAY_SESSION_TOKEN")
|
|
|
|
def test_predicate_allows_auxiliary_non_secrets(self):
|
|
"""AUXILIARY_*_PROVIDER / _MODEL and GATEWAY_RELAY_* routing hints are
|
|
NOT secrets and must remain visible so tooling that reads them works."""
|
|
from tools.environments.local import _is_hermes_internal_secret
|
|
assert not _is_hermes_internal_secret("AUXILIARY_VISION_PROVIDER")
|
|
assert not _is_hermes_internal_secret("AUXILIARY_VISION_MODEL")
|
|
assert not _is_hermes_internal_secret("GATEWAY_RELAY_URL")
|
|
assert not _is_hermes_internal_secret("GATEWAY_RELAY_PLATFORMS")
|
|
assert not _is_hermes_internal_secret("GATEWAY_RELAY_ID") # not a secret suffix
|
|
# unrelated vars pass through
|
|
assert not _is_hermes_internal_secret("PATH")
|
|
assert not _is_hermes_internal_secret("MY_APP_KEY")
|
|
|
|
def test_auxiliary_secrets_stripped_from_subprocess(self):
|
|
"""AUXILIARY_*_API_KEY / _BASE_URL injected into os.environ must not
|
|
reach the terminal subprocess, while _PROVIDER / _MODEL survive."""
|
|
result_env = _run_with_env(extra_os_env={
|
|
"AUXILIARY_VISION_API_KEY": "sk-vision-secret",
|
|
"AUXILIARY_VISION_BASE_URL": "http://internal:1234/v1",
|
|
"AUXILIARY_WEB_EXTRACT_API_KEY": "sk-webx-secret",
|
|
"AUXILIARY_VISION_PROVIDER": "openai",
|
|
"AUXILIARY_VISION_MODEL": "gpt-4o",
|
|
})
|
|
assert "AUXILIARY_VISION_API_KEY" not in result_env
|
|
assert "AUXILIARY_VISION_BASE_URL" not in result_env
|
|
assert "AUXILIARY_WEB_EXTRACT_API_KEY" not in result_env
|
|
# Non-secret routing config is preserved.
|
|
assert result_env.get("AUXILIARY_VISION_PROVIDER") == "openai"
|
|
assert result_env.get("AUXILIARY_VISION_MODEL") == "gpt-4o"
|
|
|
|
def test_gateway_relay_secret_stripped_from_subprocess(self):
|
|
result_env = _run_with_env(extra_os_env={
|
|
"GATEWAY_RELAY_SECRET": "relay-signing-secret",
|
|
"GATEWAY_RELAY_DELIVERY_KEY": "relay-delivery-key",
|
|
"GATEWAY_RELAY_URL": "https://relay.example.com",
|
|
})
|
|
assert "GATEWAY_RELAY_SECRET" not in result_env
|
|
assert "GATEWAY_RELAY_DELIVERY_KEY" not in result_env
|
|
# Non-secret routing hint stays visible.
|
|
assert result_env.get("GATEWAY_RELAY_URL") == "https://relay.example.com"
|
|
|
|
def test_auxiliary_secret_stripped_even_when_passthrough_registered(self):
|
|
"""A skill registering AUXILIARY_*_API_KEY as env_passthrough must NOT
|
|
be able to tunnel it into a subprocess — the strip is unconditional."""
|
|
with patch(
|
|
"tools.env_passthrough.is_env_passthrough",
|
|
side_effect=lambda name: name == "AUXILIARY_VISION_API_KEY",
|
|
):
|
|
result_env = _run_with_env(extra_os_env={
|
|
"AUXILIARY_VISION_API_KEY": "sk-vision-secret",
|
|
})
|
|
assert "AUXILIARY_VISION_API_KEY" not in result_env
|
|
|
|
def test_make_run_env_strips_internal_secrets(self):
|
|
"""The foreground _make_run_env path strips the same dynamic secrets."""
|
|
from tools.environments.local import _make_run_env
|
|
with patch.dict(os.environ, {
|
|
"PATH": "/usr/bin:/bin",
|
|
"AUXILIARY_VISION_API_KEY": "sk-secret",
|
|
"GATEWAY_RELAY_SECRET": "relay-secret",
|
|
"AUXILIARY_VISION_PROVIDER": "openai",
|
|
}, clear=True):
|
|
run_env = _make_run_env({})
|
|
assert "AUXILIARY_VISION_API_KEY" not in run_env
|
|
assert "GATEWAY_RELAY_SECRET" not in run_env
|
|
assert run_env.get("AUXILIARY_VISION_PROVIDER") == "openai"
|
|
|
|
def test_gateway_relay_static_names_in_blocklist(self):
|
|
"""The static relay names are also added to the name-based blocklist so
|
|
the exact-match path catches them independently of the predicate."""
|
|
assert "GATEWAY_RELAY_SECRET" in _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
assert "GATEWAY_RELAY_DELIVERY_KEY" in _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
assert "GATEWAY_RELAY_ID" in _HERMES_PROVIDER_ENV_BLOCKLIST
|