Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,69 @@ def _isolate_hermes_home(_hermetic_environment):
return None


# ── Kanban write guard (#69283) ─────────────────────────────────────────
# When hermetic isolation is bypassed (stale checkout, wrong rootdir,
# direct invocation), kanban writes silently pollute the real ~/.hermes.
# This autouse fixture patches ``kanban_db.connect`` to refuse writes that
# resolve to the REAL kanban root (captured at import time, before any
# fixture rewires the environment). A deny-list is used instead of an
# allow-list because test-level fixtures legitimately move HERMES_HOME to
# sibling directories — an allow-list captured at setup time would see the
# stale autouse-set value and falsely reject hermetic tests (#69385 review).

def _capture_real_kanban_root() -> Path:
"""Resolve the REAL kanban root from the pre-test environment.

Runs at conftest import time, before any fixture rewires HERMES_HOME.
Mirrors ``kanban_db.kanban_home()`` resolution order:
1. ``HERMES_KANBAN_HOME`` env var when set and non-empty
2. ``get_default_hermes_root()`` otherwise
"""
override = os.environ.get("HERMES_KANBAN_HOME", "").strip()
if override:
return Path(override).expanduser().resolve()
from hermes_constants import get_default_hermes_root
return get_default_hermes_root().resolve()


_REAL_KANBAN_ROOT = _capture_real_kanban_root()


@pytest.fixture(autouse=True)
def _kanban_write_guard(_hermetic_environment, monkeypatch):
"""Fail-closed guard: refuse kanban writes that target the REAL root.

Uses a **deny-list**: only blocks writes where ``kanban_home()`` resolves
to the real ``~/.hermes`` (captured at import time). Hermetic tests that
legitimately move HERMES_HOME to sibling tempdirs are unaffected.

Uses ``monkeypatch.setattr`` so pytest restores ``connect`` automatically
after each test (no stacked wrappers or state leakage across tests).
"""
try:
import hermes_cli.kanban_db as _kdb
except ImportError:
return

_orig_connect = _kdb.connect

def _guarded_connect(*args, **kwargs):
resolved = _kdb.kanban_home().resolve()
try:
resolved.relative_to(_REAL_KANBAN_ROOT)
except ValueError:
# Resolved path is NOT under the real root — safe to write.
return _orig_connect(*args, **kwargs)
raise RuntimeError(
f"kanban_write_guard: kanban_home() resolved to {resolved}, "
f"which is under the REAL kanban root ({_REAL_KANBAN_ROOT}). "
f"Hermetic isolation has been bypassed — refusing to write "
f"to the real ~/.hermes. See #69283."
)

monkeypatch.setattr(_kdb, "connect", _guarded_connect)


# ── Module-level state reset — replaced by per-file process isolation ──────
#
# Each test FILE runs in a freshly-spawned ``python -m pytest <file>``
Expand Down
30 changes: 30 additions & 0 deletions tests/hermes_cli/test_kanban_write_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""#69283: kanban write guard prevents tests from writing to real ~/.hermes."""

from __future__ import annotations

import pytest

from hermes_cli import kanban_db


def test_connect_succeeds_under_test_home(tmp_path, monkeypatch):
"""When HERMES_HOME is a temp dir, kanban connect succeeds normally."""
home = tmp_path / "hermes_home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
conn = kanban_db.connect()
try:
assert str(kanban_db.kanban_db_path()).startswith(str(home))
finally:
conn.close()


def test_connect_raises_when_kanban_home_is_real_root(monkeypatch):
"""When kanban_home resolves to the REAL root, connect raises RuntimeError."""
import tests.conftest as _conftest

monkeypatch.setattr(
kanban_db, "kanban_home", lambda: _conftest._REAL_KANBAN_ROOT
)
with pytest.raises(RuntimeError, match="kanban_write_guard"):
kanban_db.connect()