Files
aiturk-hermes-ide/tests/tools/test_local_env_blocklist.py

1789 lines
80 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 subprocess
import sys
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from tools.environments.local import (
LocalEnvironment,
_HERMES_PROVIDER_ENV_BLOCKLIST,
_HERMES_PROVIDER_ENV_FORCE_PREFIX,
)
def _running_venv_site_packages() -> Path:
"""Independently construct the host-native venv site-packages path."""
if sys.platform == "win32":
return Path(sys.prefix) / "Lib" / "site-packages"
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
return Path(sys.prefix) / "lib" / pyver / "site-packages"
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_bare_hermes_resolves_from_sanitized_subprocess_path(self):
"""Cron children can resolve Hermes even when the gateway PATH cannot."""
from tools.environments.local import _sanitize_subprocess_env
with patch(
"tools.environments.local._resolve_hermes_bin_dir",
return_value="/home/user/.local/bin",
):
result = _sanitize_subprocess_env(
{
"PATH": os.pathsep.join(["/usr/bin", "/bin"]),
"HOME": "/home/user",
}
)
assert result["PATH"] == os.pathsep.join(
["/home/user/.local/bin", "/usr/bin", "/bin"]
)
def test_bare_hermes_path_does_not_duplicate_existing_install_dir(self):
"""The PATH repair is idempotent for already-correct environments."""
from tools.environments.local import _sanitize_subprocess_env
with patch(
"tools.environments.local._resolve_hermes_bin_dir",
return_value="/home/user/.local/bin",
):
result = _sanitize_subprocess_env(
{"PATH": os.pathsep.join(["/home/user/.local/bin", "/usr/bin"])}
)
assert result["PATH"] == os.pathsep.join(
["/home/user/.local/bin", "/usr/bin"]
)
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 TestTerminalFirstPartyPlatformEnv:
"""BUZZ_* first-party platform credentials must reach terminal children —
but ONLY in a Buzz agent context.
Issue #78026: Buzz platform agents could not use the ``buzz`` CLI from the
terminal tool because BUZZ_PRIVATE_KEY / BUZZ_AUTH_TAG / BUZZ_RELAY_URL
(and the other BUZZ_* vars) are stripped by _HERMES_PROVIDER_ENV_BLOCKLIST
and env_passthrough refuses to re-allow them (GHSA-rhgp-j443-p4rf).
The carve-out is TERMINAL-ONLY and CONTEXT-GATED: it applies when the
process is a Buzz-ACP managed agent (BUZZ_MANAGED_AGENT set by the
buzz-acp harness, #76243) or the live session's platform is ``buzz``.
Foreground (_make_run_env) and background/PTY (_sanitize_subprocess_env)
children then get the BUZZ_* vars; execute_code, hermes_subprocess_env,
docker, and env_passthrough registration stay sealed, and non-Buzz
sessions/processes keep stripping the vars. The blocklist itself is NOT
modified.
"""
def test_make_run_env_preserves_buzz_vars(self):
"""Foreground terminal children get the BUZZ_* credentials when the
process is a Buzz-managed agent (BUZZ_MANAGED_AGENT set)."""
from tools.environments.local import _make_run_env
buzz_vars = {
"BUZZ_PRIVATE_KEY": "nsec1faketestkey",
"BUZZ_AUTH_TAG": '["tag","data","kind","sig"]',
"BUZZ_RELAY_URL": "https://mycommunity.communities.buzz.xyz",
}
with patch.dict(
os.environ,
{**buzz_vars, "BUZZ_MANAGED_AGENT": "1", "PATH": "/usr/bin:/bin"},
clear=True,
):
run_env = _make_run_env({})
for var, value in buzz_vars.items():
assert run_env.get(var) == value, (
f"{var} missing from foreground terminal env (issue #78026)"
)
def test_sanitize_subprocess_env_preserves_buzz_vars(self, monkeypatch):
"""Background/PTY terminal children get the BUZZ_* credentials when
the process is a Buzz-managed agent."""
from tools.environments.local import _sanitize_subprocess_env
monkeypatch.setenv("BUZZ_MANAGED_AGENT", "1")
buzz_vars = {
"BUZZ_PRIVATE_KEY": "nsec1faketestkey",
"BUZZ_AUTH_TAG": '["tag","data","kind","sig"]',
"BUZZ_RELAY_URL": "https://mycommunity.communities.buzz.xyz",
}
result = _sanitize_subprocess_env({**buzz_vars, "HOME": "/home/user"})
for var, value in buzz_vars.items():
assert result.get(var) == value, (
f"{var} missing from background/PTY terminal env (issue #78026)"
)
def test_buzz_vars_stripped_without_buzz_context(self, monkeypatch):
"""NEGATIVE gate: with no Buzz context signal (no BUZZ_MANAGED_AGENT,
session platform not buzz), the BUZZ_* credentials stay stripped from
BOTH terminal scrub paths — a Telegram/CLI/cron session on a host that
also runs a Buzz gateway must not see BUZZ_PRIVATE_KEY."""
from gateway.session_context import _SESSION_PLATFORM
from tools.environments.local import _make_run_env, _sanitize_subprocess_env
monkeypatch.delenv("BUZZ_MANAGED_AGENT", raising=False)
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
buzz_vars = {
"BUZZ_PRIVATE_KEY": "nsec1faketestkey",
"BUZZ_AUTH_TAG": '["tag","data","kind","sig"]',
"BUZZ_RELAY_URL": "https://mycommunity.communities.buzz.xyz",
}
for var, value in buzz_vars.items():
monkeypatch.setenv(var, value)
# Bind a non-buzz session platform (ContextVar-authoritative).
token = _SESSION_PLATFORM.set("telegram")
try:
run_env = _make_run_env({})
sanitized = _sanitize_subprocess_env({**buzz_vars, "HOME": "/home/user"})
finally:
_SESSION_PLATFORM.reset(token)
for var in buzz_vars:
assert var not in run_env, f"{var} leaked into non-Buzz foreground env"
assert var not in sanitized, f"{var} leaked into non-Buzz background env"
def test_session_platform_buzz_enables_carveout(self, monkeypatch):
"""A live gateway session whose platform is ``buzz`` gets the
carve-out even without BUZZ_MANAGED_AGENT (native buzz gateway
plugin path), via the concurrency-safe session ContextVar."""
from gateway.session_context import _SESSION_PLATFORM
from tools.environments.local import _make_run_env, _sanitize_subprocess_env
monkeypatch.delenv("BUZZ_MANAGED_AGENT", raising=False)
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1faketestkey")
token = _SESSION_PLATFORM.set("buzz")
try:
run_env = _make_run_env({})
sanitized = _sanitize_subprocess_env(
{"BUZZ_PRIVATE_KEY": "nsec1faketestkey", "HOME": "/home/user"}
)
finally:
_SESSION_PLATFORM.reset(token)
assert run_env.get("BUZZ_PRIVATE_KEY") == "nsec1faketestkey"
assert sanitized.get("BUZZ_PRIVATE_KEY") == "nsec1faketestkey"
def test_buzz_vars_stay_in_blocklist(self):
"""The carve-out is a scrub-path exemption, NOT a blocklist removal —
BUZZ_* must remain blocked for every non-terminal surface (execute_code,
hermes_subprocess_env, env_passthrough registration)."""
assert {"BUZZ_PRIVATE_KEY", "BUZZ_AUTH_TAG", "BUZZ_RELAY_URL"} <= \
_HERMES_PROVIDER_ENV_BLOCKLIST
def test_buzz_vars_use_plain_value_under_multiplex_without_scope(self, monkeypatch):
"""First-party platform vars are the process's own env values: with
multiplex active and NO profile secret scope installed, the terminal
scrub paths must forward the plain env value — NOT raise
UnscopedSecretError (the fail-closed regression where the webhook-
filter script runner crashed instead of running without the var)."""
from agent import secret_scope as ss
from tools.environments.local import _make_run_env, _sanitize_subprocess_env
monkeypatch.setenv("BUZZ_MANAGED_AGENT", "1")
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec-plain-value")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
ss.set_multiplex_active(True)
try:
run_env = _make_run_env({})
sanitized = _sanitize_subprocess_env(
{"BUZZ_PRIVATE_KEY": "nsec-plain-value", "HOME": "/home/user"}
)
finally:
ss.set_multiplex_active(False)
assert run_env["BUZZ_PRIVATE_KEY"] == "nsec-plain-value"
assert sanitized["BUZZ_PRIVATE_KEY"] == "nsec-plain-value"
def test_buzz_vars_are_not_scope_resolved(self, monkeypatch):
"""First-party matches bypass the profile secret scope: a scope value
for BUZZ_PRIVATE_KEY must NOT override the process env value — only
skill/config passthrough names are scope-resolved."""
from agent import secret_scope as ss
from tools.environments.local import _make_run_env, _sanitize_subprocess_env
monkeypatch.setenv("BUZZ_MANAGED_AGENT", "1")
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec-process-env")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
ss.set_multiplex_active(True)
token = ss.set_secret_scope({"BUZZ_PRIVATE_KEY": "nsec-scoped"})
try:
run_env = _make_run_env({})
sanitized = _sanitize_subprocess_env(
{"BUZZ_PRIVATE_KEY": "nsec-process-env", "HOME": "/home/user"}
)
finally:
ss.reset_secret_scope(token)
ss.set_multiplex_active(False)
assert run_env["BUZZ_PRIVATE_KEY"] == "nsec-process-env"
assert sanitized["BUZZ_PRIVATE_KEY"] == "nsec-process-env"
class TestTerminalFirstPartySnapshotIsolation:
"""BUZZ_* first-party vars must not persist in the shared terminal
snapshot — a cross-profile leak under a multiplexed gateway.
The terminal login-shell snapshot (init_session ``export -p`` dump and the
per-command re-dump) captures the child env, which now includes
BUZZ_PRIVATE_KEY. The exclusion set is derived from get_all_passthrough()
plus backend-specific additions — and BUZZ_* can never be in it, because
env_passthrough refuses blocklisted names (GHSA-rhgp-j443-p4rf). Without
an exclusion, profile A's BUZZ_PRIVATE_KEY lands in hermes-snap-<id>.sh
and profile B's later command on the same collapsed LocalEnvironment
sources it. Fix: LocalEnvironment treats first-party terminal env names
like profile-scoped passthrough names — excluded from the dump and
save/restored per command.
"""
def test_snapshot_exclusion_set_includes_first_party_names(self, monkeypatch):
"""Under multiplex, BUZZ_* names present in the env are added to the
snapshot exclusion set, so the dump excludes them and _wrap_command
save/restores them per command."""
from agent import secret_scope as ss
from tools.environments.local import LocalEnvironment
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec-profile-a")
env = LocalEnvironment.__new__(LocalEnvironment)
env.env = {}
env._snapshot_passthrough_names = set()
ss.set_multiplex_active(True)
try:
excluded = env._snapshot_excluded_passthrough_names()
finally:
ss.set_multiplex_active(False)
assert "BUZZ_PRIVATE_KEY" in excluded
# The set is monotonic for the environment lifetime: the name stays
# excluded (and unset-guarded per command) even once it leaves the env.
assert "BUZZ_PRIVATE_KEY" in env._snapshot_passthrough_names
def test_buzz_secret_never_reaches_second_profile_via_snapshot(self, monkeypatch, tmp_path):
"""Multiplex regression, end-to-end with real bash: (a) the snapshot
file never contains profile A's BUZZ_PRIVATE_KEY, and (b) profile B
sharing the same LocalEnvironment does not see profile A's
BUZZ_PRIVATE_KEY in its terminal env."""
import shutil
if not shutil.which("bash"):
pytest.skip("bash required")
from agent import secret_scope as ss
from tools.environments.local import LocalEnvironment
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec-profile-a")
ss.set_multiplex_active(True)
env = LocalEnvironment(cwd=str(tmp_path), timeout=30)
try:
# Profile A's command re-dumps the snapshot; the exclusion must
# keep BUZZ_PRIVATE_KEY out of BOTH the initial dump and the
# per-command re-dump.
env.execute("true")
snap = Path(env._snapshot_path)
assert snap.exists()
snap_text = snap.read_text(encoding="utf-8", errors="replace")
assert "nsec-profile-a" not in snap_text
assert "BUZZ_PRIVATE_KEY" not in snap_text
# Profile B: no BUZZ_PRIVATE_KEY in its env, same LocalEnvironment
# (same snapshot file). It must not see profile A's value.
monkeypatch.delenv("BUZZ_PRIVATE_KEY")
result = env.execute("printf '%s' \"${BUZZ_PRIVATE_KEY-unset}\"")
assert "nsec-profile-a" not in result["output"]
assert "unset" in result["output"]
finally:
env.cleanup()
ss.set_multiplex_active(False)
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
def _make_directory_link(link: Path, target: Path) -> None:
"""Create a directory link without requiring symlink privileges.
POSIX: Path.symlink_to. Windows: try symlink_to first (works with
Developer Mode enabled), then fall back to an unprivileged directory
junction via `cmd /c mklink /J` -- junctions do not require the
SeCreateSymbolicLinkPrivilege. Raises the original error when no
mechanism is available so callers can skip with a clear reason.
"""
try:
link.symlink_to(target, target_is_directory=True)
return
except OSError:
if sys.platform != "win32":
raise
# Binary capture: on a localized Windows the junction message is in the
# console code page (e.g. GBK), which would raise UnicodeDecodeError in
# the reader thread under UTF-8 mode. Only the exit code matters.
result = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True,
)
if result.returncode != 0:
detail = result.stderr.decode("utf-8", errors="replace").strip()
raise OSError(detail or f"mklink /J failed: {result.returncode}")
def _physical_repo_root(tmp_path: Path) -> Path:
"""Create the physical repo checkout directory for junction tests."""
physical_root = tmp_path / "physical-home" / "hermes-agent"
physical_root.mkdir(parents=True)
return physical_root
class TestPythonpathSelectiveStrip:
"""PYTHONPATH Hermes-owned entry stripping (#74817).
The Desktop Electron app injects the Hermes repo root and the Hermes
venv's site-packages (Python 3.11) into PYTHONPATH. When this leaks
into subprocesses running a different Python (e.g. 3.13), 3.11 C
extensions appear on sys.path and crash with ImportError.
``_strip_hermes_owned_pythonpath`` surgically removes only the
entries Hermes itself owns (repo root, own venv site-packages),
preserving user paths — including user paths whose names merely
contain another Python version.
"""
def test_owned_entries_stripped_matrix(self):
"""Exact Hermes-owned entries are removed; everything else survives
verbatim (ordering, duplicates, empty components).
Covers: the running venv's site-packages, the repo root (computed
independently via parents[2] so an off-by-one in _hermes_repo_root
cannot silently pass), duplicate Hermes entries, all-owned input
(PYTHONPATH key removed), and mixed user/Hermes ordering with an
empty component preserved.
"""
from tools.environments.local import _strip_hermes_owned_pythonpath
venv_sp = str(_running_venv_site_packages())
local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
repo_root = str(local_file.parents[2])
cases = [
([venv_sp, "/home/user/my-lib"], ["/home/user/my-lib"]),
([repo_root, "/home/user/my-lib"], ["/home/user/my-lib"]),
([venv_sp, "/user/lib", venv_sp, "/user/lib"], ["/user/lib", "/user/lib"]),
([venv_sp], None), # all owned -> PYTHONPATH key removed
(["/first/user/lib", repo_root, "", venv_sp, "/second/user/lib"],
["/first/user/lib", "", "/second/user/lib"]),
]
for input_entries, expected in cases:
env = {"PYTHONPATH": os.pathsep.join(input_entries)}
_strip_hermes_owned_pythonpath(env)
if expected is None:
assert "PYTHONPATH" not in env
else:
assert env["PYTHONPATH"].split(os.pathsep) == expected
@pytest.mark.parametrize("user_pp", [
os.pathsep.join(["/opt/my-lib", "/another/path"]),
"/nix/store/abc123-user-plugin/lib/python3.12/site-packages",
os.pathsep.join(["/old/lib/python2.7/site-packages", "/home/user/lib"]),
os.pathsep.join(["/opt/tools/python3.13/bin", "/opt/downloads/python3.13", "/custom/python3.13"]),
os.pathsep.join([" /opt/user-lib ", "relative/../lib", "", "/opt/user-lib", "/opt/user-lib"]),
os.pathsep.join(["/foo", "", "/bar"]),
"",
])
def test_non_owned_entries_preserved(self, user_pp):
"""Anything not proven Hermes-owned is preserved byte-for-byte.
One invariant, one matrix: ordinary user paths, Nix store paths,
other-major/minor-version site-packages, paths merely containing a
pythonX.Y component, raw spellings (whitespace, relative segments,
duplicates), empty components, and an empty PYTHONPATH all reduce to
the same contract -- ownership is decided by provenance, never by
path shape or version (P1/P2, #74817 follow-ups).
"""
from tools.environments.local import _strip_hermes_owned_pythonpath
env = {"PYTHONPATH": user_pp}
_strip_hermes_owned_pythonpath(env)
assert env.get("PYTHONPATH") == user_pp
def test_non_owned_runtime_shaped_entries_preserved(self):
"""Runtime-derived user spellings are preserved: site-packages for a
different interpreter version, a descendant of the Hermes venv
site-packages, and direct/deeper children of the repo root. The
repo root is computed independently (parents[2] of this file) so an
off-by-one in _hermes_repo_root cannot silently pass; no launcher
injects a direct child as a standalone entry, so such paths are user
paths by contract.
"""
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
running_minor = sys.version_info[1]
other_minor = running_minor + 1 if running_minor < 20 else running_minor - 1
local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
real_repo_root = local_file.parents[2]
inputs = [
os.pathsep.join([
f"/opt/other-venv/lib/python{sys.version_info[0]}.{other_minor}/site-packages",
"/home/user/my-lib",
]),
os.pathsep.join([str(_running_venv_site_packages() / "some-user-path"), "/home/user/my-lib"]),
os.pathsep.join([str(real_repo_root / "tools"), "/home/user/my-lib"]),
os.pathsep.join([str(real_repo_root / "tools" / "environments"), "/home/user/my-lib"]),
]
for user_pp in inputs:
env = {"PYTHONPATH": user_pp}
_strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"] == user_pp
def test_windows_backslash_paths(self):
"""Windows-style backslash paths are handled for Hermes-owned entries.
On Windows, os.pathsep is ';'. We mock it so the test runs
correctly on POSIX CI. On a POSIX host a backslash path is a
single path component, so ``Path`` cannot identify it as
Hermes-owned — the critical invariant is that user Windows paths
(including site-packages paths for another Python version) are
never destroyed. On a real Windows host, Path splits on
backslashes and Hermes venv site-packages entries are stripped
by the same Hermes-owned check (covered by the Windows-only test
below).
"""
from tools.environments.local import _strip_hermes_owned_pythonpath
import sys
pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}"
hermes_win = f"C:\\\\Users\\\\u\\\\.hermes\\\\hermes-agent\\\\venv\\\\lib\\\\{pyver}\\\\site-packages"
user_win = "D:\\\\user\\\\lib"
env = {
"PYTHONPATH": ";".join([hermes_win, user_win]),
}
# Mock os.pathsep to ';' (Windows) just for the strip call.
with patch("os.pathsep", ";"):
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" in env
entries = env["PYTHONPATH"].split(";")
# Both survive on POSIX: user paths must always be preserved, and
# the Hermes-owned check cannot match a backslash path here.
assert hermes_win in entries
assert user_win in entries
@pytest.mark.windows_only
def test_windows_hermes_owned_paths_stripped(self):
"""On Windows, a Hermes venv site-packages entry written with
backslashes is stripped by the same Hermes-owned check, while a
user Windows path is preserved. Windows-only: POSIX ``Path`` does
not split on backslashes, so this cannot be meaningfully simulated
on a POSIX host."""
from tools.environments.local import _strip_hermes_owned_pythonpath
venv_sp = str(_running_venv_site_packages())
# Windows form: C:\...\venv\Lib\site-packages (backslashes)
hermes_win = venv_sp
user_win = "D:\\\\user\\\\lib"
env = {
"PYTHONPATH": ";".join([hermes_win, user_win]),
}
_strip_hermes_owned_pythonpath(env)
entries = env["PYTHONPATH"].split(";")
assert hermes_win not in entries
assert user_win in entries
def test_empty_pythonpath_unchanged(self):
"""An empty PYTHONPATH is a no-op (falsy -> early return)."""
from tools.environments.local import _strip_hermes_owned_pythonpath
env = {"PYTHONPATH": ""}
_strip_hermes_owned_pythonpath(env)
# Empty string is falsy, so the function returns early without
# modifying the dict. The key stays as-is (empty string).
assert env.get("PYTHONPATH") == ""
def test_empty_component_preserved(self):
"""An empty component means cwd and must survive unchanged."""
from tools.environments.local import _strip_hermes_owned_pythonpath
user_pp = os.pathsep.join(["/foo", "", "/bar"])
env = {"PYTHONPATH": user_pp}
_strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"] == user_pp
def test_raw_user_spelling_preserved(self):
"""The sanitizer does not trim, normalize, or deduplicate user entries."""
from tools.environments.local import _strip_hermes_owned_pythonpath
user_pp = os.pathsep.join([
" /opt/user-lib ",
"relative/../lib",
"",
"/opt/user-lib",
"/opt/user-lib",
])
env = {"PYTHONPATH": user_pp}
_strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"] == user_pp
def test_base_python_sanitizer_uses_validated_separate_runtime_venv(self, tmp_path, monkeypatch):
"""A base interpreter strips the exact Windows runtime site-packages.
This deliberately uses a synthetic Hermes venv separate from the test
runner: sys.prefix represents base Python, while validated VIRTUAL_ENV
identifies ``<repo>/venv`` as the Hermes runtime producer contract.
"""
import tools.environments.local as local
repo_root = tmp_path / "hermes-agent"
runtime_venv = repo_root / "venv"
runtime_sp = runtime_venv / "Lib" / "site-packages"
runtime_sp.mkdir(parents=True)
(runtime_venv / "pyvenv.cfg").write_text("version = 3.11\n", encoding="utf-8")
base_prefix = tmp_path / "base-python"
unrelated = "/custom/lib/python3.13/site-packages"
monkeypatch.setattr(local, "_hermes_repo_root_aliases", (repo_root,))
monkeypatch.setattr(local, "_in_venv", False)
monkeypatch.setattr(local, "_hermes_site_packages", None)
monkeypatch.setattr(local.sys, "prefix", str(base_prefix))
monkeypatch.setattr(local.sys, "base_prefix", str(base_prefix))
env = {
"VIRTUAL_ENV": str(runtime_venv),
"PYTHONPATH": os.pathsep.join([str(runtime_sp), unrelated]),
}
result = local._sanitize_subprocess_env(env)
assert Path(local.sys.prefix) == base_prefix
assert runtime_venv != Path(local.sys.prefix)
assert result["PYTHONPATH"] == unrelated
assert "VIRTUAL_ENV" not in result
def test_unrelated_virtual_env_is_not_runtime_provenance(self, tmp_path, monkeypatch):
"""An arbitrary inherited VIRTUAL_ENV cannot claim PYTHONPATH ownership."""
import tools.environments.local as local
repo_root = tmp_path / "hermes-agent"
repo_root.mkdir()
unrelated_venv = tmp_path / "user-venv"
unrelated_sp = unrelated_venv / "Lib" / "site-packages"
unrelated_sp.mkdir(parents=True)
(unrelated_venv / "pyvenv.cfg").write_text("version = 3.13\n", encoding="utf-8")
monkeypatch.setattr(local, "_hermes_repo_root_aliases", (repo_root,))
monkeypatch.setattr(local, "_in_venv", False)
monkeypatch.setattr(local, "_hermes_site_packages", None)
env = {
"VIRTUAL_ENV": str(unrelated_venv),
"PYTHONPATH": str(unrelated_sp),
}
local._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"] == str(unrelated_sp)
def test_no_pythonpath_key(self):
"""Missing PYTHONPATH key is a no-op."""
from tools.environments.local import _strip_hermes_owned_pythonpath
env = {"PATH": "/usr/bin"}
_strip_hermes_owned_pythonpath(env)
assert "PYTHONPATH" not in env
@pytest.mark.parametrize("builder", [
"_make_run_env",
"_sanitize_subprocess_env",
"hermes_subprocess_env",
])
def test_builders_strip_hermes_venv_pythonpath(self, builder):
"""Every subprocess env builder applies the same sanitation contract:
Hermes venv site-packages is stripped, user entries survive.
"""
from tools.environments import local as local_mod
venv_sp = str(_running_venv_site_packages())
seed = {
"PATH": "/usr/bin:/bin",
"HOME": "/home/user",
"PYTHONPATH": os.pathsep.join([venv_sp, "/home/user/my-lib"]),
}
with patch.dict(os.environ, seed, clear=True):
if builder == "_make_run_env":
result = local_mod._make_run_env({})
elif builder == "_sanitize_subprocess_env":
result = local_mod._sanitize_subprocess_env(dict(os.environ))
else:
result = local_mod.hermes_subprocess_env()
pp = result.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert venv_sp not in entries
assert "/home/user/my-lib" in entries
def test_scrub_child_env_strips_hermes_venv_pythonpath(self):
"""execute_code's _scrub_child_env path: after scrubbing, Hermes venv
site-packages entries should be stripped when
_strip_hermes_owned_pythonpath is applied (as the spawn path does),
while user entries (even for another Python version) are preserved.
"""
from tools.code_execution_tool import _scrub_child_env
from tools.environments.local import _strip_hermes_owned_pythonpath
venv_sp = str(_running_venv_site_packages())
other_sp = "/opt/other-venv/lib/python3.99/site-packages"
source = {
"PATH": "/usr/bin",
"HOME": "/home/user",
"PYTHONPATH": os.pathsep.join([venv_sp, other_sp, "/home/user/my-lib"]),
}
scrubbed = _scrub_child_env(source)
# The scrubber passes PYTHONPATH through (it's in _SAFE_ENV_PREFIXES).
assert "PYTHONPATH" in scrubbed
# Now apply the selective strip (as the spawn path does).
_strip_hermes_owned_pythonpath(scrubbed)
pp = scrubbed.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert venv_sp not in entries
assert other_sp in entries
assert "/home/user/my-lib" in entries
@pytest.mark.parametrize("same_env", [True, False])
def test_execute_code_composition_strips_inherited_hermes_entries(self, same_env):
"""Integration: execute_code's real spawn path composes a clean PYTHONPATH.
Seeds a contaminated inherited PYTHONPATH (Hermes repo root + Hermes
venv site-packages + user entries) through os.environ and drives
execute_code all the way to Popen. Proves the #84500 conditional
composition and the #82581 selective strip compose correctly:
* inherited Hermes venv site-packages never survive into the sandbox;
* the staging tmpdir stays the first entry;
* the repo root is deliberately re-added exactly once for a same-env
child (the single occurrence proves the inherited copy was stripped
first) and stays absent for an external-environment child;
* user entries survive after the controlled entries.
"""
import tools.code_execution_tool as cet
from tools.code_execution_tool import execute_code
def _mock_handle_function_call(function_name, function_args, task_id=None, user_task=None):
return '{"output": "mock", "exit_code": 0}'
hermes_root = str(Path(cet.__file__).resolve().parents[1])
venv_sp = str(_running_venv_site_packages())
user_a = "/home/user/my-lib"
user_b = "/opt/project/lib"
captured = {}
def _fake_popen(cmd, **kwargs):
captured["env"] = kwargs.get("env", {})
captured["staging"] = os.path.dirname(cmd[1])
proc = MagicMock()
proc.stdout.read.return_value = b""
proc.stderr.read.return_value = b""
proc.wait.return_value = 0
proc.returncode = 0
proc.poll.return_value = 0
return proc
with patch("tools.code_execution_tool._load_config",
return_value={"mode": "strict"}), \
patch("model_tools.handle_function_call",
side_effect=_mock_handle_function_call), \
patch("tools.code_execution_tool._uses_hermes_python_environment",
return_value=same_env), \
patch("subprocess.Popen", side_effect=_fake_popen), \
patch.dict(os.environ, {
"PYTHONPATH": os.pathsep.join(
[hermes_root, venv_sp, user_a, user_b]),
}):
execute_code(code="pass", task_id="test-int", enabled_tools=[])
assert "PYTHONPATH" in captured["env"], \
"execute_code never reached Popen"
parts = captured["env"]["PYTHONPATH"].split(os.pathsep)
# Windows path comparison is case-insensitive: the inherited entries
# and the re-added repo root can carry a different case than the
# resolve()/abspath()-derived spellings used in this test (e.g. a
# launcher-written lowercase PYTHONPATH). Normalize with
# os.path.normcase so a case-only difference never fails the
# composition contract (identity on POSIX).
norm_parts = [os.path.normcase(p) for p in parts]
norm_staging = os.path.normcase(captured["staging"])
norm_root = os.path.normcase(hermes_root)
norm_venv = os.path.normcase(venv_sp)
norm_user_a = os.path.normcase(user_a)
norm_user_b = os.path.normcase(user_b)
assert norm_parts[0] == norm_staging, \
"staging tmpdir must be the first PYTHONPATH entry"
assert norm_venv not in norm_parts, \
"inherited Hermes venv site-packages must be stripped"
assert norm_user_a in norm_parts and norm_user_b in norm_parts, \
"user PYTHONPATH entries must survive"
assert norm_parts.index(norm_user_a) > norm_parts.index(norm_staging), \
"user entries must come after the staging tmpdir"
if same_env:
assert norm_parts.count(norm_root) == 1, \
"repo root must be re-added exactly once for a same-env child"
assert norm_parts.index(norm_user_a) > norm_parts.index(norm_root), \
"user entries must come after the re-added repo root"
else:
assert norm_root not in norm_parts, \
"repo root must stay absent for an external-env child"
def test_repo_root_direct_child_preserved(self):
"""A direct child of the repo root (depth=1) is PRESERVED.
Independent audit of every real launcher producer (Electron
``apps/desktop/electron/main.ts``,
``gateway/run.py::_ensure_windows_gateway_venv_imports``,
``cron/scheduler.py::_windows_cron_python_invocation``,
``tui_gateway/host_supervisor.py``) shows they all inject the exact
repo root and/or the venv site-packages — none injects
``<repo>/tools`` or another direct child as an independent
PYTHONPATH entry. A user path that merely happens to live under
the repo directory must therefore be preserved.
"""
from tools.environments.local import _strip_hermes_owned_pythonpath
local_file = Path(__import__("tools.environments.local", fromlist=["__file__"]).__file__).resolve()
real_repo_root = local_file.parents[2]
direct_child = str(real_repo_root / "tools")
env = {
"PYTHONPATH": os.pathsep.join([direct_child, "/home/user/my-lib"]),
}
_strip_hermes_owned_pythonpath(env)
pp = env.get("PYTHONPATH", "")
entries = pp.split(os.pathsep) if pp else []
assert direct_child in entries
assert "/home/user/my-lib" in entries
def test_configured_home_alias_matches_launcher_output(self, tmp_path, monkeypatch):
"""The real producer spelling is derived and consumed end to end."""
import tools.environments.local as local
from hermes_cli.gateway_windows import _preserve_hermes_home_path
physical_home = tmp_path / "physical-home"
physical_root = _physical_repo_root(tmp_path)
configured_home = tmp_path / "configured-home"
try:
_make_directory_link(configured_home, physical_home)
except OSError as exc:
pytest.skip(f"directory link unavailable on this host: {exc}")
monkeypatch.setenv("HERMES_HOME", str(configured_home))
launcher_entry = Path(_preserve_hermes_home_path(physical_root))
aliases = local._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
assert launcher_entry == configured_home / "hermes-agent"
assert launcher_entry in aliases
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
nested_user_path = launcher_entry / "user-data"
env = {
"PYTHONPATH": os.pathsep.join([
str(launcher_entry),
str(nested_user_path),
"/home/user/my-lib",
])
}
local._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == [
str(nested_user_path),
"/home/user/my-lib",
]
def test_profile_rehome_keeps_junction_lexical_alias(self, tmp_path, monkeypatch):
"""Profile re-home must not lose the launcher's lexical repo-root spelling.
The desktop/CLI spawn children with HERMES_HOME and PYTHONPATH in the
configured (junction) spelling, but --profile / sticky active_profile
re-home HERMES_HOME through resolve_profile_env() before the
sanitizer loads. Regression (junction + profile re-home): the alias
builder must still recover the lexical root so the inherited lexical
repo-root entry is stripped.
"""
import tools.environments.local as local
from hermes_cli.profiles import resolve_profile_env
physical_home = tmp_path / "physical-home"
physical_root = physical_home / "hermes-agent"
physical_root.mkdir(parents=True)
(physical_home / "profiles" / "coder").mkdir(parents=True)
configured_home = tmp_path / "configured-home"
try:
_make_directory_link(configured_home, physical_home)
except OSError as exc:
pytest.skip(f"directory link unavailable on this host: {exc}")
# Launcher contract: the configured spelling is the env and the root.
monkeypatch.setenv("HERMES_HOME", str(configured_home))
lexical_root = configured_home / "hermes-agent"
# Profile re-home keeps the configured spelling (physically identical
# through the link; lexically the launcher spelling is preserved).
assert Path(resolve_profile_env("default")) == configured_home
assert Path(resolve_profile_env("coder")) == configured_home / "profiles" / "coder"
# The sanitizer now runs under the re-homed (profile) HERMES_HOME.
aliases = local._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home / "profiles" / "coder",
)
assert any(local._same_path(a, lexical_root) for a in aliases)
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
env = {"PYTHONPATH": os.pathsep.join([str(lexical_root), "/home/user/my-lib"])}
local._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]
def test_repo_level_junction_recovers_lexical_alias(self, tmp_path, monkeypatch):
"""The repo itself may be a junction under the configured root
(e.g. D:\\hermes\\hermes-agent -> C:\\...\\hermes-agent) while the
editable import spelling resolves to the physical location. The
alias builder must recover the lexical spelling via exact-identity
proof (strict resolve), not a name-based guess.
"""
import tools.environments.local as local
physical_root = _physical_repo_root(tmp_path)
configured_home = tmp_path / "configured-home"
configured_home.mkdir()
# repo-level link: <configured-home>/hermes-agent -> physical repo
try:
_make_directory_link(configured_home / "hermes-agent", physical_root)
except OSError as exc:
pytest.skip(f"directory link unavailable on this host: {exc}")
lexical_root = configured_home / "hermes-agent"
aliases = local._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
assert any(local._same_path(a, lexical_root) for a in aliases)
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
env = {"PYTHONPATH": os.pathsep.join([str(lexical_root), "/home/user/my-lib"])}
local._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]
def test_same_named_non_owned_directories_preserved(self, tmp_path, monkeypatch):
"""Negative controls: a directory that merely shares the repo's name
-- whether under the configured root or in an unrelated location --
is never aliased or stripped. Exact filesystem identity decides,
not the name; no ownership provenance means no strip.
"""
import tools.environments.local as local
physical_root = _physical_repo_root(tmp_path)
configured_home = tmp_path / "configured-home"
(configured_home / "hermes-agent").mkdir(parents=True)
unrelated = tmp_path / "user-tools" / "hermes-agent"
unrelated.mkdir(parents=True)
aliases = local._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
for lookalike in (configured_home / "hermes-agent", unrelated):
assert not any(local._same_path(a, lookalike) for a in aliases)
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
for lookalike in (configured_home / "hermes-agent", unrelated):
env = {"PYTHONPATH": os.pathsep.join([str(lookalike), "/home/user/my-lib"])}
local._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == [str(lookalike), "/home/user/my-lib"]
def test_profile_home_with_repo_level_junction(self, tmp_path, monkeypatch):
"""Profile re-home + repo-level junction together: the configured home
is <root>/profiles/<name> while the repo is a link at <root>/hermes-agent.
The root spelling must be derived (profiles -> grandparent) and then
the lexical repo alias recovered from it.
"""
import tools.environments.local as local
physical_root = _physical_repo_root(tmp_path)
configured_root = tmp_path / "configured-root"
(configured_root / "profiles" / "coder").mkdir(parents=True)
try:
_make_directory_link(configured_root / "hermes-agent", physical_root)
except OSError as exc:
pytest.skip(f"directory link unavailable on this host: {exc}")
configured_home = configured_root / "profiles" / "coder"
lexical_root = configured_root / "hermes-agent"
aliases = local._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
assert any(local._same_path(a, lexical_root) for a in aliases)
assert not any(local._same_path(a, configured_home / "hermes-agent") for a in aliases)
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
env = {"PYTHONPATH": os.pathsep.join([str(lexical_root), "/home/user/my-lib"])}
local._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]
def test_validated_runtime_venv_lexical_after_repo_recovery(self, tmp_path, monkeypatch):
"""uv-base gateway: once the lexical repo alias is recovered, a lexical
VIRTUAL_ENV (<lexical repo>/venv) validates and its site-packages is
stripped together with the repo root, while user entries survive.
"""
import tools.environments.local as local
physical_root = _physical_repo_root(tmp_path)
venv_dir = physical_root / "venv"
venv_dir.mkdir(parents=True)
(venv_dir / "pyvenv.cfg").write_text("home = x\n", encoding="utf-8")
configured_home = tmp_path / "configured-home"
configured_home.mkdir()
try:
_make_directory_link(configured_home / "hermes-agent", physical_root)
except OSError as exc:
pytest.skip(f"directory link unavailable on this host: {exc}")
lexical_root = configured_home / "hermes-agent"
aliases = local._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
assert any(local._same_path(a, lexical_root) for a in aliases)
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
lexical_venv = lexical_root / "venv"
validated = local._validated_runtime_venv({"VIRTUAL_ENV": str(lexical_venv)})
assert validated is not None
assert local._same_path(validated, lexical_venv)
local._hermes_site_packages = None
env = {"PYTHONPATH": os.pathsep.join([
str(lexical_root),
str(lexical_venv / "Lib" / "site-packages"),
"/home/user/my-lib",
]), "VIRTUAL_ENV": str(lexical_venv)}
local._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]
class TestPythonhomeSanitized:
"""PYTHONHOME must not leak from the Hermes runtime into subprocesses.
The gateway inherits/sets PYTHONHOME in its process environment; a child
interpreter (system Python, another venv, cron no_agent scripts) that
inherits it redirects its stdlib search to the Hermes venv and crashes
with version-mismatch errors before importing anything (#75018).
"""
@pytest.mark.parametrize("builder", [
"_make_run_env",
"_sanitize_subprocess_env",
"hermes_subprocess_env",
"build_subprocess_env",
])
def test_builders_strip_pythonhome(self, builder):
"""The gateway's inherited PYTHONHOME must not reach any subprocess
builder -- terminal, background/PTY, cron no_agent scripts, and
execute_code children (#75018).
"""
from tools.environments import local as local_mod
seed = {
"PATH": "/usr/bin:/bin",
"HOME": "/home/user",
"PYTHONHOME": "/opt/hermes-venv",
}
with patch.dict(os.environ, seed, clear=True):
if builder == "_make_run_env":
result = local_mod._make_run_env({})
elif builder == "_sanitize_subprocess_env":
result = local_mod._sanitize_subprocess_env(dict(os.environ))
elif builder == "hermes_subprocess_env":
result = local_mod.hermes_subprocess_env()
else:
result = local_mod.build_subprocess_env()
assert "PYTHONHOME" not in result
def test_pythonhome_removed_from_active_venv_markers(self):
"""PYTHONHOME is part of _ACTIVE_VENV_MARKER_VARS so all builders
that iterate it drop the variable."""
from tools.environments.local import _ACTIVE_VENV_MARKER_VARS
assert "PYTHONHOME" in _ACTIVE_VENV_MARKER_VARS
def test_build_subprocess_env_no_scrub_preserves_pythonhome(self):
"""``build_subprocess_env(scrub_secrets=False)`` is the documented
byte-for-byte escape hatch: no key is removed, so PYTHONHOME (and
everything else) survives there by contract, not by omission.
Callers that explicitly opt out of scrubbing (git credential flows,
secret CLIs) must not have their environment silently altered — this
test pins that exception as intentional.
"""
from tools.environments.local import build_subprocess_env
base = {
"PATH": "/usr/bin:/bin",
"HOME": "/home/user",
"PYTHONHOME": "/opt/hermes-venv",
"VIRTUAL_ENV": "/opt/hermes-venv",
"SERVICE_TOKEN": "s3cr3t",
}
result = build_subprocess_env(base, scrub_secrets=False)
assert result.get("PYTHONHOME") == "/opt/hermes-venv"
assert result.get("VIRTUAL_ENV") == "/opt/hermes-venv"
assert result.get("SERVICE_TOKEN") == "s3cr3t"
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, monkeypatch):
"""When PATH is minimal, _make_run_env appends missing sane entries.
POSIX: the sane-path merge appends the Homebrew dirs. Windows:
_append_missing_sane_path_entries is a documented passthrough (the
native PATH must not be touched), so the assertion is the unchanged
input. Git Bash dir prepending is neutralised so the merged PATH
layout is deterministic on every host.
"""
from tools.environments import local as local_mod
from tools.environments.local import _SANE_PATH, _make_run_env
monkeypatch.setattr(local_mod, "_git_bash_bin_dirs", lambda: [])
minimal_env = {"PATH": "/some/custom/bin"}
with patch.dict(os.environ, minimal_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(os.pathsep)
assert path_entries[0] == "/some/custom/bin"
if sys.platform == "win32":
assert result["PATH"] == "/some/custom/bin"
else:
for entry in _SANE_PATH.split(os.pathsep):
assert entry in path_entries
@pytest.mark.macos_only
def test_make_run_env_real_launchd_path_gains_homebrew(self):
"""The literal macOS launchd PATH is the production trigger for #35613.
macOS-only: the regression is the launchd environment on macOS, and
the sane-path merge is a documented passthrough on Windows.
"""
from tools.environments.local import _make_run_env
launchd_env = {"PATH": os.pathsep.join(["/usr/bin", "/bin", "/usr/sbin", "/sbin"])}
with patch.dict(os.environ, launchd_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(os.pathsep)
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": os.pathsep.join(["/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