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:
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user