diff --git a/agent/file_safety.py b/agent/file_safety.py index 09da46cafdf..f8678b68c06 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -16,9 +16,19 @@ def _hermes_home_path() -> Path: return Path(os.path.expanduser("~/.hermes")) +def _hermes_root_path() -> Path: + """Resolve the Hermes root dir (always the parent of any profile, never per-profile).""" + try: + from hermes_constants import get_default_hermes_root # local import to avoid cycles + return get_default_hermes_root() + except Exception: + return Path(os.path.expanduser("~/.hermes")) + + def build_write_denied_paths(home: str) -> set[str]: """Return exact sensitive paths that must never be written.""" hermes_home = _hermes_home_path() + hermes_root = _hermes_root_path() return { os.path.realpath(p) for p in [ @@ -26,7 +36,11 @@ def build_write_denied_paths(home: str) -> set[str]: os.path.join(home, ".ssh", "id_rsa"), os.path.join(home, ".ssh", "id_ed25519"), os.path.join(home, ".ssh", "config"), + # Active profile .env (or top-level .env when not in profile mode). str(hermes_home / ".env"), + # Top-level .env, even when running under a profile — overwriting it + # leaks credentials across every profile that inherits from root (#15981). + str(hermes_root / ".env"), os.path.join(home, ".bashrc"), os.path.join(home, ".zshrc"), os.path.join(home, ".profile"), diff --git a/tests/tools/test_write_deny.py b/tests/tools/test_write_deny.py index 7d264525336..e83845e6626 100644 --- a/tests/tools/test_write_deny.py +++ b/tests/tools/test_write_deny.py @@ -41,6 +41,31 @@ class TestWriteDenyExactPaths: path = str(get_hermes_home() / ".env") assert _is_write_denied(path) is True + def test_hermes_root_env_when_running_under_profile(self, tmp_path, monkeypatch): + """Top-level ``/.env`` stays write-denied even when running under + a profile (#15981). + + Before the fix, ``build_write_denied_paths`` only added + ``/.env`` to the deny list, so the global + ``~/.hermes/.env`` (whose credentials are inherited by every profile) + could be silently overwritten by ``write_file`` while a profile was + active. + """ + root = tmp_path / "hermes_root" + profile_home = root / "profiles" / "coder" + profile_home.mkdir(parents=True) + global_env = root / ".env" + global_env.write_text("OPENAI_API_KEY=sk-real\n") + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + + # Sanity check: HERMES_HOME does point to the profile dir, not the root. + from hermes_constants import get_hermes_home, get_default_hermes_root + assert get_hermes_home() == profile_home + assert get_default_hermes_root() == root + + assert _is_write_denied(str(global_env)) is True + def test_shell_profiles(self): home = str(Path.home()) for name in [".bashrc", ".zshrc", ".profile", ".bash_profile", ".zprofile"]: