diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index c5e95a24dbc..c7d507d8c2f 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -243,10 +243,43 @@ def load_hermes_dotenv( loaded.append(project_env_path) _apply_external_secret_sources(home_path) + _apply_managed_env() return loaded +def _apply_managed_env() -> None: + """Apply the managed-scope .env last, with override, so it beats user/shell. + + Managed scope is machine-global (independent of HERMES_HOME / profile). v1 + enforcement is "applied last with override=True" — at the end of startup load + ``os.environ`` holds the managed value for every managed key, beating both the + user ``.env`` and any pre-existing shell export. This deliberately inverts the + usual env-over-config precedence for the pinned keys (see + ``docs/design/managed-scope.md`` §4.1). + + This does NOT prevent the agent from later mutating ``os.environ`` in-process + or ``export``-ing in a subprocess shell; that hard boundary is a documented + v2 item (design §8.1). v1 relies on filesystem permissions only. + + Fail-open: a missing managed dir or .env is the common case and a no-op; any + error here is swallowed so managed scope can never block startup. + """ + try: + from hermes_cli import managed_scope + + managed_dir = managed_scope.get_managed_dir() + except Exception: # noqa: BLE001 — managed scope must never block startup + return + if managed_dir is None: + return + managed_env = managed_dir / ".env" + if not managed_env.exists(): + return + _sanitize_env_file_if_needed(managed_env) + _load_dotenv_with_fallback(managed_env, override=True) + + def _apply_external_secret_sources(home_path: Path) -> None: """Pull secrets from external sources (currently Bitwarden) into env. diff --git a/tests/hermes_cli/test_managed_scope_env.py b/tests/hermes_cli/test_managed_scope_env.py new file mode 100644 index 00000000000..fb259216f55 --- /dev/null +++ b/tests/hermes_cli/test_managed_scope_env.py @@ -0,0 +1,58 @@ +"""Env integration tests — managed .env applied last with override.""" +import os + +import pytest + + +@pytest.fixture +def env_homes(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + managed = tmp_path / "managed" + managed.mkdir() + monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed)) + from hermes_cli import managed_scope + + managed_scope.invalidate_managed_cache() + return home, managed + + +def test_managed_env_beats_user_env(env_homes, monkeypatch): + from hermes_cli.env_loader import load_hermes_dotenv + + home, managed = env_homes + (home / ".env").write_text("OPENAI_API_BASE=https://user.example/v1\n", encoding="utf-8") + (managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8") + load_hermes_dotenv(hermes_home=str(home)) + assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1" + + +def test_managed_env_beats_shell(env_homes, monkeypatch): + from hermes_cli.env_loader import load_hermes_dotenv + + home, managed = env_homes + monkeypatch.setenv("OPENAI_API_BASE", "https://shell.example/v1") + (managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8") + load_hermes_dotenv(hermes_home=str(home)) + assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1" + + +def test_managed_env_leaves_unmanaged_keys_alone(env_homes, monkeypatch): + from hermes_cli.env_loader import load_hermes_dotenv + + home, managed = env_homes + (home / ".env").write_text("USER_ONLY=keepme\n", encoding="utf-8") + (managed / ".env").write_text("OPENAI_API_BASE=https://org.example/v1\n", encoding="utf-8") + load_hermes_dotenv(hermes_home=str(home)) + assert os.environ["USER_ONLY"] == "keepme" + assert os.environ["OPENAI_API_BASE"] == "https://org.example/v1" + + +def test_no_managed_env_is_noop(env_homes, monkeypatch): + from hermes_cli.env_loader import load_hermes_dotenv + + home, managed = env_homes # managed dir exists but has no .env + monkeypatch.setenv("SOME_VALUE", "from_shell") + (home / ".env").write_text("SOME_VALUE=from_user\n", encoding="utf-8") + load_hermes_dotenv(hermes_home=str(home)) + assert os.environ["SOME_VALUE"] == "from_user"