Skip to content
Draft
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
2 changes: 1 addition & 1 deletion tests/ingest/test_issue_558_async_stale_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def test_main_calls_maintenance_not_scan(self, monkeypatch):
_scan_stale_sessions (synchronous)."""
calls = {"maintenance": 0, "scan": 0}

monkeypatch.setattr(ss, "_run_maintenance_background", lambda: calls.__setitem__("maintenance", calls["maintenance"] + 1))
monkeypatch.setattr(ss, "_run_maintenance_background", lambda host="claude": calls.__setitem__("maintenance", calls["maintenance"] + 1))
monkeypatch.setattr(ss, "_scan_stale_sessions", lambda: calls.__setitem__("scan", calls["scan"] + 1))
monkeypatch.setattr(ss, "_drain_backlog", lambda: None)
monkeypatch.setattr(ss, "_is_first_run", lambda: True)
Expand Down
18 changes: 12 additions & 6 deletions tests/ingest/test_stop_hook_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
import json
import subprocess
from pathlib import Path
import pytest


@pytest.fixture(autouse=True)
def _allow_synthetic_transcripts(monkeypatch, tmp_path):
monkeypatch.setenv("TRUEMEMORY_TRANSCRIPT_DIR", str(tmp_path))


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -46,7 +52,7 @@ def _record_popen(*args, **kwargs):
monkeypatch.setattr(subprocess, "Popen", _record_popen)

stop_mod._run_background_ingestion(
transcript_path="/fake/transcript.json",
transcript_path=str(tmp_path / "transcript.json"),
session_id="sess-at-cap",
user_id="alice",
db_path="/tmp/fake.db",
Expand Down Expand Up @@ -93,7 +99,7 @@ def _record_popen(*args, **kwargs):
monkeypatch.setattr(subprocess, "Popen", _record_popen)

stop_mod._run_background_ingestion(
transcript_path="/fake/transcript.json",
transcript_path=str(tmp_path / "transcript.json"),
session_id="sess-ok",
user_id="alice",
db_path="/tmp/fake.db",
Expand Down Expand Up @@ -174,7 +180,7 @@ def _spy_inline(*args, **kwargs):

# Must not raise, must not call inline ingest, must queue to backlog.
stop_mod._run_background_ingestion(
transcript_path="/fake/transcript.json",
transcript_path=str(tmp_path / "transcript.json"),
session_id="sess-popen-fail",
user_id="alice",
db_path="/tmp/fake.db",
Expand Down Expand Up @@ -207,7 +213,7 @@ def test_backlog_write_failure_is_swallowed(monkeypatch, tmp_path):

# Should not raise
stop_mod._queue_to_backlog(
transcript_path="/fake/transcript.json",
transcript_path=str(tmp_path / "transcript.json"),
session_id="sess-backlog-fail",
user_id="alice",
db_path="/tmp/fake.db",
Expand All @@ -222,7 +228,7 @@ def test_backlog_marker_schema(monkeypatch, tmp_path):

monkeypatch.setattr(stop_mod, "BACKLOG_DIR", tmp_path / "backlog")
stop_mod._queue_to_backlog(
transcript_path="/conv/transcript.json",
transcript_path=str(tmp_path / "transcript.json"),
session_id="sess-abc",
user_id="alice",
db_path="/tmp/alice.db",
Expand Down Expand Up @@ -250,7 +256,7 @@ def test_empty_session_id_still_queues(monkeypatch, tmp_path):

monkeypatch.setattr(stop_mod, "BACKLOG_DIR", tmp_path / "backlog")
stop_mod._queue_to_backlog(
transcript_path="/conv/transcript.json",
transcript_path=str(tmp_path / "transcript.json"),
session_id="",
user_id="",
db_path="",
Expand Down
605 changes: 605 additions & 0 deletions tests/test-transcript-allowlists-761.py

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions tests/test_codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ def test_install_hooks_migrates_legacy_format(tmp_path, monkeypatch):
'[general]\ntheme = "dark"\n\n'
'[[hooks]]\n'
'event = "SessionStart"\n'
'command = "/usr/bin/python3 /path/to/truememory/session_start.py"\n'
'command = "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py"\n'
'timeout = 10000\n',
encoding="utf-8",
)
Expand Down Expand Up @@ -338,7 +338,7 @@ def test_uninstall_cleans_legacy_format(tmp_path, monkeypatch):
'[general]\ntheme = "dark"\n\n'
'[[hooks]]\n'
'event = "SessionStart"\n'
'command = "/usr/bin/python3 /path/to/truememory/session_start.py"\n'
'command = "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py"\n'
'timeout = 10000\n',
encoding="utf-8",
)
Expand Down Expand Up @@ -380,7 +380,7 @@ def test_remove_hook_blocks_correct_format():
'[[hooks.SessionStart]]\n\n'
'[[hooks.SessionStart.hooks]]\n'
'type = "command"\n'
'command = "/usr/bin/python3 /path/to/truememory/session_start.py"\n'
'command = "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py"\n'
'timeout = 10\n\n'
'[[hooks.MyCustomHook]]\n\n'
'[[hooks.MyCustomHook.hooks]]\n'
Expand All @@ -403,7 +403,7 @@ def test_remove_legacy_hook_blocks_with_blank_lines():
'[[hooks]]\n'
'event = "SessionStart"\n'
'\n'
'command = "/usr/bin/python3 /path/to/truememory/session_start.py"\n'
'command = "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py"\n'
'timeout = 10000\n\n'
'[[hooks]]\n'
'event = "MyCustomHook"\n'
Expand Down Expand Up @@ -432,12 +432,12 @@ def test_parse_existing_hooks_correct_format():
'[[hooks.SessionStart]]\n\n'
'[[hooks.SessionStart.hooks]]\n'
'type = "command"\n'
'command = "/usr/bin/python3 /path/to/truememory/session_start.py"\n'
'command = "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py"\n'
'timeout = 10\n\n'
'[[hooks.Stop]]\n\n'
'[[hooks.Stop.hooks]]\n'
'type = "command"\n'
'command = "/usr/bin/python3 /path/to/truememory/stop.py"\n'
'command = "/usr/bin/python3 /path/to/truememory/ingest/hooks/stop.py"\n'
'timeout = 5\n'
)
result = CodexAdapter._parse_existing_hooks(text)
Expand All @@ -446,7 +446,7 @@ def test_parse_existing_hooks_correct_format():
assert "Stop" in result
mg = result["SessionStart"][0]
assert "hooks" in mg
assert mg["hooks"][0]["command"] == "/usr/bin/python3 /path/to/truememory/session_start.py"
assert mg["hooks"][0]["command"] == "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py"


def test_parse_existing_hooks_empty():
Expand All @@ -465,7 +465,7 @@ def test_event_already_registered_correct_format():
"hooks": [
{
"type": "command",
"command": "/usr/bin/python3 /path/to/truememory/session_start.py",
"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py",
"timeout": 10,
}
]
Expand Down
4 changes: 2 additions & 2 deletions tests/test_gemini_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ def test_uninstall_preserves_other_entries(tmp_path, monkeypatch):
"hooks": {
"SessionStart": [
{"command": "my-hook", "timeout": 5000},
{"command": "/path/to/truememory/hooks/session_start.py", "timeout": 10000},
{"command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py", "timeout": 10000},
]
},
"theme": "dark",
Expand All @@ -278,7 +278,7 @@ def test_uninstall_removes_nested_format(tmp_path, monkeypatch):
"hooks": {
"SessionStart": [
{"hooks": [{"type": "command", "command": "my-hook", "timeout": 5000}]},
{"hooks": [{"type": "command", "command": "/path/to/truememory/session_start.py", "timeout": 10000, "name": "truememory-sessionstart"}]},
{"hooks": [{"type": "command", "command": "/usr/bin/python3 /path/to/truememory/ingest/hooks/session_start.py", "timeout": 10000, "name": "truememory-sessionstart"}]},
]
},
}), encoding="utf-8")
Expand Down
1 change: 1 addition & 0 deletions tests/test_issue_644_ingest_races.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def test_queue_to_backlog_refuses_to_clobber_live_claim(monkeypatch, tmp_path):
claim with a fresh ``.json`` (POSIX rename-overwrite race / M-15).
"""
from truememory.ingest.hooks import stop as stop_mod
monkeypatch.setenv("TRUEMEMORY_TRANSCRIPT_DIR", str(tmp_path))

backlog = tmp_path / "backlog"
backlog.mkdir(parents=True)
Expand Down
43 changes: 43 additions & 0 deletions truememory/hooks/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,53 @@

import json
import os
import re
import shlex
import sys
from abc import ABC, abstractmethod
from pathlib import Path


def is_owned_hook_command(command: object) -> bool:
"""Recognize a simple Python invocation of an installed TrueMemory hook."""
if not isinstance(command, str) or not command.strip() or any(char in command for char in ("\0", "\n", "\r")):
return False
try:
lexer = shlex.shlex(command, posix=False, punctuation_chars=";&|<>()`")
lexer.whitespace_split = True
for token in lexer:
if token and all(char in ";&|<>()`" for char in token):
return False
if not token.startswith("'") and any(part in token for part in ("$(", "${", "`")):
return False
tokens = shlex.split(command, comments=True, posix=sys.platform != "win32")
except ValueError:
return False
if sys.platform == "win32":
tokens = [token[1:-1] if token.startswith('"') and token.endswith('"') else token for token in tokens]
if len(tokens) < 2:
return False
executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1]
if re.fullmatch(r"python(?:\d+(?:\.\d+)*)?(?:\.exe)?", executable, re.IGNORECASE) is None:
return False
index = 1
while index < len(tokens) and tokens[index] in ("-B", "-u", "-E", "-I", "-s", "-S", "-O", "-OO"):
index += 1
if index >= len(tokens):
return False
stems = ("session_start", "user_prompt_submit", "stop", "compact")
if tokens[index] == "-m":
return index + 1 < len(tokens) and tokens[index + 1] in (
f"truememory.ingest.hooks.{stem}" for stem in stems
)
script = tokens[index].replace("\\", "/").split("/")
return (
len(script) >= 4
and script[-4:-1] == ["truememory", "ingest", "hooks"]
and script[-1] in (f"{stem}.py" for stem in stems)
)


def atomic_write_text(path: Path, text: str) -> None:
"""Write *text* to *path* atomically (tmp-in-same-dir + os.replace).

Expand Down
67 changes: 58 additions & 9 deletions truememory/hooks/adapters/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
from __future__ import annotations

import logging
import json
import re
import shlex
import shutil
import sys
from pathlib import Path

from truememory.hooks.adapters.base import CLIAdapter, atomic_write_text
from truememory.hooks.adapters.base import CLIAdapter, atomic_write_text, is_owned_hook_command

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -158,6 +159,7 @@ def install_hooks(

# Migrate any legacy [[hooks]] blocks before appending new ones
existing_text = self._remove_legacy_hook_blocks(existing_text)
existing_text = self._refresh_hook_commands(existing_text, py, hooks_dir, user_id, db_path)

existing_hooks = self._parse_existing_hooks(existing_text)

Expand All @@ -167,7 +169,7 @@ def install_hooks(
continue

script_path = hooks_dir / info["script"]
cmd = self._build_command(py, script_path, user_id, db_path)
cmd = self._build_command(py, script_path, user_id, db_path, event=event)

lines_to_append.append("")
lines_to_append.append(f"[[hooks.{event}]]")
Expand Down Expand Up @@ -223,8 +225,11 @@ def _build_command(
script_path: Path,
user_id: str = "",
db_path: str = "",
event: str = "",
) -> str:
parts: list[str] = [python_path, str(script_path)]
parts: list[str] = [python_path, str(script_path), "--host", "codex"]
if event:
parts.extend(["--event", event])
if user_id:
parts.extend(["--user", user_id])
if db_path:
Expand All @@ -234,6 +239,52 @@ def _build_command(
return _sp.list2cmdline(parts)
return " ".join(shlex.quote(p) for p in parts)

def _refresh_hook_commands(
self, text: str, python_path: str, hooks_dir: Path, user_id: str, db_path: str,
) -> str:
"""Upgrade owned command lines while retaining other TOML fields/hooks."""
event = ""
lines: list[str] = []
for line in text.splitlines(keepends=True):
header = re.match(r'^\s*\[\[hooks\.(\w+)\.hooks\]\]\s*$', line)
if header:
event = header.group(1)
elif line.lstrip().startswith("["):
event = ""
if (
event in _HOOK_EVENTS
and re.match(r'^\s*command\s*=', line)
and is_owned_hook_command(self._hook_command_value(line))
):
cmd = self._build_command(
python_path, hooks_dir / _HOOK_EVENTS[event]["script"],
user_id, db_path, event=event,
)
indent = line[:len(line) - len(line.lstrip())]
ending = "\n" if line.endswith("\n") else ""
line = f'{indent}command = "{_toml_escape(cmd)}"{ending}'
lines.append(line)
return "".join(lines)

@staticmethod
def _hook_command_value(line: str) -> str | None:
"""Decode the generated one-line TOML string without reading comments."""
match = re.match(r'^\s*command\s*=\s*(.*)$', line)
if match is None:
return None
value = match.group(1).strip()
if value.startswith("'"):
literal = re.fullmatch(r"'([^']*)'\s*(?:#.*)?", value)
return literal.group(1) if literal else None
try:
command, end = json.JSONDecoder().raw_decode(value)
except (json.JSONDecodeError, ValueError):
return None
tail = value[end:].strip()
if isinstance(command, str) and (not tail or tail.startswith("#")):
return command
return None

@staticmethod
def _parse_existing_hooks(toml_text: str) -> dict:
"""Parse hooks from TOML text, returning ``{event: [matcher_groups]}``."""
Expand Down Expand Up @@ -315,7 +366,7 @@ def _event_already_registered(hooks: dict, event: str) -> bool:
for hook_entry in mg.get("hooks", []):
handler = hook_entry if isinstance(hook_entry, dict) else {}
cmd = handler.get("command", "")
if _TRUEMEMORY_MARKER in cmd.lower():
if is_owned_hook_command(cmd):
return True
return False

Expand All @@ -340,7 +391,7 @@ def _has_hook_entries_in_text(self, text: str) -> bool:
for mg in matcher_groups:
for hook_entry in mg.get("hooks", []):
handler = hook_entry if isinstance(hook_entry, dict) else {}
if _TRUEMEMORY_MARKER in handler.get("command", "").lower():
if is_owned_hook_command(handler.get("command", "")):
return True
return False

Expand Down Expand Up @@ -410,8 +461,7 @@ def _remove_hook_blocks(text: str) -> str:
block_lines.append(lines[i])
i += 1

block_text = "".join(block_lines)
if _TRUEMEMORY_MARKER not in block_text.lower():
if not any(is_owned_hook_command(CodexAdapter._hook_command_value(line)) for line in block_lines):
cleaned.extend(block_lines)
continue

Expand Down Expand Up @@ -441,8 +491,7 @@ def _remove_legacy_hook_blocks(text: str) -> str:
block_lines.append(lines[i])
i += 1

block_text = "".join(block_lines)
if _TRUEMEMORY_MARKER not in block_text.lower():
if not any(is_owned_hook_command(CodexAdapter._hook_command_value(line)) for line in block_lines):
cleaned.extend(block_lines)
continue

Expand Down
Loading
Loading