From d26c46262348ecdddb1b28cb9c3df0db720374b2 Mon Sep 17 00:00:00 2001 From: David Sanchez <64162682+dsfaccini@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:36:20 -0500 Subject: [PATCH] fix(hooks): contain saved scan roots --- graphify/hooks.py | 22 +++++++++++---- tests/test_hooks.py | 69 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index e535e1913..bd6da0f60 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -161,13 +161,18 @@ def _bail(): _watchdog.daemon = True _watchdog.start() _force = os.environ.get('GRAPHIFY_FORCE', '').lower() in ('1', 'true', 'yes') - _root = Path('.') + _worktree_root = Path.cwd().resolve() + _root = _worktree_root _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out') _saved = Path(_out) / '.graphify_root' - if _saved.exists(): + try: _txt = _saved.read_text(encoding='utf-8-sig').strip() if _txt: - _root = Path(_txt) + _saved_root = Path(_txt).resolve() + _saved_root.relative_to(_worktree_root) + _root = _saved_root + except (OSError, RuntimeError, ValueError): + pass _rebuild_code(_root, changed_paths=changed, force=_force) # Refresh the work-memory lessons doc when saved Q&A outcomes exist # (best-effort; never fails the hook). @@ -210,13 +215,18 @@ def _bail(): # post-checkout: branch switch can touch arbitrary files; full rebuild path # (no changed_paths) is correct here. The flock inside _rebuild_code still # prevents pile-ups when commit + checkout fire back-to-back. - _root = Path('.') + _worktree_root = Path.cwd().resolve() + _root = _worktree_root _out = os.environ.get('GRAPHIFY_OUT', 'graphify-out') _saved = Path(_out) / '.graphify_root' - if _saved.exists(): + try: _txt = _saved.read_text(encoding='utf-8-sig').strip() if _txt: - _root = Path(_txt) + _saved_root = Path(_txt).resolve() + _saved_root.relative_to(_worktree_root) + _root = _saved_root + except (OSError, RuntimeError, ValueError): + pass _rebuild_code(_root, force=_force) # Refresh the work-memory lessons doc when saved Q&A outcomes exist # (best-effort; never fails the hook). diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 342c4d8b1..1e45b5b34 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -2,7 +2,8 @@ import os import shutil import subprocess -from types import SimpleNamespace +import sys +from types import ModuleType, SimpleNamespace from pathlib import Path import pytest from graphify.hooks import install, uninstall, status, _hooks_dir, _HOOK_MARKER, _CHECKOUT_MARKER @@ -332,6 +333,72 @@ def test_rebuild_bodies_with_graphify_root_are_valid_python(): ast.parse(body) +@pytest.mark.parametrize( + "name,body", + [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)], +) +@pytest.mark.parametrize("marker_kind", ("external", "empty", "malformed")) +def test_rebuild_bodies_anchor_untrusted_roots_to_worktree( + tmp_path, monkeypatch, capsys, name, body, marker_kind, +): + """Automatic hooks must never re-anchor a rebuild beyond their worktree.""" + repo = tmp_path / "repo" + repo.mkdir() + out = repo / "graphify-out" + out.mkdir() + external = tmp_path / "external" + external.mkdir() + marker = out / ".graphify_root" + marker.write_text( + str(external) if marker_kind == "external" else "\0" if marker_kind == "malformed" else "", + encoding="utf-8", + ) + rebuilt: list[Path] = [] + watch = ModuleType("graphify.watch") + watch._apply_resource_limits = lambda: None + watch._rebuild_code = lambda root, **kwargs: rebuilt.append(root) + monkeypatch.setitem(sys.modules, "graphify.watch", watch) + monkeypatch.chdir(repo) + monkeypatch.delenv("GRAPHIFY_OUT", raising=False) + monkeypatch.setenv("GRAPHIFY_REBUILD_TIMEOUT", "0") + if name == "post-commit": + monkeypatch.setenv("GRAPHIFY_CHANGED", "src/module.py") + + exec(body) + + assert rebuilt == [repo.resolve()] + captured = capsys.readouterr() + assert str(external) not in captured.out + captured.err + + +@pytest.mark.parametrize( + "name,body", + [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)], +) +def test_rebuild_bodies_accept_saved_subdirectory_roots(tmp_path, monkeypatch, name, body): + """A valid saved subdirectory scan must remain available to automatic hooks.""" + repo = tmp_path / "repo" + source = repo / "src" + source.mkdir(parents=True) + out = repo / "graphify-out" + out.mkdir() + (out / ".graphify_root").write_text(str(source), encoding="utf-8") + rebuilt: list[Path] = [] + watch = ModuleType("graphify.watch") + watch._apply_resource_limits = lambda: None + watch._rebuild_code = lambda root, **kwargs: rebuilt.append(root) + monkeypatch.setitem(sys.modules, "graphify.watch", watch) + monkeypatch.chdir(repo) + monkeypatch.delenv("GRAPHIFY_OUT", raising=False) + monkeypatch.setenv("GRAPHIFY_REBUILD_TIMEOUT", "0") + if name == "post-commit": + monkeypatch.setenv("GRAPHIFY_CHANGED", "src/module.py") + + exec(body) + + assert rebuilt == [source.resolve()] + + @pytest.mark.parametrize( "name,body", [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],