fix(security): isolate multiplex dotenv reloads
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user