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
- List and clear suspicious links in the cache area: `find .specify -type l -delete` (after review), then retry the install
- Exclude .specify from file-sync/cloud-placeholder tooling so files stay real
- Audit who else can write to the project's .specify and tighten permissions (go-w)
- 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
- Sweep the cache area for unexpected symlinks before installs: find .specify -type l
- Exclude .specify from cloud-sync placeholder tooling
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
- ENOTDIR
- Error: --short-name requires a value
- Error: --number must be a non-negative integer
- Error: branch_template must not place {slug} before {number}
- ERROR: --template requires a template name
AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14).
Data as JSON: /api/errors/ad873ed7b195d05d.
Report an issue: GitHub.