fix(security): isolate multiplex dotenv reloads

This commit is contained in:
Lester Liang
2026-08-03 21:09:42 +10:00
committed by Teknium
parent 0058bde251
commit 1aa62ceb45
3 changed files with 121 additions and 4 deletions
+24 -2
View File
@@ -483,10 +483,32 @@ def load_hermes_dotenv(
- callers that only maintain the installation can set
``load_external_secrets=False`` to avoid loading optional secret-manager
dependencies into the process that replaces that same environment.
- routed multiplex profile loads hydrate external sources into the
profile's private secret snapshot without mutating the shared process
environment; unscoped startup loads retain the normal behavior above.
"""
loaded: list[Path] = []
home_path = Path(hermes_home or os.getenv("HERMES_HOME", Path.home() / ".hermes"))
# A multiplex gateway hosts every profile in one process. While a routed
# profile-home override is active, copying that profile's .env into
# os.environ would expose its credentials to sibling turns and every
# subsequently spawned child. An unscoped startup load remains process
# configuration and must retain the normal loading path.
# External secret sources still need their normal refresh path, so resolve
# them against the existing profile-local mapping instead of simply
# returning before all hydration work.
from agent.secret_scope import is_multiplex_active
from hermes_constants import get_hermes_home_override
if is_multiplex_active() and get_hermes_home_override() is not None:
if load_external_secrets:
from hermes_cli import _early_recovery
if not _early_recovery._should_skip_external_secret_sources():
hydrate_profile_secret_sources(home_path)
return []
loaded: list[Path] = []
user_env = home_path / ".env"
project_env_path = Path(project_env) if project_env else None
@@ -88,6 +88,54 @@ class TestProfilePathResolutionUnderMultiplexScope:
assert b_seen == prof_b / "skills"
def test_turn_scoped_dotenv_reload_does_not_pollute_process_env(tmp_path, monkeypatch):
"""A routed profile reload must stay inside its context-local scope.
``load_hermes_dotenv`` has several lazy-import and cron call sites beyond
the gateway's guarded reload helper. Any one of them can run during a
multiplexed turn, so the loader itself must not copy the active profile's
``.env`` into the shared process environment.
"""
import os
from agent.secret_scope import get_secret
from gateway.run import _profile_runtime_scope
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import get_hermes_home
profile_a = tmp_path / "profiles" / "a"
profile_b = tmp_path / "profiles" / "b"
profile_a.mkdir(parents=True)
profile_b.mkdir(parents=True)
(profile_a / ".env").write_text(
"PROFILE_SCOPED_API_KEY=secret-a\n"
"DISCORD_ALLOWED_CHANNELS=profile-a-only\n",
encoding="utf-8",
)
(profile_b / ".env").write_text(
"PROFILE_SCOPED_API_KEY=secret-b\n"
"DISCORD_ALLOWED_CHANNELS=profile-b-only\n",
encoding="utf-8",
)
monkeypatch.delenv("PROFILE_SCOPED_API_KEY", raising=False)
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "all-channels")
ss.set_multiplex_active(True)
with _profile_runtime_scope(profile_a):
assert get_secret("PROFILE_SCOPED_API_KEY") == "secret-a"
assert get_secret("DISCORD_ALLOWED_CHANNELS") == "profile-a-only"
assert load_hermes_dotenv(hermes_home=get_hermes_home()) == []
assert "PROFILE_SCOPED_API_KEY" not in os.environ
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "all-channels"
with _profile_runtime_scope(profile_b):
assert get_secret("PROFILE_SCOPED_API_KEY") == "secret-b"
assert get_secret("DISCORD_ALLOWED_CHANNELS") == "profile-b-only"
assert load_hermes_dotenv(hermes_home=get_hermes_home()) == []
assert "PROFILE_SCOPED_API_KEY" not in os.environ
assert os.environ["DISCORD_ALLOWED_CHANNELS"] == "all-channels"
def test_cold_profile_hydrates_external_source_without_global_env(
tmp_path, monkeypatch
):
@@ -164,5 +212,3 @@ def test_cold_profile_hydrates_external_source_without_global_env(
assert calls["count"] == 1
assert "TEST_PROVIDER_API_KEY" not in os.environ
assert "EXPLICIT_API_KEY" not in os.environ
+49
View File
@@ -174,6 +174,55 @@ def test_cold_profile_bitwarden_uses_profile_bootstrap_without_global_env(
assert os.environ.get("ANTHROPIC_API_KEY") is None
def test_multiplex_dotenv_load_hydrates_sources_without_global_env(
tmp_path, monkeypatch
):
"""The safe multiplex path must still refresh profile secret sources."""
from agent import secret_scope
import agent.secret_sources.bitwarden as bw_module
from agent.secret_sources import registry as reg_module
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
monkeypatch.delenv("BWS_ACCESS_TOKEN", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
(tmp_path / ".env").write_text(
"BWS_ACCESS_TOKEN=profile-bootstrap\n", encoding="utf-8"
)
(tmp_path / "config.yaml").write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" project_id: test-project\n"
" access_token_env: BWS_ACCESS_TOKEN\n",
encoding="utf-8",
)
monkeypatch.setattr(bw_module, "find_bws", lambda **_kw: Path("/fake/bws"))
monkeypatch.setattr(
bw_module,
"fetch_bitwarden_secrets",
lambda **_kw: ({"ANTHROPIC_API_KEY": "profile-provider-key"}, []),
)
reg_module._reset_registry_for_tests()
was_active = secret_scope.is_multiplex_active()
home_token = set_hermes_home_override(tmp_path)
secret_scope.set_multiplex_active(True)
try:
assert env_loader.load_hermes_dotenv(hermes_home=tmp_path) == []
finally:
secret_scope.set_multiplex_active(was_active)
reset_hermes_home_override(home_token)
assert env_loader.get_secret_source_values(tmp_path) == {
"ANTHROPIC_API_KEY": "profile-provider-key"
}
assert os.environ.get("BWS_ACCESS_TOKEN") is None
assert os.environ.get("ANTHROPIC_API_KEY") is None
def test_cold_profile_hydration_seeds_op_env_bootstrap(tmp_path, monkeypatch):
"""The .op.env bootstrap file must feed cold-profile hydration.