{"record":{"id":"5cbaea56899a1d37","repo":"github/spec-kit","slug":"refusing-to-write-event-config-through-a-symlink","errorCode":null,"errorMessage":"Refusing to write event config through a symlink: {walked}","messagePattern":"Refusing to write event config through a symlink: (.+?)","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/events.py","lineNumber":2456,"sourceCode":"\n\ndef _ensure_safe_destination(dst: Path) -> None:\n    \"\"\"Validate a write target is a regular path inside the project (#12).\n\n    Walks each path component and rejects symlinks (which could escape the\n    project — e.g. a symlinked ``.claude`` or ``.specify`` directory pointing\n    outside the repo would redirect writes to external files). Then validates\n    lexical containment so ``..`` traversal is also rejected.\n    \"\"\"\n    from .agents import CommandRegistrar\n\n    # Walk each component so a symlinked ancestor (e.g. ``.claude`` → outside)\n    # cannot be silently followed. Mirrors IntegrationManifest.record_existing.\n    walked = dst.anchor and Path(dst.anchor) or Path(\"/\")\n    for part in dst.relative_to(dst.anchor).parts if dst.anchor else dst.parts:\n        walked = walked / part\n        if walked.is_symlink():\n            raise ValueError(\n                f\"Refusing to write event config through a symlink: {walked}\"\n            )\n\n    # Containment check against the nearest existing ancestor directory.\n    base = dst.parent\n    while not base.exists() and base != base.parent:\n        base = base.parent\n    CommandRegistrar._ensure_inside(dst, base)\n\n\ndef _remove_json_entries(dst: Path) -> bool:\n    \"\"\"Remove Specify-authored entries; delete the file if now empty (#14).\n\n    Returns True if the file was deleted (Spec Kit created it and no user\n    content remains), False otherwise.\n    \"\"\"\n    _ensure_safe_destination(dst)\n    existing = _load_user_json(dst)","sourceCodeStart":2438,"sourceCodeEnd":2474,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/events.py#L2438-L2474","documentation":"A deliberate security guard: Specify refuses to write generated event configuration to a destination path that passes through a symbolic link, because a symlinked `.claude` or `.specify` directory could redirect writes to files outside the repository. Each path component is walked and checked with is_symlink(); a lexical containment check against the nearest existing ancestor then rejects `..` traversal as well.","triggerScenarios":"install_integration_events computes a destination (e.g. `.claude/settings.json` or a config path under `.specify`) where any ancestor component — `.claude`, `.specify`, or a parent — is a symlink; the write is aborted with ValueError naming the offending component.","commonSituations":"Developers symlinking dotfile directories between repos or to a shared dotfiles repo; a monorepo setup where `.claude` is a link to a central config dir; CI environments that link config dirs into the workspace; dotfiles managers (stow, chezmoi) creating links.","solutions":["Replace the symlinked directory with a real directory and copy (or manage) its contents directly.","If you need shared config, have Specify write in-repo and symlink individual non-managed files yourself — never a directory Specify writes into.","Check every component of the destination path from repo root down (`ls -la` each level) to find the link named in the error.","Do not attempt to work around this — the redirect-outside-repo risk is exactly what the guard prevents."],"exampleFix":"# before\nln -s ~/dotfiles/claude .claude\nspecify extension install my-ext   # ValueError: symlink\n\n# after\nmkdir .claude\ncp ~/dotfiles/claude/* .claude/   # manage contents directly\nspecify extension install my-ext","handlingStrategy":"validation","validationCode":"from pathlib import Path\ndef path_crosses_symlink(dst: Path) -> bool:\n    walked = Path(dst.anchor or \"/\")\n    for part in dst.relative_to(dst.anchor).parts:\n        walked = walked / part\n        if walked.is_symlink():\n            return True\n    return False\n\nif path_crosses_symlink(config_dst):\n    raise SystemExit(\"replace symlinked config dir with a real directory\")","typeGuard":null,"tryCatchPattern":"try:\n    install_integration_events(integration, root, manifest, events_map)\nexcept ValueError as e:\n    if \"symlink\" in str(e):\n        materialize_dir(e_path)  # copy link target contents into a real dir, then retry","preventionTips":["Never symlink `.claude`, `.specify`, or other directories Specify writes into.","With dotfile managers, exclude agent config dirs from stow/chezmoi linking.","Check `ls -la` for stray links after repo scaffolding in CI."],"tags":["security","symlink","filesystem","events","path-traversal"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}