fix(hooks): treat absent ~/.mempalace as auto-save off

When the user removes ~/.mempalace/ (a strong "do not auto-capture"
signal), the next hook fire would silently recreate the entire dir
hierarchy and ingest existing transcripts:

1. _log() at hooks_cli.py:148 unconditionally calls
   STATE_DIR.mkdir(parents=True, exist_ok=True), so the act of
   writing the hook log line recreated ~/.mempalace/hook_state/
2. With no config file present, hook_stop_auto_save and
   hook_precompact_auto_save defaulted to True (no override to read)
3. The full save path then ran, materializing palace/, wal/,
   knowledge_graph.sqlite3, and N drawers from existing transcripts
   in ~/.claude/projects/*.jsonl

All four entry points (hook_stop, hook_precompact, hook_session_start,
and _log itself) now check a new PALACE_ROOT = Path.home() / ".mempalace"
constant first and short-circuit (returning {} on stdout, never logging)
when the dir is absent. The user-removable directory is now a kill-switch.

Five unit tests in tests/test_hooks_cli.py cover: hook_stop /
hook_precompact / hook_session_start do not create the dir when absent;
_log() does not create it when absent; existing dir proceeds normally
(regression).

Caught in the wild on a downstream fork: ~146 drawers materialized in
under a second after a deliberate `rm -rf ~/.mempalace/`, into a planning
session that was explicitly not meant to be captured.
This commit is contained in:
lcatlett
2026-05-01 19:26:43 -04:00
parent d07b730f08
commit 8472d553a3
3 changed files with 100 additions and 0 deletions
+76
View File
@@ -959,3 +959,79 @@ def test_stop_hook_rejects_injected_stop_hook_active(tmp_path):
# The injected value is not "true"/"1"/"yes", so the hook should NOT pass through.
# Save must have been attempted.
assert mock_save.called
# --- Absent palace root: hooks must not recreate ~/.mempalace ---
#
# When the user removes ~/.mempalace (e.g. `rm -rf`), that is the strongest
# possible "do not auto-capture" signal. Hooks must short-circuit BEFORE
# touching disk — including before the log-line that previously triggered
# STATE_DIR.mkdir() on its own.
import mempalace.hooks_cli as hooks_cli_mod
def _redirect_palace_root(monkeypatch, tmp_path):
"""Point PALACE_ROOT and STATE_DIR at a tmp location that does NOT exist."""
fake_root = tmp_path / "absent-mempalace"
monkeypatch.setattr(hooks_cli_mod, "PALACE_ROOT", fake_root)
monkeypatch.setattr(hooks_cli_mod, "STATE_DIR", fake_root / "hook_state")
monkeypatch.setattr(hooks_cli_mod, "_state_dir_initialized", False)
return fake_root
def test_hook_stop_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
hook_stop(
{"session_id": "absent", "transcript_path": str(transcript), "stop_hook_active": False},
"claude-code",
)
assert json.loads(buf.getvalue() or "{}") == {}
assert not fake_root.exists()
def test_hook_precompact_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
transcript = tmp_path / "t.jsonl"
transcript.write_text("")
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
hook_precompact(
{"session_id": "absent", "transcript_path": str(transcript)},
"claude-code",
)
assert json.loads(buf.getvalue() or "{}") == {}
assert not fake_root.exists()
def test_hook_session_start_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
hook_session_start({"session_id": "absent"}, "claude-code")
assert json.loads(buf.getvalue() or "{}") == {}
assert not fake_root.exists()
def test_log_does_not_create_palace_dir_when_absent(tmp_path, monkeypatch):
fake_root = _redirect_palace_root(monkeypatch, tmp_path)
_log("test message")
assert not fake_root.exists()
def test_existing_dir_proceeds_normally(tmp_path, monkeypatch):
"""Regression: when PALACE_ROOT exists, hooks must proceed (no short-circuit)."""
fake_root = tmp_path / "present-mempalace"
fake_root.mkdir()
monkeypatch.setattr(hooks_cli_mod, "PALACE_ROOT", fake_root)
monkeypatch.setattr(hooks_cli_mod, "STATE_DIR", fake_root / "hook_state")
monkeypatch.setattr(hooks_cli_mod, "_state_dir_initialized", False)
_log("test message")
# _log should have created the state dir under the existing palace root
assert (fake_root / "hook_state").exists()
assert (fake_root / "hook_state" / "hook.log").is_file()