abhigyanpatwari/GitNexus · error · SandboxError

results directory must not traverse symlinks: {root}

Error message

results directory must not traverse symlinks: {root}

What it means

Even when the leaf is a real directory, _real_results_root requires root.resolve(strict=True) == root — i.e. no parent component of the path may be a symlink. If resolving the path lands on a different inode, some ancestor traverses a symlink, which the trust boundary forbids because the symlink target could be swapped under the harness.

Source

Thrown at eval/workflow_bench/evolve.py:297

    mode = path.lstat().st_mode
    if path.is_symlink() or not stat.S_ISREG(mode):
        raise SandboxError(f"evidence source must be a regular non-symlink file: {path}")
    with path.open("rb") as handle:
        if path.stat().st_size > limit:
            handle.seek(-limit, os.SEEK_END)
        return handle.read(limit).decode(errors="replace")


def _real_results_root(results_dir: Path) -> Path:
    root = results_dir.expanduser().absolute()
    try:
        metadata = root.lstat()
    except OSError as exc:
        raise SandboxError(f"results directory is unavailable: {root}: {exc}") from exc
    if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
        raise SandboxError(f"results directory must be a real non-symlink directory: {root}")
    if root.resolve(strict=True) != root:
        raise SandboxError(f"results directory must not traverse symlinks: {root}")
    return root


def _results_artifact_path(root: Path, relative_value: str, *, transcript: bool) -> Path:
    relative = PurePosixPath(relative_value)
    expected_parts = 2 if transcript else 1
    if (
        relative.is_absolute()
        or len(relative.parts) != expected_parts
        or any(part in {"", ".", ".."} for part in relative.parts)
        or (transcript and relative.parts[0] != "transcripts")
    ):
        raise SandboxError(f"unsafe results artifact path: {relative_value!r}")
    current = root
    for part in relative.parts[:-1]:
        current /= part
        try:
            metadata = current.lstat()

View on GitHub (pinned to d540b00184)

Solutions

  1. Resolve the path yourself first and pass the realpath: `Path(...).resolve(strict=True)`.
  2. Remove or replace the symlinked ancestor with a real directory, or move the results tree under a non-symlinked root.
  3. Document the no-symlink-traversal rule for operators picking the results location.

Example fix

# before
results_dir = Path("/opt/bench/results")  # /opt/bench is a symlink

# after: caller resolves to the real inode first
results_dir = Path("/opt/bench/results").resolve(strict=True)
# or point directly at the real volume
results_dir = Path("/mnt/x/results")
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path

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

# use the realpath to avoid the guard
results_dir = Path(...).resolve(strict=True)

Prevention

When it happens

Trigger: A parent directory of results_dir is a symlink: e.g. /opt/bench -> /mnt/x, and results_dir=/opt/bench/results resolves to /mnt/x/results, which differs from the as-given path.

Common situations: Standard symlinked install prefixes (/opt, /var/lib); a workspace root that is a symlink to a volume; PATH-like convenience links used in the results dir argument.

Related errors


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