fix(file-safety): bind the write-guard resolver fallback to the active profile

The per-call home/config getters fell back to `_expand_tilde("~/.hermes...")`
when the primary resolver raised. `_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`. So on the exception path the guards
recreated the very cross-profile authority bug the happy path fixed: beta's
`config.yaml` was compared against the default/root config (hard-block fails
open), and the protected-instruction exemption resolved against the wrong home.

Re-derive both fallbacks from the same `get_hermes_home()` key the happy path
uses (`Path(home)/config.yaml`, `realpath(home)`), and substitute no unrelated
home if the active security path cannot be established — a `None` fails closed
at the protected-instruction consumer (exemption skipped, gate runs).

Adds opposite-side regressions: forcing the primary config resolver to raise
keeps beta's own config refused (and does not spuriously protect alpha's under
beta's scope); forcing the primary home resolver to raise keeps beta's
instruction-file exemption resolved against beta.

Addresses the fallback-authority review on #107335 (thanks @andrexibiza).

(cherry picked from commit 119d88b46f745ba081f12adf3f6ebba457d95d68)
This commit is contained in:
PRATHAMESH75
2026-09-10 23:15:00 +05:30
committed by Teknium
parent 1271622e4b
commit 7af5006b24
2 changed files with 97 additions and 2 deletions
+78
View File
@@ -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"])
+19 -2
View File
@@ -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