abhigyanpatwari/GitNexus · error · ValueError

repository root must not traverse symlinks: {root}

Error message

repository root must not traverse symlinks: {root}

What it means

All errors below are raised by internal helpers of `eval/workflow_bench/promotion_apply.py` and propagate to the caller of the public entry points: `apply_promoted_overlay(overlay, repo_root, *, expected_digest, expected_target_bases)`, `destination_base_digests(overlay, repo_root)`, `committed_destination_base_digests(overlay, repo_root, *, ref)` and `freeze_overlay(overlay, destination)`. The module applies promoted skill overlays across the canonical skill tree plus its shipped mirrors (`gitnexus/skills`, `gitnexus-claude-plugin/skills`) in a TOCTOU-hardened, symlink-rejecting, descriptor-bound transaction. `_open_repository_root` computes `resolved = root.resolve(strict=True)` and compares it to the (already `.expanduser().absolute()`-d) `root`. If they differ, some lexical component of the path traverses a symlink, and the guard fires. Even if the final entry is a real dir, a symlinked intermediate component is rejected.

Source

Thrown at eval/workflow_bench/promotion_apply.py:160

        stat.S_ISLNK(final.st_mode)
        or not stat.S_ISREG(final.st_mode)
        or not (identity(before) == identity(opened) == identity(after) == identity(final))
    ):
        raise ValueError(f"overlay destination changed while being read: {target}")
    return b"".join(chunks), opened.st_mode


def _open_repository_root(repo_root: Path) -> tuple[Path, int]:
    root = repo_root.expanduser().absolute()
    try:
        metadata = root.lstat()
        resolved = root.resolve(strict=True)
    except OSError as exc:
        raise ValueError(f"repository root is unavailable: {root}") from exc
    if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
        raise ValueError(f"repository root must be a real directory: {root}")
    if resolved != root:
        raise ValueError(f"repository root must not traverse symlinks: {root}")
    flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
    try:
        descriptor = os.open(root, flags)
    except OSError as exc:
        raise ValueError(f"repository root changed while opening: {root}") from exc
    try:
        opened = os.fstat(descriptor)
        final = root.lstat()
        final_resolved = root.resolve(strict=True)

        def identity(value: os.stat_result) -> tuple[int, int, int]:
            return value.st_dev, value.st_ino, stat.S_IFMT(value.st_mode)

        if (
            stat.S_ISLNK(final.st_mode)
            or not stat.S_ISDIR(opened.st_mode)
            or not stat.S_ISDIR(final.st_mode)
            or final_resolved != root

View on GitHub (pinned to d540b00184)

Solutions

  1. Print `Path(root).resolve(strict=True)` vs `Path(root).absolute()` and find the differing component.
  2. Rewrite the path using the resolved (non-symlink) components, or remove the offending symlink in the path chain.
  3. On macOS, prefer `/private/tmp` over `/tmp`; elsewhere expand all symlinks in the parent chain.

Example fix

// before
root = Path('/tmp/work/repo')  # /tmp -> /private/tmp on macOS
apply_promoted_overlay(overlay, repo_root=root)
// after
root = Path('/tmp/work/repo').resolve(strict=True)
# ensure no component is itself a symlink by using the resolved form
apply_promoted_overlay(overlay, repo_root=root)
Defensive patterns

Strategy: validation

Validate before calling

def lexical_real_root(p):
    root = Path(p).expanduser().absolute()
    if root.resolve(strict=True) != root:
        raise ValueError(f'repo_root traverses a symlink; use the resolved path: {root.resolve(strict=True)}')
    return root

Type guard

def root_is_lexical_non_symlinked(p: str) -> bool:
    root = Path(p).expanduser().absolute()
    try:
        return root.resolve(strict=True) == root
    except OSError:
        return False

Try / catch

except ValueError as exc:
    if 'must not traverse symlinks' in str(exc):
        resolved = Path(repo_root).resolve(strict=True)
        log.warning('re-running promotion against symlink-free root %s', resolved)
        apply_promoted_overlay(overlay, repo_root=resolved)

Prevention

When it happens

Trigger: Any path component of `repo_root` is a symlink, so `resolve()` yields a different string than the lexical absolute path (e.g. `/tmp` -> `/private/tmp` on macOS, or a symlinked parent like `/var/tmp`).

Common situations: macOS where `/tmp` resolves to `/private/tmp`; home dir under a symlinked `/home`; XDG cache under a symlinked `/mnt/cache`; promotion run from a path whose parent is a symlink for layout convenience.

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@d540b00184 (2026-08-12). Data as JSON: /api/errors/4d144cca928ec3fd. Report an issue: GitHub.