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

  1. Serialize workflow installs per project (one process, or a file lock around specify invocations)
  2. Pause/exclude sync tools and file watchers for the project tree during installs
  3. Remove any symlink found at the staged path after failure and rerun
  4. 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

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


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