Files
hermes-agent/tests/tools/test_local_env_blocklist.py
T
kshitijk4poor 3a6406137d fix(child-env): per-profile plugin secrets, fail-closed declaration scans, dashboard auth in Tier 1
Review follow-ups on the declared-secret policy:

- A profile's user-installed platform plugins declared secrets into
  one process-wide set, read from the launch home at import. Under
  multiplex that stripped profile B's same-named user variable and
  missed profile A's own declarations. Bundled manifests stay
  process-wide; user manifests are now read per bound home, cached
  until that home's plugin dirs change, and are Tier 1 for that
  profile only.
- The declaration scans no longer fail open. A registry error is no
  longer swallowed into an empty set, non-string required_env entries
  are skipped rather than breaking the set, and a manifest that
  cannot be read raises instead of vanishing from the policy. A
  malformed manifest still declares nothing.
- A plugin manifest can no longer reclassify a core-declared name
  such as OPENAI_API_KEY; the same rule the config form applies.
- The dashboard basic-auth password and signing secret, the OIDC
  client secret and the drain bearer move to Tier 1. Credentialed
  CLIs (claude, codex) no longer receive them.
- openviking-server starts from served_profile_child_env, so it gets
  the bound profile's provider keys, never the launch profile's.
  With no bound profile under multiplex the start is refused.
- NOUS_API_KEY and QWEN_API_KEY are listed statically. Discovering
  provider plugins while the policy module imported re-mirrored them
  over a plugin's own auth registry entry
  (tests/providers/test_auth_registry_import_order.py).
2026-09-29 02:06:58 +05:30

1423 lines
67 KiB
Python

"""Child-observed terminal policy, profile lifetime and PYTHONPATH provenance."""
import json
import os
import subprocess
import sys
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
from tests.tools._child_env_fixtures import child_env, observe_child, observe_terminal # noqa: F401
from tools.environments import local
from tools.environments import local_pythonpath as pp
from tools.environments.local_env_policy import _HERMES_PROVIDER_ENV_BLOCKLIST
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"
return Path(sys.prefix) / "lib" / f"python{sys.version_info[0]}.{sys.version_info[1]}" / "site-packages"
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
# Expectations come from independent provider/config declarations and literal
# policy examples, never the finished blocklist or a test-owned scrubber.
STATIC_BLOCKED = """
OPENAI_BASE_URL OPENAI_API_KEY OPENAI_API_BASE OPENAI_ORG_ID OPENAI_ORGANIZATION
OPENROUTER_API_KEY ANTHROPIC_BASE_URL ANTHROPIC_API_KEY ANTHROPIC_TOKEN LLM_MODEL
VERTEX_CREDENTIALS_PATH GOOGLE_APPLICATION_CREDENTIALS AWS_BEARER_TOKEN_BEDROCK
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
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
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD HERMES_DASHBOARD_BASIC_AUTH_SECRET HERMES_DASHBOARD_DRAIN_SECRET
HERMES_DASHBOARD_OIDC_CLIENT_SECRET HERMES_ANON_API_SECRET HERMES_MEET_REALTIME_KEY
GATEWAY_ALLOWED_USERS GATEWAY_ALLOW_ALL_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 GATEWAY_RELAY_ID
AUXILIARY_VISION_API_KEY AUXILIARY_WEB_EXTRACT_API_KEY AUXILIARY_APPROVAL_API_KEY
AUXILIARY_MY_PLUGIN_TASK_API_KEY AUXILIARY_VISION_BASE_URL AUXILIARY_COMPRESSION_BASE_URL
GATEWAY_RELAY_SECRET GATEWAY_RELAY_DELIVERY_KEY GATEWAY_RELAY_SESSION_TOKEN
""".split()
OPERATOR_ALLOWED = """
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
CLAUDE_CODE_OAUTH_TOKEN AUXILIARY_VISION_PROVIDER AUXILIARY_VISION_MODEL GATEWAY_RELAY_URL
GATEWAY_RELAY_PLATFORMS MY_APP_KEY MY_CUSTOM_VAR
""".split()
def _running_site():
return Path(sys.prefix) / ("Lib/site-packages" if os.name == "nt" else
f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
def test_terminal_child_observes_declared_policy(child_env, monkeypatch):
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.config import OPTIONAL_ENV_VARS
blocked = set(STATIC_BLOCKED)
for config in PROVIDER_REGISTRY.values():
blocked.update(config.api_key_env_vars)
if config.base_url_env_var:
blocked.add(config.base_url_env_var)
blocked.update(name for name, meta in OPTIONAL_ENV_VARS.items()
if meta.get("category") in {"tool", "messaging"}
or (meta.get("category") == "setting" and meta.get("password")))
blocked.discard("CLAUDE_CODE_OAUTH_TOKEN") # operator's subscription, not Hermes inference
for name in blocked | set(OPERATOR_ALLOWED):
monkeypatch.setenv(name, "fake-" + name)
before = dict(os.environ)
env = local.LocalEnvironment(cwd=str(child_env), timeout=30,
env={"OPENAI_BASE_URL": "fake-override", "MY_CUSTOM_VAR": "caller-value"})
try:
observed = observe_terminal(env, sorted(blocked | set(OPERATOR_ALLOWED)))
finally:
env.cleanup()
assert observed == {**dict.fromkeys(blocked), **{k: "fake-" + k for k in OPERATOR_ALLOWED},
"MY_CUSTOM_VAR": "caller-value"}
assert dict(os.environ) == before
# Secrets adapters read straight from the environment (webhook signing secrets, access tokens,
# app secrets), declared in the gateway env-override table, a plugin manifest, or the policy's
# own short list, none of them among the hand-written OPTIONAL_ENV_VARS entries.
ADAPTER_SECRETS = """
TEAMS_INCOMING_WEBHOOK_URL WHATSAPP_CLOUD_ACCESS_TOKEN WHATSAPP_CLOUD_APP_SECRET WHATSAPP_CLOUD_VERIFY_TOKEN WEIXIN_TOKEN
YUANBAO_APP_SECRET FEISHU_ENCRYPT_KEY FEISHU_VERIFICATION_TOKEN TELEGRAM_WEBHOOK_SECRET
PHOTON_SIDECAR_TOKEN A2A_PUSH_SECRET TEAMS_GRAPH_ACCESS_TOKEN QQ_STT_API_KEY
MSGRAPH_WEBHOOK_CLIENT_STATE MSGRAPH_CLIENT_SECRET
""".split()
# The user's own credentials that merely start with a platform name. Hermes never reads them, so
# they reach the terminal like any other variable, and passthrough can forward them.
OPERATOR_SECRETS = ["MY_APP_KEY", "DEPLOY_WEBHOOK_SECRET", "SLACK_USER_TOKEN", "LOCAL_LLM_API_KEY",
"GATEWAY_API_KEY"]
@pytest.mark.parametrize("builder", ["foreground", "background", "nonterminal"])
def test_adapter_and_provider_profile_secrets_never_reach_children(child_env, monkeypatch, builder):
from providers import list_providers
from tools.env_passthrough import is_env_passthrough, register_env_passthrough
secrets = set(ADAPTER_SECRETS)
# child_env's HERMES_HOME has no provider plugins, so these are the bundled profiles.
secrets.update(name for profile in list_providers() for name in (profile.env_vars or ()))
secrets.discard("CLAUDE_CODE_OAUTH_TOKEN") # operator's subscription, not Hermes inference
for name in [*secrets, *OPERATOR_SECRETS]:
monkeypatch.setenv(name, "fake-" + name)
factories = {
"foreground": lambda: local._make_run_env({}),
"background": lambda: local._sanitize_subprocess_env(dict(os.environ)),
"nonterminal": local.hermes_subprocess_env,
}
observed = observe_child(factories[builder](), sorted(secrets | set(OPERATOR_SECRETS)))
assert observed == {**dict.fromkeys(secrets), **{k: "fake-" + k for k in OPERATOR_SECRETS}}
# Skill passthrough is what forwards a name into docker/ssh/modal and execute_code children.
register_env_passthrough(sorted(secrets | set(OPERATOR_SECRETS)))
assert not any(is_env_passthrough(name) for name in secrets)
assert all(is_env_passthrough(name) for name in OPERATOR_SECRETS)
def _user_platform_plugin(home, name, secret):
plugin = home / "plugins" / "platforms" / name
plugin.mkdir(parents=True)
(plugin / "plugin.yaml").write_text(
f"name: {name}\nkind: platform\nrequires_env:\n - name: {secret}\n password: true\n",
encoding="utf-8")
def test_user_platform_plugin_secrets_belong_to_their_own_profile(child_env, monkeypatch):
"""A profile's user-installed platform plugin declares secrets for that profile only: bound to
it, the name is stripped from every child and refused for passthrough; bound to a sibling
profile, the sibling's own same-named value is its user variable and reaches its children."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from tools.env_passthrough import is_env_passthrough, register_env_passthrough
a, b = child_env / "profiles" / "a", child_env / "profiles" / "b"
_user_platform_plugin(a, "chatx", "CHATX_SIGNING_SECRET")
b.mkdir(parents=True)
monkeypatch.setenv("CHATX_SIGNING_SECRET", "fake-value")
register_env_passthrough(["CHATX_SIGNING_SECRET"])
seen = {}
for home in (a, b):
token = set_hermes_home_override(home)
try:
seen[home.name] = (
"CHATX_SIGNING_SECRET" in local.hermes_subprocess_env(inherit_credentials=True),
"CHATX_SIGNING_SECRET" in local._make_run_env({}),
is_env_passthrough("CHATX_SIGNING_SECRET"))
finally:
reset_hermes_home_override(token)
assert seen == {"a": (False, False, False), "b": (True, True, True)}
@pytest.mark.skipif(os.name == "nt" or os.geteuid() == 0, # windows-footgun: ok — short-circuits on nt
reason="needs POSIX permissions as non-root")
def test_unreadable_platform_manifest_fails_closed(tmp_path):
"""A manifest that cannot be read might declare secrets, so the policy scan raises rather than
returning a set without them. A malformed one declares nothing and is skipped."""
from hermes_cli.config import platform_manifest_secret_envs
_user_platform_plugin(tmp_path, "chatx", "CHATX_SIGNING_SECRET")
broken = tmp_path / "plugins" / "platforms" / "broken"
broken.mkdir()
(broken / "plugin.yaml").write_text("name: [unclosed\n", encoding="utf-8")
assert platform_manifest_secret_envs(tmp_path, bundled=False, strict=True) == {"CHATX_SIGNING_SECRET"}
manifest = tmp_path / "plugins" / "platforms" / "chatx" / "plugin.yaml"
manifest.chmod(0)
try:
with pytest.raises(PermissionError):
platform_manifest_secret_envs(tmp_path, bundled=False, strict=True)
finally:
manifest.chmod(0o644)
def test_inheriting_child_gets_provider_keys_but_never_adapter_secrets(child_env, monkeypatch):
# inherit_credentials is the narrow grant for model-driving CLIs: provider keys only.
granted = ["OPENAI_API_KEY", "NOUS_API_KEY", *OPERATOR_SECRETS]
for name in [*ADAPTER_SECRETS, *granted]:
monkeypatch.setenv(name, "fake-" + name)
observed = observe_child(local.hermes_subprocess_env(inherit_credentials=True),
sorted([*ADAPTER_SECRETS, *granted]))
assert observed == {**dict.fromkeys(ADAPTER_SECRETS), **{k: "fake-" + k for k in granted}}
def test_runtime_adapter_secret_follows_the_bound_profiles_registry(child_env, monkeypatch):
from gateway.platform_registry import PlatformEntry, platform_registry
from tools.env_passthrough import is_env_passthrough, register_env_passthrough
home_a, home_b = child_env / "hermes", child_env / "profile-b"
home_a.mkdir(exist_ok=True)
home_b.mkdir()
name = "ACME_CHAT_SIGNING_SECRET" # declared by a user adapter; no OPTIONAL_ENV_VARS entry
monkeypatch.setenv(name, "fake-secret")
monkeypatch.setenv("MY_APP_KEY", "operator")
scope_a = platform_registry.current_scope_key()
# Registered after the policy module was imported, in profile A only.
platform_registry.register(PlatformEntry(name="acme-chat", label="Acme", adapter_factory=lambda c: None,
check_fn=lambda: True, required_env=[name]), scope=scope_a)
def child_sees():
return observe_child(local.hermes_subprocess_env(), [name, "MY_APP_KEY"])
try:
assert child_sees() == {name: None, "MY_APP_KEY": "operator"}
register_env_passthrough([name])
assert not is_env_passthrough(name)
monkeypatch.setenv("HERMES_HOME", str(home_b)) # B has no such adapter: operator-owned
assert child_sees() == {name: "fake-secret", "MY_APP_KEY": "operator"}
monkeypatch.setenv("HERMES_HOME", str(home_a))
assert child_sees() == {name: None, "MY_APP_KEY": "operator"}
finally:
platform_registry.unregister("acme-chat", scope=scope_a)
assert child_sees() == {name: "fake-secret", "MY_APP_KEY": "operator"}
@pytest.mark.parametrize("declared_by", ["skill", "config"])
def test_passthrough_accepted_before_an_adapter_owns_the_name_stops_forwarding_it(
child_env, monkeypatch, declared_by):
from agent import secret_scope as ss
from gateway.platform_registry import PlatformEntry, platform_registry
from tools.code_execution_env import _scrub_child_env
from tools.env_passthrough import register_env_passthrough
name, names = "ACME_CHAT_SIGNING_SECRET", ["ACME_CHAT_SIGNING_SECRET", "MY_APP_KEY"]
monkeypatch.setenv(name, "fake-secret")
monkeypatch.setenv("MY_APP_KEY", "operator")
if declared_by == "skill":
register_env_passthrough(names)
else:
home = child_env / "hermes"
home.mkdir(exist_ok=True)
(home / "config.yaml").write_text("terminal:\n env_passthrough: [%s]\n" % ", ".join(names), encoding="utf-8")
builders = [lambda: local._make_run_env({}), lambda: local._sanitize_subprocess_env(dict(os.environ)),
lambda: _scrub_child_env(dict(os.environ))]
assert [observe_child(b(), names) for b in builders] == [
{name: "fake-secret", "MY_APP_KEY": "operator"}] * 3 # accepted while operator-owned
scope = platform_registry.current_scope_key()
platform_registry.register(PlatformEntry(name="acme-chat", label="Acme", adapter_factory=lambda c: None,
check_fn=lambda: True, required_env=[name]), scope=scope)
try:
assert [observe_child(b(), names) for b in builders] == [{name: None, "MY_APP_KEY": "operator"}] * 3
# A routed profile's value lives only in its scope; the stale declaration must not add it back.
monkeypatch.delenv(name)
token = ss.set_secret_scope({name: "scoped-secret", "MY_APP_KEY": "scoped-operator"})
try:
assert [observe_child(b(), names) for b in (builders[0], builders[2])] == [
{name: None, "MY_APP_KEY": "scoped-operator"}] * 2
finally:
ss.reset_secret_scope(token)
finally:
platform_registry.unregister("acme-chat", scope=scope)
@pytest.mark.parametrize("builder", ["foreground", "background", "factory", "nonterminal"])
def test_builders_strip_runtime_markers_and_owned_paths(child_env, monkeypatch, builder):
repo, site = Path(__file__).resolve().parents[2], _running_site()
user_path = str(child_env / "user-lib")
for k, v in {"VIRTUAL_ENV": "/unrelated/venv", "CONDA_PREFIX": "/unrelated/conda",
"PYTHONHOME": "/nonexistent/python-home",
"PYTHONPATH": os.pathsep.join([str(repo), str(site), user_path])}.items():
monkeypatch.setenv(k, v)
factories = {
"foreground": lambda: local._make_run_env({}),
"background": lambda: local._sanitize_subprocess_env(dict(os.environ), {"VIRTUAL_ENV": "/extra/venv"}),
"factory": local.build_subprocess_env,
"nonterminal": local.hermes_subprocess_env,
}
before = dict(os.environ)
actual = observe_child(factories[builder](), ["VIRTUAL_ENV", "CONDA_PREFIX", "PYTHONHOME", "PYTHONPATH", "HOME"])
assert actual == {"VIRTUAL_ENV": None, "CONDA_PREFIX": None, "PYTHONHOME": None,
"PYTHONPATH": user_path, "HOME": str(child_env)}
assert dict(os.environ) == before
@pytest.mark.parametrize("builder,base_force,extra_force", [
("foreground", "base-forced", "extra-forced"),
("background", None, "extra-forced"),
("factory", None, "extra-forced"),
("nonterminal", None, None),
])
def test_force_prefix_is_not_plugin_passthrough(child_env, monkeypatch, builder, base_force, extra_force):
from tools.env_passthrough import register_env_passthrough, is_env_passthrough
register_env_passthrough(["OPENAI_API_KEY", "AUXILIARY_VISION_API_KEY", "SERVICE_TOKEN"])
assert not is_env_passthrough("OPENAI_API_KEY")
assert not is_env_passthrough("AUXILIARY_VISION_API_KEY")
assert is_env_passthrough("SERVICE_TOKEN")
monkeypatch.setenv("OPENAI_API_KEY", "fake-parent")
monkeypatch.setenv("_HERMES_FORCE_OPENAI_API_KEY", "base-forced")
extra = {"_HERMES_FORCE_OPENAI_BASE_URL": "extra-forced",
"_HERMES_FORCE_AUXILIARY_VISION_API_KEY": "never-forward",
"AUXILIARY_VISION_API_KEY": "never-forward", "MY_CUSTOM_VAR": "caller-value"}
factories = {
"foreground": lambda: local._make_run_env(extra),
"background": lambda: local._sanitize_subprocess_env(dict(os.environ), extra),
"factory": lambda: local.build_subprocess_env(extra=extra),
"nonterminal": lambda: local.hermes_subprocess_env(base_env={**os.environ, **extra}),
}
result = factories[builder]()
assert result.get("OPENAI_API_KEY") == base_force
assert result.get("OPENAI_BASE_URL") == extra_force
assert result["MY_CUSTOM_VAR"] == "caller-value"
assert "AUXILIARY_VISION_API_KEY" not in result
assert not any(k.startswith("_HERMES_FORCE_") for k in result)
# Even a buggy plugin hook cannot bypass dynamic-secret exclusion.
with patch("tools.env_passthrough.is_env_passthrough", return_value=True):
assert "AUXILIARY_VISION_API_KEY" not in factories[builder]()
@pytest.mark.parametrize("managed,platform,allowed", [(True, "telegram", True), (False, "buzz", True), (False, "telegram", False)])
@pytest.mark.parametrize("scope", [None, {"BUZZ_PRIVATE_KEY": "fake-scoped"}])
def test_buzz_context_and_plain_process_value(child_env, monkeypatch, managed, platform, allowed, scope):
from agent import secret_scope as ss
from gateway.session_context import _SESSION_PLATFORM
from tools.code_execution_env import _scrub_child_env
from tools.env_passthrough import register_env_passthrough, is_env_passthrough
buzz = {"BUZZ_PRIVATE_KEY": "fake-process", "BUZZ_AUTH_TAG": "fake-tag", "BUZZ_RELAY_URL": "fake-relay"}
for k, v in buzz.items():
monkeypatch.setenv(k, v)
if managed:
monkeypatch.setenv("BUZZ_MANAGED_AGENT", "1")
platform_token = _SESSION_PLATFORM.set(platform)
ss.set_multiplex_active(True)
scope_token = ss.set_secret_scope(scope) if scope is not None else None
try:
register_env_passthrough(buzz)
assert not any(is_env_passthrough(k) for k in buzz)
for result in (local._make_run_env({}), local._sanitize_subprocess_env(dict(os.environ))):
assert {k: result.get(k) for k in buzz} == (buzz if allowed else dict.fromkeys(buzz))
for result in (local.hermes_subprocess_env(), _scrub_child_env(os.environ)):
assert not set(buzz) & result.keys()
finally:
if scope_token is not None:
ss.reset_secret_scope(scope_token)
ss.set_multiplex_active(False)
_SESSION_PLATFORM.reset(platform_token)
@pytest.mark.platforms("linux", "macos", "windows")
@pytest.mark.parametrize("first_platform,first_value", [("buzz", "fake-profile-a"), ("telegram", None)], ids=["buzz", "other"])
def test_buzz_secret_never_reaches_second_profile_via_snapshot(child_env, monkeypatch, first_platform, first_value):
from agent import secret_scope as ss
from gateway.session_context import _SESSION_PLATFORM
monkeypatch.setenv("BUZZ_PRIVATE_KEY", "fake-profile-a")
ss.set_multiplex_active(True)
platform = _SESSION_PLATFORM.set(first_platform)
env = None
try:
env = local.LocalEnvironment(cwd=str(child_env), timeout=30)
snap = Path(env._snapshot_path)
assert snap.exists()
assert "BUZZ_PRIVATE_KEY" not in snap.read_text(encoding="utf-8")
assert observe_terminal(env, ["BUZZ_PRIVATE_KEY"]) == {"BUZZ_PRIVATE_KEY": first_value}
assert "fake-profile-a" not in snap.read_text(encoding="utf-8")
monkeypatch.delenv("BUZZ_PRIVATE_KEY")
_SESSION_PLATFORM.reset(platform)
platform = _SESSION_PLATFORM.set("telegram")
assert observe_terminal(env, ["BUZZ_PRIVATE_KEY"]) == {"BUZZ_PRIVATE_KEY": None}
assert "BUZZ_PRIVATE_KEY" in env._snapshot_passthrough_names
assert "BUZZ_PRIVATE_KEY" not in snap.read_text(encoding="utf-8")
finally:
if env is not None:
env.cleanup()
_SESSION_PLATFORM.reset(platform)
ss.set_multiplex_active(False)
@pytest.mark.parametrize("scoped,expected", [({"SERVICE_TOKEN": "fake-routed"}, "fake-routed"), ({}, None)])
def test_profile_passthrough_in_terminal_child(child_env, monkeypatch, scoped, expected):
from agent import secret_scope as ss
from tools.env_passthrough import register_env_passthrough
register_env_passthrough(["SERVICE_TOKEN"])
monkeypatch.setenv("SERVICE_TOKEN", "fake-default")
ss.set_multiplex_active(True)
token = ss.set_secret_scope(scoped)
env = None
try:
env = local.LocalEnvironment(cwd=str(child_env), timeout=30)
assert observe_terminal(env, ["SERVICE_TOKEN"]) == {"SERVICE_TOKEN": expected}
result = local._sanitize_subprocess_env(dict(os.environ))
assert result.get("SERVICE_TOKEN") == expected
finally:
if env is not None:
env.cleanup()
ss.reset_secret_scope(token)
ss.set_multiplex_active(False)
@pytest.mark.parametrize("entries,expected", [
(["SITE", "/user/lib"], ["/user/lib"]),
(["REPO", "/user/lib"], ["/user/lib"]),
(["SITE", "/user/lib", "SITE", "/user/lib"], ["/user/lib", "/user/lib"]),
(["SITE", "REPO"], None),
(["/first", "REPO", "", "SITE", "/last"], ["/first", "", "/last"]),
([" /opt/user-lib ", "relative/../lib", "", "/opt/user-lib", "/opt/user-lib"],
[" /opt/user-lib ", "relative/../lib", "", "/opt/user-lib", "/opt/user-lib"]),
(["/nix/store/user-plugin/lib/python3.12/site-packages", "/old/lib/python2.7/site-packages"],
["/nix/store/user-plugin/lib/python3.12/site-packages", "/old/lib/python2.7/site-packages"]),
(["/opt/tools/python3.13/bin", "/opt/downloads/python3.13", "/custom/python3.13"],
["/opt/tools/python3.13/bin", "/opt/downloads/python3.13", "/custom/python3.13"]),
([""], [""]),
(None, None),
])
def test_pythonpath_literal_policy(entries, expected):
locations = {"REPO": str(Path(__file__).resolve().parents[2]), "SITE": str(_running_site())}
env = {} if entries is None else {"PYTHONPATH": os.pathsep.join(locations.get(p, p) for p in entries)}
pp._strip_hermes_owned_pythonpath(env)
assert env.get("PYTHONPATH") == (os.pathsep.join(expected) if expected is not None else None)
def test_pythonpath_descendants_are_not_owned():
repo, site = Path(__file__).resolve().parents[2], _running_site()
entries = [str(site / "user-path"), str(repo / "tools"), str(repo / "tools/environments"),
"/opt/other-venv/lib/python3.99/site-packages"]
env = {"PYTHONPATH": os.pathsep.join(entries)}
pp._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == entries
@pytest.mark.platforms("linux", "macos", "windows")
@pytest.mark.parametrize("link_at", ["home", "repo", "unrelated"])
@pytest.mark.parametrize("profile", [False, True])
def test_launcher_alias_provenance(child_env, monkeypatch, link_at, profile):
from hermes_cli.gateway_windows import _preserve_hermes_home_path
from hermes_cli.profiles import resolve_profile_env
physical_home = child_env / "physical-home"
physical_root = physical_home / "hermes-agent"
physical_root.mkdir(parents=True)
configured = child_env / "configured-home"
if link_at == "home":
_make_directory_link(configured, physical_home)
else:
configured.mkdir()
if link_at == "repo":
_make_directory_link(configured / "hermes-agent", physical_root)
else:
(configured / "hermes-agent").mkdir()
(configured / "profiles/coder").mkdir(parents=True)
(configured / "profiles/coder/config.yaml").write_text("{}\n", encoding="utf-8")
unrelated = child_env / "user-tools/hermes-agent"
unrelated.mkdir(parents=True)
lexical_root = configured / "hermes-agent"
monkeypatch.setenv("HERMES_HOME", str(configured))
assert Path(resolve_profile_env("default")) == configured
assert Path(resolve_profile_env("coder")) == configured / "profiles/coder"
if link_at == "home":
assert Path(_preserve_hermes_home_path(physical_root)) == lexical_root
active_home = configured / "profiles/coder" if profile else configured
aliases = pp._build_hermes_repo_root_aliases(physical_root.resolve(), physical_root, active_home)
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
nested = lexical_root / "user-data"
entries = [str(lexical_root), str(nested), str(unrelated), str(active_home / "not-the-repo")]
env = {"PYTHONPATH": os.pathsep.join(entries)}
pp._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == (entries if link_at == "unrelated" else entries[1:])
if profile:
assert active_home / "hermes-agent" not in aliases
@pytest.mark.parametrize("has_facts", [True, False])
def test_runtime_provenance_is_independent_of_aliases_and_virtual_env(child_env, monkeypatch, has_facts):
from pm.environments import runtime_facts_path
payload = child_env / "payload"
runtime = payload / "state/environments/candidate/venv"
site = runtime / ("Lib/site-packages" if os.name == "nt" else
f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
site.mkdir(parents=True)
(runtime / "pyvenv.cfg").write_text("version = 3.14\n", encoding="utf-8")
(payload / "tools").mkdir()
(payload / "manifest.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr("pm.environments.install_state_dir", lambda repo: payload / "state")
if has_facts:
facts = runtime_facts_path(Path(__file__).resolve().parents[2])
facts.parent.mkdir(parents=True, exist_ok=True)
facts.write_text(json.dumps({"packages": {"venv": {"environment": str(runtime)}}}), encoding="utf-8")
monkeypatch.setattr(local, "_in_venv", False)
monkeypatch.setattr(local, "_hermes_site_packages", None)
alias = child_env / "unrelated-repo-alias"
monkeypatch.setattr(local, "_hermes_repo_root_aliases", (alias,))
user_venv = child_env / "user-venv"
user_site = user_venv / "Lib/site-packages"
user_site.mkdir(parents=True)
(user_venv / "pyvenv.cfg").write_text("version = 3.13\n", encoding="utf-8")
base = {"VIRTUAL_ENV": str(user_venv), "PYTHONPATH": os.pathsep.join(map(str, [site, alias, user_site]))}
result = local._sanitize_subprocess_env(base)
expected = [str(user_site)] if has_facts else [str(site), str(user_site)]
assert result["PYTHONPATH"].split(os.pathsep) == expected
assert "VIRTUAL_ENV" not in result
assert base["VIRTUAL_ENV"] == str(user_venv)
@pytest.mark.parametrize("existing,expected", [
(["/usr/bin", "/bin"], ["/opt/hermes/bin", "/usr/bin", "/bin"]),
(["/usr/bin", "/opt/hermes/bin"], ["/usr/bin", "/opt/hermes/bin"]),
])
def test_background_hermes_path_repair_is_idempotent(child_env, monkeypatch, existing, expected):
monkeypatch.setattr(local, "_HERMES_BIN_DIR", "/opt/hermes/bin")
result = local._sanitize_subprocess_env({"PATH": os.pathsep.join(existing)})
assert result["PATH"].split(os.pathsep) == expected
assert local._sanitize_subprocess_env(result)["PATH"] == result["PATH"]
def test_hermes_bin_resolution_and_unresolved_noop(child_env, monkeypatch):
bin_dir = child_env / "bin"
bin_dir.mkdir()
monkeypatch.setattr(local, "_HERMES_BIN_DIR", local._SENTINEL)
monkeypatch.setattr(local.shutil, "which", lambda name: str(bin_dir / "hermes") if name == "hermes" else None)
assert local._resolve_hermes_bin_dir() == str(bin_dir)
monkeypatch.setattr(local, "_HERMES_BIN_DIR", None)
assert local._prepend_hermes_bin_dir("/usr/bin") == "/usr/bin"
@pytest.mark.platforms("posix")
def test_foreground_minimal_path_preserves_operator_precedence(child_env, monkeypatch):
monkeypatch.setattr(local, "_HERMES_BIN_DIR", "/opt/hermes/bin")
monkeypatch.setenv("PATH", "/custom/bin:/custom/bin::/usr/bin")
result = local._make_run_env({})["PATH"].split(":")
assert result[:3] == ["/opt/hermes/bin", "/custom/bin", "/usr/bin"]
assert "/opt/homebrew/bin" in result and "/opt/homebrew/sbin" in result
assert "" not in result
assert result.count("/custom/bin") == 1
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}")
class TestNativeEnvironmentContracts:
@pytest.fixture(autouse=True)
def _no_bin_injection(self, monkeypatch):
monkeypatch.setattr(local, "_HERMES_BIN_DIR", None)
@pytest.mark.platforms("windows")
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_pythonpath import _strip_hermes_owned_pythonpath
venv_sp = str(_running_site())
# 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_pythonpath 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_pythonpath 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_pythonpath 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
@pytest.mark.platforms("windows")
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
from tools.environments import local_pythonpath
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
from tools.environments import local_pythonpath
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_pythonpath._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_pythonpath 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_env import _scrub_child_env
from tools.environments.local_pythonpath 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()
# The kernel's reader threads drain with read1(); a bare MagicMock never returns
# EOF there, so the stderr thread spins forever appending mocks (a 1 GB/min leak
# that outlived the test and OOM-killed the worker five times).
proc.stdout.read.return_value = b""
proc.stdout.read1.return_value = b""
proc.stderr.read.return_value = b""
proc.stderr.read1.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_env._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"
# The fake streams must hit EOF: the kernel's reader threads consume
# ``read1()``, and an unconfigured MagicMock there is a truthy value
# forever — the stderr reader spins after the test returns, growing
# the pytest process by hundreds of MB per second (#115912).
for thread in threading.enumerate():
if "_reader" in thread.name:
thread.join(timeout=2)
assert not thread.is_alive(), \
f"{thread.name} is still spinning on the fake kernel stream"
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_pythonpath 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 tools.environments import local_pythonpath
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_pythonpath._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_pythonpath._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 tools.environments import local_pythonpath
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)
(physical_home / "profiles" / "coder" / "config.yaml").write_text("{}\n") # identity marker
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_pythonpath._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home / "profiles" / "coder",
)
assert any(local_pythonpath._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_pythonpath._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
from tools.environments import local_pythonpath
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_pythonpath._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
assert any(local_pythonpath._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_pythonpath._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
from tools.environments import local_pythonpath
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_pythonpath._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
for lookalike in (configured_home / "hermes-agent", unrelated):
assert not any(local_pythonpath._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_pythonpath._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
from tools.environments import local_pythonpath
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_pythonpath._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
assert any(local_pythonpath._same_path(a, lexical_root) for a in aliases)
assert not any(local_pythonpath._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_pythonpath._strip_hermes_owned_pythonpath(env)
assert env["PYTHONPATH"].split(os.pathsep) == ["/home/user/my-lib"]
@pytest.mark.platforms("windows")
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
from tools.environments import local_pythonpath
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_pythonpath._build_hermes_repo_root_aliases(
physical_root.resolve(),
physical_root,
configured_home,
)
assert any(local_pythonpath._same_path(a, lexical_root) for a in aliases)
monkeypatch.setattr(local, "_hermes_repo_root_aliases", aliases)
lexical_venv = lexical_root / "venv"
validated = local_pythonpath._validated_runtime_venv({"VIRTUAL_ENV": str(lexical_venv)})
assert validated is not None
assert local_pythonpath._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_pythonpath._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_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_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_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_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_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.platforms("macos")
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.platforms("windows")
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