diff --git a/tests/tools/test_code_execution_hermes_home.py b/tests/tools/test_code_execution_hermes_home.py index 8edbc568bf..7e762d58ca 100644 --- a/tests/tools/test_code_execution_hermes_home.py +++ b/tests/tools/test_code_execution_hermes_home.py @@ -42,12 +42,14 @@ def _child_env(): class TestMultiplexedHermesHome: - def test_override_rewrites_stale_server_default(self, monkeypatch, home_override, tmp_path): - """The reported bug: child must see the active profile's home, not the server default.""" + def test_override_rewrites_stale_server_default_per_turn(self, monkeypatch, home_override, tmp_path): + """The reported bug: a child must see the ACTIVE profile's home, not the server default, + and sequential turns for different profiles each see their own.""" monkeypatch.setenv("HERMES_HOME", "/machine/default/.hermes") - alpha_home = home_override(tmp_path / "profiles" / "alpha") - - assert _child_env()["HERMES_HOME"] == alpha_home + alpha = home_override(tmp_path / "profiles" / "alpha") + assert _child_env()["HERMES_HOME"] == alpha + beta = home_override(tmp_path / "profiles" / "beta") + assert _child_env()["HERMES_HOME"] == beta def test_no_override_leaves_inherited_value_untouched(self, monkeypatch): """Dedicated per-profile processes (no override): zero behavior change.""" @@ -55,21 +57,3 @@ class TestMultiplexedHermesHome: monkeypatch.setenv("HERMES_HOME", "/machine/default/.hermes") assert _child_env()["HERMES_HOME"] == "/machine/default/.hermes" - - def test_override_applies_when_parent_env_unset(self, monkeypatch, home_override, tmp_path): - monkeypatch.delenv("HERMES_HOME", raising=False) - beta_home = home_override(tmp_path / "profiles" / "beta") - - assert _child_env()["HERMES_HOME"] == beta_home - - def test_override_does_not_leak_between_profiles(self, monkeypatch, home_override, tmp_path): - """Sequential turns for different profiles each see their own home.""" - monkeypatch.setenv("HERMES_HOME", "/machine/default/.hermes") - home_override(tmp_path / "profiles" / "alpha") - first = _child_env()["HERMES_HOME"] - - home_override(tmp_path / "profiles" / "beta") - second = _child_env()["HERMES_HOME"] - - assert first == str(tmp_path / "profiles" / "alpha") - assert second == str(tmp_path / "profiles" / "beta") diff --git a/tests/tools/test_file_write_safety.py b/tests/tools/test_file_write_safety.py index 59345a9237..a025751a41 100644 --- a/tests/tools/test_file_write_safety.py +++ b/tests/tools/test_file_write_safety.py @@ -736,100 +736,57 @@ class TestProfileHomeExemptsHermesRoot: (root / "config.yaml").write_text("model:\n default: x\n", encoding="utf-8") return root, profile - def test_profile_home_exempts_root_direct_files( - self, tmp_path, monkeypatch, approvals - ): - """Positive: the root's own store is not project-local ``.hermes`` config.""" + def test_named_profile_scope_exempts_root_direct_files(self, tmp_path, monkeypatch, approvals): + """Under a named profile bound by the per-turn scope (multiplex path), the ROOT's own store is + not project-local ``.hermes`` config: the write lands with no approval prompt.""" import tools.file_tools_write_guards as ft + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + root, profile = self._profile_layout(tmp_path) - monkeypatch.setenv("HERMES_HOME", str(profile)) - - assert os.path.realpath(str(profile)) in ft._hermes_exempt_homes() - assert os.path.realpath(str(root)) in ft._hermes_exempt_homes() - for name in ("LEDGER.md", "MEMORY.md", "USER.md", "SOUL.md", "AGENTS.md"): - reason = ft._protected_instruction_reason(str(root / name)) - assert reason is None, f"{name} misread as project-local .hermes config" - - # …and the write actually lands, with no approval prompt at all. - res = self._write(root / "LEDGER.md", "caliber fixed") + monkeypatch.delenv("HERMES_HOME", raising=False) + token = set_hermes_home_override(str(profile)) + try: + assert os.path.realpath(str(root)) in ft._hermes_exempt_homes() + for name in ("LEDGER.md", "MEMORY.md", "SOUL.md", "AGENTS.md"): + assert ft._protected_instruction_reason(str(root / name)) is None, name + res = self._write(root / "LEDGER.md", "caliber fixed") + finally: + reset_hermes_home_override(token) assert not res.get("error"), res assert (root / "LEDGER.md").read_text(encoding="utf-8") == "caliber fixed" assert approvals["calls"] == [] - def test_negatives_still_gated_under_profile_home( - self, tmp_path, monkeypatch, approvals - ): - """Negative samples must keep failing closed with the profile home active.""" + def test_only_a_real_hermes_root_is_exempt(self, tmp_path, monkeypatch, approvals): + """Negatives hold with a named profile active: a checkout's ``.hermes/config.yaml`` and + protected basenames stay gated (fail-closed, unwritten), and a coincidental + ``.../profiles/`` tree that is NOT a Hermes root never exempts its parent.""" import tools.file_tools_write_guards as ft + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + root, profile = self._profile_layout(tmp_path) - repo = tmp_path / "repo" # a checkout OUTSIDE the Hermes tree + repo = tmp_path / "repo" (repo / ".hermes").mkdir(parents=True) - monkeypatch.setenv("HERMES_HOME", str(profile)) - - assert ft._protected_instruction_reason(str(repo / ".hermes" / "config.yaml")) - for name in ("AGENTS.md", "SOUL.md", "CLAUDE.md"): - assert ft._protected_instruction_reason(str(repo / name)) == name - assert ft._protected_instruction_reason("/tmp/elsewhere/docs/AGENTS.md") - - # End-to-end: the project-local .hermes config is still refused, unwritten. - target = repo / ".hermes" / "config.yaml" - res = self._write(target, "gate: off\n") + monkeypatch.delenv("HERMES_HOME", raising=False) + token = set_hermes_home_override(str(profile)) + try: + assert ft._protected_instruction_reason(str(repo / ".hermes" / "config.yaml")) + assert ft._protected_instruction_reason(str(repo / "AGENTS.md")) == "AGENTS.md" + target = repo / ".hermes" / "config.yaml" + res = self._write(target, "gate: off\n") + finally: + reset_hermes_home_override(token) assert res.get("error") and "BLOCKED" in res["error"] assert not target.exists() assert len(approvals["calls"]) == 1 - def test_lookalike_profiles_dir_does_not_exempt_parent( - self, tmp_path, monkeypatch - ): - """Only a REAL Hermes root is exempt; a coincidental ``profiles/`` dir is not. - - The root is derived from the ACTIVE home (contextvar scope — the multiplex - path), not from ``HERMES_HOME``, so a random ``.../profiles/`` tree - cannot quietly exempt its parent directory. - """ - import tools.file_tools_write_guards as ft - from hermes_constants import reset_hermes_home_override, set_hermes_home_override - - fake = tmp_path / "not-a-hermes-root" - profile = fake / "profiles" / "worker" - profile.mkdir(parents=True) - monkeypatch.delenv("HERMES_HOME", raising=False) - token = set_hermes_home_override(str(profile)) + fake_profile = tmp_path / "not-a-hermes-root" / "profiles" / "worker" + fake_profile.mkdir(parents=True) + token = set_hermes_home_override(str(fake_profile)) try: - assert ft._hermes_exempt_homes() == (os.path.realpath(str(profile)),) - proj = fake / "proj" / ".hermes" - proj.mkdir(parents=True) - assert ft._protected_instruction_reason(str(proj / "config.yaml")) + assert ft._hermes_exempt_homes() == (os.path.realpath(str(fake_profile)),) finally: reset_hermes_home_override(token) - def test_multiplex_profile_scope_exempts_root( - self, tmp_path, monkeypatch - ): - """The contextvar scope (multiplex gateway) resolves the root the same way.""" - import tools.file_tools_write_guards as ft - from hermes_constants import reset_hermes_home_override, set_hermes_home_override - - root, _profile = self._profile_layout(tmp_path) - alpha = root / "profiles" / "alpha" - alpha.mkdir(parents=True) - monkeypatch.delenv("HERMES_HOME", raising=False) - token = set_hermes_home_override(str(alpha)) - try: - assert os.path.realpath(str(root)) in ft._hermes_exempt_homes() - assert ft._protected_instruction_reason(str(root / "LEDGER.md")) is None - finally: - reset_hermes_home_override(token) - - def test_default_profile_exemption_unchanged(self, tmp_path, monkeypatch): - """HERMES_HOME= (default profile): one exempt tree, as before.""" - import tools.file_tools_write_guards as ft - root, _profile = self._profile_layout(tmp_path) - monkeypatch.setenv("HERMES_HOME", str(root)) - - assert ft._hermes_exempt_homes() == (os.path.realpath(str(root)),) - assert ft._protected_instruction_reason(str(root / "LEDGER.md")) is None - class TestMultiplexProfileWriteGuardsAreProfileScoped: """#107327: a multiplexed gateway scopes ``HERMES_HOME`` per turn via a diff --git a/tools/file_tools_write_guards.py b/tools/file_tools_write_guards.py index cae57d9fb7..29daaa1eeb 100644 --- a/tools/file_tools_write_guards.py +++ b/tools/file_tools_write_guards.py @@ -101,18 +101,15 @@ def _get_real_hermes_home() -> str | None: def _hermes_exempt_homes() -> tuple[str, ...]: - """Realpaths of the Hermes home tree(s) the protected-instruction gate must stay out of. - - Always the ACTIVE profile's home; PLUS the Hermes ROOT when that home is a named - profile (``/profiles/``). Exempting only the profile dir left the root's - DIRECT files (LEDGER.md / MEMORY.md / SOUL.md / AGENTS.md / DECISIONS.md ...) to fall - through to the ``.hermes`` component rule in ``_protected_instruction_reason``, which - then gated them as if they were a project-local ``/.hermes/config.yaml`` — and - that gate has no approval channel headless, so every write there failed closed (#60; - it blocked #54). Those files are the agent's own store, governed by their own guards, - exactly like ``~/.hermes`` under the default profile. The root is only added when the - shape really is a named profile (``named_profile_home``), so a coincidental - ``profiles/`` directory elsewhere never exempts its parent.""" + """Realpaths of the Hermes home tree(s) the protected-instruction gate must stay out of: + the ACTIVE profile's home, plus the Hermes ROOT when that home is a named profile + (``/profiles/``). Exempting only the profile dir left the root's DIRECT files + (LEDGER.md / MEMORY.md / SOUL.md / AGENTS.md ...) to the ``.hermes`` component rule, which + gated them like a project-local ``/.hermes/config.yaml`` — fail-closed headless + (#110630). They are the agent's own store, governed by their own guards, exactly like + ``~/.hermes`` under the default profile. The root is added only when the shape really is a + named profile (``named_profile_home``), so a coincidental ``profiles/`` dir elsewhere never + exempts its parent; the home comes from the ACTIVE scope, never ``HERMES_HOME`` alone.""" home = _get_real_hermes_home() if not home: return ()