From 42021910e696af49217da4ea7d15faba7f3c67be Mon Sep 17 00:00:00 2001 From: poweredbyGEN Date: Mon, 10 Aug 2026 16:27:06 -0500 Subject: [PATCH] fix(assess): skip gitlink dirs, snapshot symlink targets without following git ls-files lists gitlink (160000) submodule entries and symlinks (120000). Reading a gitlink as a file raised IsADirectoryError; resolving a symlink with an external target aborted with ASSESSMENT_SOURCE_ESCAPE. Snapshot symlinks as their target text (exact git blob semantics) and skip gitlink directories, in both snapshot implementations (session_ux._source_identity, project digest). Found live on dayprotocol/day (layerzero-v2 submodule, xel-v2-ref symlink). Co-Authored-By: Claude Opus 4.8 --- src/graph_engineering/project.py | 21 ++++++++++++++++- src/graph_engineering/session_ux.py | 22 ++++++++++++++++++ tests/test_project.py | 36 +++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/graph_engineering/project.py b/src/graph_engineering/project.py index 2a736e1..65ab49d 100644 --- a/src/graph_engineering/project.py +++ b/src/graph_engineering/project.py @@ -306,12 +306,31 @@ def assessment_source(repo: Path) -> Mapping[str, str]: records: list[Mapping[str, Any]] = [] total_bytes = 0 for relative in paths: - path = (repo / relative).resolve() + unresolved = repo / relative + if unresolved.is_symlink(): + # Git stores a symlink (mode 120000) as a blob holding the target + # path text; snapshot exactly that and never follow the link. + payload = os.readlink(unresolved).encode("utf-8", errors="surrogateescape") + total_bytes += len(payload) + records.append( + { + "path": relative, + "sha256": hashlib.sha256(payload).hexdigest(), + "tracked": relative in tracked, + } + ) + continue + path = unresolved.resolve() if path != repo and not path.is_relative_to(repo): raise ProjectPolicyError( "ASSESSMENT_SOURCE_ESCAPE", f"source path escapes repository: {relative}", ) + if path.is_dir(): + # Gitlink (submodule) entries appear in `git ls-files` as tracked + # paths but are directories on disk; their content belongs to the + # sub-repository, not this snapshot. + continue try: payload = path.read_bytes() except OSError as exc: diff --git a/src/graph_engineering/session_ux.py b/src/graph_engineering/session_ux.py index eb3d24b..2150efe 100644 --- a/src/graph_engineering/session_ux.py +++ b/src/graph_engineering/session_ux.py @@ -4,6 +4,7 @@ import hashlib import json +import os import re import sqlite3 import stat @@ -109,6 +110,21 @@ def _source_identity(repo: Path) -> dict[str, str]: f"source path {relative!r} escapes repository", ) path = repo / relative + if path.is_symlink(): + # Git stores a symlink (mode 120000) as a blob holding the target + # path text; snapshot exactly that and never follow the link. + # Following would either escape the repository (absolute/external + # targets abort assess) or double-count in-repo content. + payload = os.readlink(path).encode("utf-8", errors="surrogateescape") + total_bytes += len(payload) + records.append( + { + "path": relative, + "sha256": hashlib.sha256(payload).hexdigest(), + "tracked": relative in tracked, + } + ) + continue try: if not path.resolve().is_relative_to(repo.resolve()): raise SessionUxError( @@ -119,6 +135,12 @@ def _source_identity(repo: Path) -> dict[str, str]: raise SessionUxError( "ASSESSMENT_SOURCE_READ", f"cannot resolve source path {relative!r}" ) from exc + if path.is_dir(): + # Gitlink (submodule) entries appear in `git ls-files` as tracked + # paths but are directories on disk; their content belongs to the + # sub-repository, not this snapshot. Reading one as a file raises + # IsADirectoryError and aborted assess on any repo with submodules. + continue try: size = path.stat().st_size if size > MAX_SOURCE_FILE_BYTES: diff --git a/tests/test_project.py b/tests/test_project.py index affa463..234e93d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -974,3 +974,39 @@ def test_run_scope_registry_is_atomic_repo_bound_and_resume_exact(tmp_path: Path resume=False, ) assert registry.matches(other)[0]["run_id"] == "other-repository" + + +def test_assess_repo_skips_gitlink_submodule_entries(tmp_path: Path): + # intent: `git ls-files` lists gitlink (mode 160000) submodule entries as + # tracked paths; they are directories on disk and reading one as a file + # raised ASSESSMENT_SOURCE_READ, aborting assess on any repo with + # submodules (found on dayprotocol/day: contracts/evm/lib/layerzero-v2). + repo = bare_repo(tmp_path) + head_sha = git(repo, "rev-parse", "HEAD") + git( + repo, + "update-index", + "--add", + "--cacheinfo", + f"160000,{head_sha},vendor/subrepo", + ) + git(repo, "commit", "-qm", "add gitlink") + (repo / "vendor" / "subrepo").mkdir(parents=True) + result = assess_repo(repo) + assert result["repo_digest"] + + +def test_assess_repo_snapshots_symlink_targets_without_following(tmp_path: Path): + # intent: a tracked symlink (mode 120000) whose target lies outside the + # repository aborted assess with ASSESSMENT_SOURCE_ESCAPE because the + # snapshot resolved (followed) it. Git stores the target path text as the + # blob; the snapshot must hash exactly that and never follow the link + # (found on dayprotocol/day: contracts/xel-v2-ref -> /root/projects/XEL/…). + repo = bare_repo(tmp_path) + outside = tmp_path / "outside-target" + outside.mkdir() + (repo / "external-ref").symlink_to(outside) + git(repo, "add", "--", "external-ref") + git(repo, "commit", "-qm", "add external symlink") + result = assess_repo(repo) + assert result["repo_digest"]