From 88dc8bbe26a6055161d3251b70f5cd3d3c5831b0 Mon Sep 17 00:00:00 2001 From: Hinotobi Date: Mon, 13 Apr 2026 15:51:55 +0800 Subject: [PATCH] fix: isolate profile .env secrets on switch (#351) * fix: isolate profile .env secrets on switch * fix: move direct os.environ set after _reload_dotenv to survive profile isolation The profile env isolation in _reload_dotenv now clears previously tracked env keys before re-reading .env. When apply_onboarding_setup set os.environ BEFORE _reload_dotenv, the key was immediately cleared. Move the belt-and-braces os.environ set to AFTER _reload_dotenv so the API key survives regardless of profile tracking state. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Nathan Esquenazi Co-authored-by: Claude Opus 4.6 (1M context) --- api/onboarding.py | 9 ++-- api/profiles.py | 20 ++++++++- tests/test_profile_env_isolation.py | 67 +++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 tests/test_profile_env_isolation.py diff --git a/api/onboarding.py b/api/onboarding.py index ae8ae8d..331d6d7 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -479,9 +479,6 @@ def apply_onboarding_setup(body: dict) -> dict: if api_key: _write_env_file(env_path, {provider_meta["env_var"]: api_key}) - # Belt-and-braces: set directly on os.environ so the value is visible to - # any code in the same process that reads it before the next request cycle. - os.environ[provider_meta["env_var"]] = api_key # Reload the hermes_cli provider/config cache so the next streaming call # picks up the new key without requiring a server restart. @@ -491,6 +488,12 @@ def apply_onboarding_setup(body: dict) -> dict: except Exception: pass + # Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the + # value survives even if _reload_dotenv cleared it (e.g. when _write_env_file + # wrote to disk but the profile isolation tracking hasn't seen it yet). + if api_key: + os.environ[provider_meta["env_var"]] = api_key + try: # hermes_cli may cache config at import time; ask it to reload if possible. from hermes_cli.config import reload as _cli_reload diff --git a/api/profiles.py b/api/profiles.py index e686340..11bf130 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -26,6 +26,7 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md'] # ── Module state ──────────────────────────────────────────────────────────── _active_profile = 'default' _profile_lock = threading.Lock() +_loaded_profile_env_keys: set[str] = set() def _resolve_base_hermes_home() -> Path: """Return the BASE ~/.hermes directory — the root that contains profiles/. @@ -120,11 +121,24 @@ def _set_hermes_home(home: Path): def _reload_dotenv(home: Path): - """Load .env from the profile dir into os.environ (additive).""" + """Load .env from the profile dir into os.environ with profile isolation. + + Clears env vars that were loaded from the previously active profile before + applying the current profile's .env. This prevents API keys and other + profile-scoped secrets from leaking across profile switches. + """ + global _loaded_profile_env_keys + + # Remove keys loaded from the previous profile first. + for key in list(_loaded_profile_env_keys): + os.environ.pop(key, None) + _loaded_profile_env_keys = set() + env_path = home / '.env' if not env_path.exists(): return try: + loaded_keys: set[str] = set() for line in env_path.read_text().splitlines(): line = line.strip() if line and not line.startswith('#') and '=' in line: @@ -133,8 +147,10 @@ def _reload_dotenv(home: Path): v = v.strip().strip('"').strip("'") if k and v: os.environ[k] = v + loaded_keys.add(k) + _loaded_profile_env_keys = loaded_keys except Exception: - pass + _loaded_profile_env_keys = set() def init_profile_state() -> None: diff --git a/tests/test_profile_env_isolation.py b/tests/test_profile_env_isolation.py new file mode 100644 index 0000000..79b0068 --- /dev/null +++ b/tests/test_profile_env_isolation.py @@ -0,0 +1,67 @@ +import importlib +import os +import sys +from pathlib import Path + + +def test_profile_switch_clears_previous_profile_env_vars(monkeypatch, tmp_path): + base = tmp_path / ".hermes" + (base / "profiles" / "p1").mkdir(parents=True) + (base / "profiles" / "p2").mkdir(parents=True) + (base / "profiles" / "p1" / ".env").write_text( + "OPENAI_API_KEY=secret-from-p1\nCUSTOM_TOKEN=token-from-p1\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_BASE_HOME", str(base)) + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("CUSTOM_TOKEN", raising=False) + + sys.modules.pop("api.profiles", None) + profiles = importlib.import_module("api.profiles") + profiles = importlib.reload(profiles) + + profiles.init_profile_state() + profiles.switch_profile("p1") + assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1" + assert os.environ.get("CUSTOM_TOKEN") == "token-from-p1" + + profiles.switch_profile("p2") + assert os.environ.get("OPENAI_API_KEY") is None + assert os.environ.get("CUSTOM_TOKEN") is None + assert profiles.get_active_profile_name() == "p2" + + +def test_profile_switch_replaces_overlapping_keys(monkeypatch, tmp_path): + base = tmp_path / ".hermes" + (base / "profiles" / "p1").mkdir(parents=True) + (base / "profiles" / "p2").mkdir(parents=True) + (base / "profiles" / "p1" / ".env").write_text( + "OPENAI_API_KEY=secret-from-p1\nONLY_P1=one\n", + encoding="utf-8", + ) + (base / "profiles" / "p2" / ".env").write_text( + "OPENAI_API_KEY=secret-from-p2\nONLY_P2=two\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_BASE_HOME", str(base)) + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("ONLY_P1", raising=False) + monkeypatch.delenv("ONLY_P2", raising=False) + + sys.modules.pop("api.profiles", None) + profiles = importlib.import_module("api.profiles") + profiles = importlib.reload(profiles) + + profiles.init_profile_state() + profiles.switch_profile("p1") + assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1" + assert os.environ.get("ONLY_P1") == "one" + + profiles.switch_profile("p2") + assert os.environ.get("OPENAI_API_KEY") == "secret-from-p2" + assert os.environ.get("ONLY_P1") is None + assert os.environ.get("ONLY_P2") == "two"