From 31aa4d4e9db6bfde55a80dea527c8d6c525e2473 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 11 Sep 2026 15:13:45 +0530 Subject: [PATCH 1/3] Keep the git hook rebuild root inside the repo Team setup documents committing the graphify output directory, and .graphify_root lives inside it, so its contents are checkout controlled. Both generated git hooks read that file and pass its value straight to _rebuild_code with no bound, so a value planted there by a forked PR could point the rebuild, and therefore what it reads and what it writes back into that same committed directory, at a location outside the repository the hook runs in. Both rebuild bodies now only adopt the saved root when it resolves inside the working tree the hook is running from, falling back to the repo top otherwise. Fixes #3265. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/hooks.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index dbd2fb46d5..174014b0df 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -188,7 +188,17 @@ def _bail(): if _saved.exists(): _txt = _saved.read_text(encoding='utf-8-sig').strip() if _txt: - _root = Path(_txt) + _candidate = Path(_txt) + try: + _cwd = Path.cwd().resolve() + _resolved = _candidate.resolve() + _in_repo = _resolved == _cwd or _cwd in _resolved.parents + except OSError: + _in_repo = False + if _in_repo: + _root = _candidate + else: + print(f'[graphify hook] ignoring out-of-repo .graphify_root: {_txt}') _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). @@ -250,7 +260,17 @@ def _bail(): if _saved.exists(): _txt = _saved.read_text(encoding='utf-8-sig').strip() if _txt: - _root = Path(_txt) + _candidate = Path(_txt) + try: + _cwd = Path.cwd().resolve() + _resolved = _candidate.resolve() + _in_repo = _resolved == _cwd or _cwd in _resolved.parents + except OSError: + _in_repo = False + if _in_repo: + _root = _candidate + else: + print(f'[graphify] ignoring out-of-repo .graphify_root: {_txt}') _rebuild_code(_root, force=_force) # Refresh the work-memory lessons doc when saved Q&A outcomes exist # (best-effort; never fails the hook). From 5976f7122a12165ada287a97f9008fb9f4c8d475 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 11 Sep 2026 15:13:51 +0530 Subject: [PATCH 2/3] Add regression tests for the git hook root guard Executes the shipped snippet directly (extracted from the rebuild body text) rather than a hand copy that could quietly drift from it, covering both the rejected case (a marker pointing outside the repo) and the case the guard must not break (a subdirectory scoped root). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- tests/test_hooks.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 43943df1ad..e672459b5f 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -2,6 +2,7 @@ import os import shutil import subprocess +import textwrap from types import SimpleNamespace from pathlib import Path import pytest @@ -332,6 +333,56 @@ def test_rebuild_bodies_with_graphify_root_are_valid_python(): ast.parse(body) +def _extract_root_resolution(body: str) -> str: + """Pull the `.graphify_root` -> `_root` snippet out of a rebuild body, so a + test can execute the shipped logic itself rather than a hand copy that could + quietly drift from it.""" + match = re.search(r"( _root = Path\('\.'\).*?)\n _rebuild_code\(", body, re.DOTALL) + assert match, "root resolution snippet not found" + return textwrap.dedent(match.group(1)) + + +@pytest.mark.parametrize( + "name,body", + [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)], +) +def test_rebuild_bodies_reject_an_out_of_repo_graphify_root(name, body, tmp_path, monkeypatch): + """#3265: `.graphify_root` sits inside graphify-out/, a directory the + documented team workflow says to commit, so its contents are checkout + controlled. Without a bound, a value planted there by a malicious fork or PR + (an absolute path outside the repository) would steer the rebuild -- and so + what gets read, and what gets written into the same committed graphify-out/ + -- to wherever the checkout names, not the repository the hook was installed + into. The recovered root must stay inside the working tree the hook actually + runs from.""" + repo = tmp_path / "repo" + outside = tmp_path / "outside" + (repo / "graphify-out").mkdir(parents=True) + outside.mkdir() + (repo / "graphify-out" / ".graphify_root").write_text(str(outside), encoding="utf-8") + monkeypatch.chdir(repo) + ns = {"Path": Path, "os": os} + exec(compile(_extract_root_resolution(body), "", "exec"), ns) + assert ns["_root"].resolve() == repo.resolve(), f"{name} honoured an out-of-repo root" + + +@pytest.mark.parametrize( + "name,body", + [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)], +) +def test_rebuild_bodies_honour_an_in_repo_graphify_root(name, body, tmp_path, monkeypatch): + """The legitimate case the #3265 guard must not break: a subdirectory-scoped + root (#1173) is still recovered.""" + repo = tmp_path / "repo" + (repo / "graphify-out").mkdir(parents=True) + (repo / "backend").mkdir() + (repo / "graphify-out" / ".graphify_root").write_text("backend", encoding="utf-8") + monkeypatch.chdir(repo) + ns = {"Path": Path, "os": os} + exec(compile(_extract_root_resolution(body), "", "exec"), ns) + assert ns["_root"].resolve() == (repo / "backend").resolve(), f"{name} lost the scoped root" + + @pytest.mark.parametrize( "name,body", [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)], From 96d48c76b52b4a60cc9164177230eaac0808b54e Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Fri, 11 Sep 2026 16:39:46 +0530 Subject: [PATCH 3/3] Catch symlink loop RuntimeError in the root guard Path.resolve() raises RuntimeError, not OSError, when a symlink chain loops back on itself, so the #3265 in repo check let that propagate up instead of falling back to the repo top like every other bad marker value. Since a fork or PR can commit an actual symlink loop alongside a crafted .graphify_root value, an untrusted checkout could still turn the guard itself into a failure path. Both rebuild bodies now catch RuntimeError alongside OSError. Found by the graphify review bot on PR 3492. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh --- graphify/hooks.py | 4 ++-- tests/test_hooks.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index 174014b0df..ac26f160e0 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -193,7 +193,7 @@ def _bail(): _cwd = Path.cwd().resolve() _resolved = _candidate.resolve() _in_repo = _resolved == _cwd or _cwd in _resolved.parents - except OSError: + except (OSError, RuntimeError): _in_repo = False if _in_repo: _root = _candidate @@ -265,7 +265,7 @@ def _bail(): _cwd = Path.cwd().resolve() _resolved = _candidate.resolve() _in_repo = _resolved == _cwd or _cwd in _resolved.parents - except OSError: + except (OSError, RuntimeError): _in_repo = False if _in_repo: _root = _candidate diff --git a/tests/test_hooks.py b/tests/test_hooks.py index e672459b5f..37a187f5f2 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -2,6 +2,7 @@ import os import shutil import subprocess +import sys import textwrap from types import SimpleNamespace from pathlib import Path @@ -383,6 +384,28 @@ def test_rebuild_bodies_honour_an_in_repo_graphify_root(name, body, tmp_path, mo assert ns["_root"].resolve() == (repo / "backend").resolve(), f"{name} lost the scoped root" +@pytest.mark.skipif(sys.platform == "win32", reason="symlink setup differs on Windows") +@pytest.mark.parametrize( + "name,body", + [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)], +) +def test_rebuild_bodies_survive_a_graphify_root_symlink_loop(name, body, tmp_path, monkeypatch): + """A committed `.graphify_root` naming a path that resolves through a + symlink loop (two symlinks pointing at each other) must fall back to the + repo top rather than let `Path.resolve()`'s RuntimeError escape the #3265 + guard uncaught -- the guard's own `except OSError` doesn't catch it, since + a symlink loop is a RuntimeError on this platform, not an OSError.""" + repo = tmp_path / "repo" + (repo / "graphify-out").mkdir(parents=True) + (repo / "loop_a").symlink_to(repo / "loop_b") + (repo / "loop_b").symlink_to(repo / "loop_a") + (repo / "graphify-out" / ".graphify_root").write_text("loop_a", encoding="utf-8") + monkeypatch.chdir(repo) + ns = {"Path": Path, "os": os} + exec(compile(_extract_root_resolution(body), "", "exec"), ns) + assert ns["_root"].resolve() == repo.resolve(), f"{name} did not fall back on a symlink loop" + + @pytest.mark.parametrize( "name,body", [("post-commit", _REBUILD_BODY_COMMIT), ("post-checkout", _REBUILD_BODY_CHECKOUT)],