github/spec-kit · error · OSError

ELOOP

ELOOP

Error message

Refusing to write through a symlinked download file

What it means

Pre-open guard in _safe_open_download_zip: the target zip_path inside the download cache is itself a symlink, and the tool refuses to write through it (ELOOP). Combined with O_CREAT|O_EXCL and O_NOFOLLOW this closes the pre-staged-symlink attack where a local attacker plants a link so the installer overwrites an arbitrary file.

Source

Thrown at src/specify_cli/extensions/_commands.py:821

    root) immediately before an exclusive create. ``O_EXCL`` guarantees an
    attacker cannot pre-stage the leaf as a symlink/junction, ``O_TEMPORARY``
    makes the OS delete it on close, and a post-open inode-identity check
    detects a leaf swapped underneath us. The returned descriptor is the only
    handle installation ever uses, so the cache pathname is never reopened.
    """
    zip_path = download_dir / zip_filename
    project_root_resolved = project_root.resolve()

    if download_dir.is_symlink() or not download_dir.is_dir():
        raise OSError(
            errno.ENOTDIR, "Download cache directory is not a real directory"
        )
    try:
        download_dir.resolve().relative_to(project_root_resolved)
    except (OSError, ValueError):
        raise OSError(errno.ENOTDIR, "Download cache directory escapes project root")
    if zip_path.is_symlink():
        raise OSError(errno.ELOOP, "Refusing to write through a symlinked download file")

    flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
    flags |= getattr(os, "O_NOFOLLOW", 0)
    flags |= getattr(os, "O_CLOEXEC", 0)
    flags |= getattr(os, "O_BINARY", 0)
    o_temporary = getattr(os, "O_TEMPORARY", 0)
    flags |= o_temporary

    download_fd = os.open(zip_path, flags, 0o600)
    try:
        _verify_leaf_identity(download_fd, zip_path)
    except OSError:
        os.close(download_fd)
        # Without O_TEMPORARY the leaf is not auto-deleted, so remove the file
        # we just exclusively created (best effort, never through a symlink).
        if not o_temporary:
            try:
                if not zip_path.is_symlink():

View on GitHub (pinned to bf88c9f9a8)

Solutions

  1. List and clear suspicious links in the cache area: `find .specify -type l -delete` (after review), then retry the install
  2. Exclude .specify from file-sync/cloud-placeholder tooling so files stay real
  3. Audit who else can write to the project's .specify and tighten permissions (go-w)
  4. If it recurs, capture `ls -la` of the cache dir — a repeated hit at a random-uuid name indicates active interference
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path

def no_symlink_at(path: Path) -> bool:
    return not path.is_symlink()

Try / catch

import errno
from specify_cli.extensions import ExtensionError

try:
    install_from_url_cmd(project_root, url, speckit_version)
except ExtensionError as e:
    cause = e.__cause__
    if isinstance(cause, OSError) and cause.errno == errno.ELOOP:
        # symlink planted at the transient download name; clean cache dir, retry

Prevention

When it happens

Trigger: `specify extension install <url>` when a file with the exact transient name (extension-url-download-<hex>.archive) already exists as a symlink in the cache dir — planted, or left by a sync/AV tool converting files to links. Note the name embeds a fresh uuid4 per attempt, so a pre-existing link at that exact name is highly abnormal.

Common situations: Adversarial multi-user environments; file-sync clients (Dropbox/OneDrive-style) that replace in-flight files with placeholder symlinks; deliberately crafted test fixtures for the security suite.

Related errors


AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14). Data as JSON: /api/errors/ad873ed7b195d05d. Report an issue: GitHub.