{"record":{"id":"2e5d734cc2f86885","repo":"github/spec-kit","slug":"refusing-to-record-symlinked-manifest-path-rel","errorCode":null,"errorMessage":"Refusing to record symlinked manifest path: {rel} (symlinked at {_walk.relative_to(self.project_root).as_posix()})","messagePattern":"Refusing to record symlinked manifest path: (.+?) \\(symlinked at (.+?)\\)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/integrations/manifest.py","lineNumber":215,"sourceCode":"            _validate_rel_path(rel, self.project_root)\n            # _validate_rel_path raised for any actually-escaping path. If we reach\n            # here the path normalizes inside root (e.g. ``dir/../file.txt``).\n            # Reject anyway: manifest keys must be canonical so ``check_modified``\n            # and ``uninstall`` cannot key the same file under two paths.\n            raise ValueError(\n                f\"Manifest paths must be canonical; '..' segments are not \"\n                f\"allowed (got {rel})\"\n            )\n        # Walk each path component before resolution so a symlinked ancestor\n        # (e.g. ``linked_dir/file.txt`` where ``linked_dir`` is a symlink)\n        # cannot be silently followed by ``_validate_rel_path().resolve()``\n        # down to a target outside the project root. ``_ensure_safe_manifest_directory``\n        # uses the same pattern.\n        _walk = self.project_root\n        for part in rel.parts:\n            _walk = _walk / part\n            if _walk.is_symlink():\n                raise ValueError(\n                    f\"Refusing to record symlinked manifest path: {rel} \"\n                    f\"(symlinked at {_walk.relative_to(self.project_root).as_posix()})\"\n                )\n        abs_path = _validate_rel_path(rel, self.project_root)\n        if not abs_path.is_file():\n            raise ValueError(\n                f\"Manifest path is not a regular file: {rel}\"\n            )\n        normalized = abs_path.relative_to(self.project_root).as_posix()\n        self._files[normalized] = _sha256(abs_path)\n        if recovered:\n            self._recovered_files.add(normalized)\n        else:\n            # ``recovered=False`` means the caller is asserting this path is\n            # managed-baseline now, not merely observed; drop any stale\n            # recovered marker so future is_recovered() queries reflect the\n            # transition. ``discard`` is a no-op when the key is absent.\n            self._recovered_files.discard(normalized)","sourceCodeStart":197,"sourceCodeEnd":233,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/integrations/manifest.py#L197-L233","documentation":"Raised when walking the components of a rel_path passed to record_file()/record_existing(): some ancestor directory of the file is a symlink. Recording it would let resolve() silently follow the link, so the file could be tracked (and later uninstalled) outside the project root.","triggerScenarios":"record_file('linked_dir/cmd.md') where linked_dir is a symlink inside the project pointing elsewhere (inside or outside); the check is unconditional on the ancestor being a link, regardless of target.","commonSituations":"Monorepos symlinking shared command directories into per-package agent dirs; dotfile-managed .claude/.kilo directories; worktrees sharing command folders via links.","solutions":["Replace the symlinked ancestor with a real directory containing the file","If sharing is needed, copy files into the project before recording them","Skip recording linked paths; manage those files outside the manifest"],"exampleFix":"# before\n.claude/commands -> ../../shared/commands\nmanifest.record_file(\".claude/commands/build.md\")\n# after\ncp -r ../../shared/commands/. .claude/commands/\nmanifest.record_file(\".claude/commands/build.md\")","handlingStrategy":"validation","validationCode":"def has_symlinked_ancestor(root: Path, rel: Path) -> bool:\n    cur = root\n    for part in rel.parts:\n        cur = cur / part\n        if cur.is_symlink():\n            return True\n    return False\n\nif not has_symlinked_ancestor(project_root, rel):\n    manifest.record_file(rel)","typeGuard":null,"tryCatchPattern":"try:\n    manifest.record_file(rel)\nexcept ValueError as exc:\n    if \"symlinked manifest path\" in str(exc):\n        copy_file_into_project(rel)  # then record the copied path\n    else:\n        raise","preventionTips":["Materialize shared command dirs into the repo instead of symlinking","Audit monorepo setups for linked agent config dirs","Exclude manifest-tracked trees from dotfile managers"],"tags":["manifest","symlink","security"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}