github/spec-kit · error · OSError
ENOTDIR
ENOTDIR
Error message
Download file changed between creation and open
What it means
Post-open TOCTOU defense (_verify_leaf_identity): after opening the transient download file, the fstat of the open descriptor is compared against an lstat of the pathname; if the pathname is no longer a regular file or its device/inode pair differs, the leaf was swapped (e.g. replaced by a symlink) between creation and use, and an ENOTDIR OSError aborts before any write. It mirrors the staged-file check used by the workflow installer.
Source
Thrown at src/specify_cli/extensions/_commands.py:622
return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", -1))
def _verify_leaf_identity(fd: int, path: Path) -> None:
"""Confirm ``fd`` still refers to the regular file at ``path``.
Mirrors the workflow installer's staged-file check: comparing the open
descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf
that was swapped for a symlink/reparse point between creation and use, so
the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.
"""
path_stat = path.stat(follow_symlinks=False)
open_stat = os.fstat(fd)
if (
not stat.S_ISREG(path_stat.st_mode)
or path_stat.st_dev != open_stat.st_dev
or path_stat.st_ino != open_stat.st_ino
):
raise OSError(
errno.ENOTDIR, "Download file changed between creation and open"
)
def _validate_safe_cache_dir(project_root: Path) -> Path:
"""Create and validate the extension URL download cache one component at a
time, refusing symlinked/junctioned components on every supported platform."""
download_dir = project_root.joinpath(*_CACHE_REL_PARTS)
try:
if _has_secure_dir_fd():
_validate_cache_dir_via_dir_fd(project_root, download_dir)
else:
_validate_cache_dir_via_paths(project_root, download_dir)
except typer.Exit:
raise
except FileExistsError:
console.print(
"[red]Error:[/red] Refusing to use symlinked download cache directory"View on GitHub (pinned to bf88c9f9a8)
Solutions
- Treat as a security signal: stop, inspect the cache directory for unexpected symlinks (`find .specify -type l`), and recreate the download cache area fresh
- Remove world/group write access to the project and .specify (`chmod -R go-w .specify`) so other users cannot interfere
- Pause file-sync/AV tooling that touches .specify during installs, or exclude .specify from sync
- Retry the install after cleanup — a transient race from misbehaving tooling clears on a clean run
Defensive patterns
Strategy: try-catch
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.ENOTDIR and \
'changed between creation and open' in cause.strerror:
# security race: audit cache dir for symlinks, tighten perms, retry once Prevention
- Keep .specify non-writable by other users (chmod go-w)
- Exclude .specify from sync/AV tooling that rewrites files in place
When it happens
Trigger: Only fires if something concurrently replaces the freshly O_EXCL-created cache file (unlink + symlink swap) in the microseconds between os.open and the identity check — i.e. an active local attacker or an aggressive antivirus/sync tool manipulating the cache directory.
Common situations: Practically never in normal use; theoretically possible on multi-user machines where .specify is shared or world-writable, or with buggy file-sync daemons rewriting files in the cache area.
Related errors
- ELOOP
- 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/a7c53bd247ee22b1.
Report an issue: GitHub.