github/spec-kit · error · TemplateResolutionError
PyYAML is required to resolve preset template composition
Error message
PyYAML is required to resolve preset template composition
What it means
The core TOCTOU guard of the staged commit: verify_path() compares the path's stat (follow_symlinks=False) against fstat of the held fd and requires S_ISREG plus matching st_dev/st_ino. If the path is no longer a regular file or the inode/device changed, the path was swapped (e.g. replaced by a symlink or a different file) after the staged fd was opened, and the commit refuses with OSError rather than atomically commit an attacker-chosen file.
Source
Thrown at scripts/python/common.py:394
raise ValueError(f"invalid manifest template strategy '{strategy}'")
if entry["type"] == "script" and strategy not in _VALID_SCRIPT_STRATEGIES:
raise ValueError(
f"invalid manifest script strategy '{strategy}'"
)
def _preset_template_layer(
preset_dir: Path, template_name: str
) -> tuple[Path, str] | None:
"""Return the preset template path and composition strategy."""
manifest_path = preset_dir / "preset.yml"
conventional = _conventional_template(preset_dir, template_name)
try:
import yaml
except ImportError as exc:
if manifest_path.is_file():
raise TemplateResolutionError(
"PyYAML is required to resolve preset template composition"
) from exc
return (conventional, "replace") if conventional is not None else None
if manifest_path.is_file():
try:
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict):
raise ValueError("manifest root must be a mapping")
if "provides" not in manifest:
raise ValueError("manifest missing provides section")
provides = manifest["provides"]
if not isinstance(provides, dict):
raise ValueError("manifest provides must be a mapping")
if "templates" not in provides:
raise ValueError("manifest provides missing templates")
templates = provides["templates"]
if not isinstance(templates, list):View on GitHub (pinned to bf88c9f9a8)
Solutions
- Serialize workflow installs per project (one process, or a file lock around specify invocations)
- Pause/exclude sync tools and file watchers for the project tree during installs
- Remove any symlink found at the staged path after failure and rerun
- If you are pen-testing this guard on purpose, note it is working as designed — the abort is the mitigation, not a bug
Example fix
# before # syncthing running on the repo while installing $ specify workflow add my.yaml # OSError: Staged workflow file changed before commit # after $ syncthing pause my-repo $ specify workflow add my.yaml # ok $ syncthing resume my-repo
Defensive patterns
Strategy: retry
Validate before calling
import os, stat
def path_matches_fd(path: str, fd: int) -> bool:
ps = os.stat(path, follow_symlinks=False)
fs = os.fstat(fd)
return stat.S_ISREG(ps.st_mode) and ps.st_dev == fs.st_dev and ps.st_ino == fs.st_ino
assert path_matches_fd(str(staged.path), staged.fd), 'staged file swapped; abort before commit' Try / catch
try:
staged.verify_path()
except OSError as e:
if 'changed before commit' in str(e):
if staged.path.is_symlink():
staged.path.unlink() # remove swap, restage once
restage_and_retry_once()
else:
raise Prevention
- One workflow install at a time per repo; use a lockfile around specify runs
- Pause sync tools (syncthing/Dropbox) during installs
- If testing symlink-swap resistance, expect this abort — it is the TOCTOU mitigation working
- Remove stray symlinks in .specify staging dirs before rerunning
When it happens
Trigger: Between opening the staged file (with O_NOFOLLOW) and commit, the path is replaced — by a symlink, a rename, or a new file. Concurrency in .specify, a malicious local process racing installs, or over-eager tooling recreating files trigger it.
Common situations: Symlink-swap security tests deliberately racing the installer; sync tools (syncthing/Dropbox) replacing files mid-install; concurrent specify runs writing the same destination; scripts that 'normalize' file types in .specify while installs run.
Related errors
- Invalid extension registry {registry}: 'extensions' must be
- Error: branch_template must not place {slug} before {number}
- Error: branch_template must put {number}- at the start of th
- Invalid extension registry {registry}: root must be a mappin
- ENOTDIR
AI-assisted analysis of github/spec-kit@bf88c9f9a8 (2026-08-14).
Data as JSON: /api/errors/b1d192a3420308b7.
Report an issue: GitHub.