diff --git a/tests/tools/test_file_write_safety.py b/tests/tools/test_file_write_safety.py index 238ca1cd4c..40951361f4 100644 --- a/tests/tools/test_file_write_safety.py +++ b/tests/tools/test_file_write_safety.py @@ -771,6 +771,84 @@ class TestMultiplexProfileWriteGuardsAreProfileScoped: assert err is not None assert "Refusing to write to Hermes config file" in err + def test_config_resolver_failure_stays_bound_to_active_profile( + self, tmp_path, monkeypatch + ): + """Opposite-side regression (PR #107335 review): when the primary config + resolver raises, the fallback must derive beta's config from beta's own + ``HERMES_HOME`` key — never the subprocess/default home — so beta's + ``config.yaml`` stays hard-blocked instead of failing open against an + unrelated config, and alpha's config is not spuriously protected under + beta's scope.""" + import tools.file_tools_write_guards as ft + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + a, b = self._profiles(tmp_path) + # alpha runs first (would poison a process memo), then the primary + # resolver breaks and beta's turn must fall back to beta's own home key. + tok = set_hermes_home_override(str(a)) + try: + ft._get_hermes_config_resolved() + finally: + reset_hermes_home_override(tok) + + def boom(): + raise RuntimeError("config path unavailable") + + monkeypatch.setattr(ft, "_config_path_resolved", boom) + + tok = set_hermes_home_override(str(b)) + try: + assert ft._get_hermes_config_resolved() == str( + (b / "config.yaml").resolve() + ) + err = ft._check_sensitive_path(str(b / "config.yaml"), "default") + # beta scope must NOT protect alpha's unrelated config (no wrong-home + # compare in either direction). + alpha_err = ft._check_sensitive_path(str(a / "config.yaml"), "default") + finally: + reset_hermes_home_override(tok) + assert err is not None + assert "Refusing to write to Hermes config file" in err + assert alpha_err is None + + def test_home_resolver_failure_keeps_exemption_on_active_profile( + self, tmp_path, monkeypatch + ): + """Opposite-side regression (PR #107335 review): when the primary home + resolver raises, the protected-instruction exemption must still resolve + against beta's own home (not the process/default home).""" + import tools.file_tools_write_guards as ft + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + a, b = self._profiles(tmp_path) + + def boom(): + raise RuntimeError("home unavailable") + + monkeypatch.setattr(ft, "_hermes_home_real", boom) + + tok = set_hermes_home_override(str(b)) + try: + assert ft._get_real_hermes_home() == os.path.realpath(str(b)) + # beta's own ~/.hermes tree is exempt from the project-local + # instruction gate (governed by its config-level guards instead), + # resolved against beta — not alpha or the default home. + assert ( + ft._check_protected_instruction_write( + [str(b / "AGENTS.md")], "default" + ) + is None + ) + finally: + reset_hermes_home_override(tok) + if __name__ == "__main__": pytest.main([__file__, "-v"]) diff --git a/tools/file_tools_write_guards.py b/tools/file_tools_write_guards.py index b567720140..1168078870 100644 --- a/tools/file_tools_write_guards.py +++ b/tools/file_tools_write_guards.py @@ -59,8 +59,17 @@ def _get_hermes_config_resolved() -> str | None: try: return _config_path_resolved() except Exception: + # Resolver failure must stay bound to the ACTIVE profile's home, not the + # subprocess HOME. ``_expand_tilde("~/...")`` follows the subprocess-HOME + # contract, which under host ``auto`` mode can be the real/default user + # home rather than the active multiplex ``HERMES_HOME`` — comparing + # beta's ``config.yaml`` against the default/root config would let the + # hard-block fail open on the exception path. Re-derive from the same + # ``get_hermes_home()`` key the happy path uses, and substitute no + # unrelated home if even that is gone (#107327 follow-up; PR #107335). try: - return str(Path(_expand_tilde("~/.hermes/config.yaml")).resolve()) + from hermes_constants import get_hermes_home + return str((Path(str(get_hermes_home())) / "config.yaml").resolve()) except Exception: return None @@ -75,8 +84,16 @@ def _get_real_hermes_home() -> str | None: try: return _hermes_home_real() except Exception: + # Same active-profile binding on the exception path (see + # ``_get_hermes_config_resolved``): re-derive from ``get_hermes_home()`` + # rather than ``_expand_tilde("~/.hermes")`` so the protected-instruction + # exemption resolves against the active profile — not the subprocess / + # default home — and substitute no unrelated home if the active security + # path cannot be established (PR #107335). A ``None`` here fails closed at + # the consumer: the ``~/.hermes`` exemption is skipped, so the gate runs. try: - return os.path.realpath(_expand_tilde("~/.hermes")) + from hermes_constants import get_hermes_home + return os.path.realpath(str(get_hermes_home())) except Exception: return None