{"record":{"id":"b1d192a3420308b7","repo":"github/spec-kit","slug":"pyyaml-is-required-to-resolve-preset-template-comp","errorCode":null,"errorMessage":"PyYAML is required to resolve preset template composition","messagePattern":"PyYAML is required to resolve preset template composition","errorType":"exception","errorClass":"TemplateResolutionError","httpStatus":null,"severity":"error","filePath":"scripts/python/common.py","lineNumber":394,"sourceCode":"        raise ValueError(f\"invalid manifest template strategy '{strategy}'\")\n    if entry[\"type\"] == \"script\" and strategy not in _VALID_SCRIPT_STRATEGIES:\n        raise ValueError(\n            f\"invalid manifest script strategy '{strategy}'\"\n        )\n\n\ndef _preset_template_layer(\n    preset_dir: Path, template_name: str\n) -> tuple[Path, str] | None:\n    \"\"\"Return the preset template path and composition strategy.\"\"\"\n    manifest_path = preset_dir / \"preset.yml\"\n    conventional = _conventional_template(preset_dir, template_name)\n\n    try:\n        import yaml\n    except ImportError as exc:\n        if manifest_path.is_file():\n            raise TemplateResolutionError(\n                \"PyYAML is required to resolve preset template composition\"\n            ) from exc\n        return (conventional, \"replace\") if conventional is not None else None\n\n    if manifest_path.is_file():\n        try:\n            manifest = yaml.safe_load(manifest_path.read_text(encoding=\"utf-8\"))\n            if not isinstance(manifest, dict):\n                raise ValueError(\"manifest root must be a mapping\")\n            if \"provides\" not in manifest:\n                raise ValueError(\"manifest missing provides section\")\n            provides = manifest[\"provides\"]\n            if not isinstance(provides, dict):\n                raise ValueError(\"manifest provides must be a mapping\")\n            if \"templates\" not in provides:\n                raise ValueError(\"manifest provides missing templates\")\n            templates = provides[\"templates\"]\n            if not isinstance(templates, list):","sourceCodeStart":376,"sourceCodeEnd":412,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/scripts/python/common.py#L376-L412","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before\n# syncthing running on the repo while installing\n$ specify workflow add my.yaml  # OSError: Staged workflow file changed before commit\n\n# after\n$ syncthing pause my-repo\n$ specify workflow add my.yaml  # ok\n$ syncthing resume my-repo","handlingStrategy":"retry","validationCode":"import os, stat\n\ndef path_matches_fd(path: str, fd: int) -> bool:\n    ps = os.stat(path, follow_symlinks=False)\n    fs = os.fstat(fd)\n    return stat.S_ISREG(ps.st_mode) and ps.st_dev == fs.st_dev and ps.st_ino == fs.st_ino\n\nassert path_matches_fd(str(staged.path), staged.fd), 'staged file swapped; abort before commit'","typeGuard":null,"tryCatchPattern":"try:\n    staged.verify_path()\nexcept OSError as e:\n    if 'changed before commit' in str(e):\n        if staged.path.is_symlink():\n            staged.path.unlink()  # remove swap, restage once\n        restage_and_retry_once()\n    else:\n        raise","preventionTips":["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"],"tags":["workflow","staging","race-condition","security","toctou"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}