{"record":{"id":"badc3309fa208116","repo":"github/spec-kit","slug":"skills-destination-skills-dir-escapes-project-ro","errorCode":null,"errorMessage":"Skills destination {skills_dir} escapes project root {project_root_resolved}","messagePattern":"Skills destination (.+?) escapes project root (.+?)","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/integrations/base.py","lineNumber":1706,"sourceCode":"        ``name``, ``description``, ``compatibility``, and ``metadata``.\n        \"\"\"\n\n        templates = self.list_command_templates()\n        if not templates:\n            return []\n\n        project_root_resolved = project_root.resolve()\n        if manifest.project_root != project_root_resolved:\n            raise ValueError(\n                f\"manifest.project_root ({manifest.project_root}) does not match \"\n                f\"project_root ({project_root_resolved})\"\n            )\n\n        skills_dir = self.skills_dest(project_root).resolve()\n        try:\n            skills_dir.relative_to(project_root_resolved)\n        except ValueError as exc:\n            raise ValueError(\n                f\"Skills destination {skills_dir} escapes \"\n                f\"project root {project_root_resolved}\"\n            ) from exc\n\n        script_type = opts.get(\"script_type\", \"sh\")\n        arg_placeholder = (\n            self.registrar_config.get(\"args\", \"$ARGUMENTS\")\n            if self.registrar_config\n            else \"$ARGUMENTS\"\n        )\n        created: list[Path] = []\n\n        for src_file in templates:\n            raw = src_file.read_text(encoding=\"utf-8\")\n\n            # Derive the skill name from the template stem\n            command_name = src_file.stem  # e.g. \"plan\"\n            skill_name = f\"speckit-{command_name.replace('.', '-')}\"","sourceCodeStart":1688,"sourceCodeEnd":1724,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/integrations/base.py#L1688-L1724","documentation":"The skills setup()'s second guard: after the manifest root matches, it resolves skills_dest() (config folder + commands_subdir, e.g. .claude/skills or .agents/skills) and requires it inside the resolved project root. It blocks skills installation from escaping the project via a misconfigured folder, an absolute path, or an out-of-tree symlink on the destination directory.","triggerScenarios":"config['folder'] absolute or containing '..' in a custom skills integration; the skills directory (e.g. .claude) symlinked to a location outside the project.","commonSituations":"Custom skills integrations with hard-coded folders; users symlinking agent config dirs to dotfiles repos; forked integrations where folder was never localized.","solutions":["Set config['folder'] to a project-relative directory such as '.my-agent/'.","Replace out-of-tree symlinks on the skills path with real directories.","Verify the resolved destination: run Path(skills_dest).resolve() and confirm it is under the project root."],"exampleFix":"# before\nclass MyIntegration(SkillsIntegration):\n    config = {\"folder\": \"~/.claude-shared\", ...}\n# after\nclass MyIntegration(SkillsIntegration):\n    config = {\"folder\": \".claude\", ...}","handlingStrategy":"validation","validationCode":"from pathlib import Path\n\ndef skills_dest_safe(project_root: Path, integration) -> bool:\n    try:\n        integration.skills_dest(project_root).resolve().relative_to(project_root.resolve())\n    except ValueError:\n        return False\n    return True","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Keep config['folder'] project-relative and non-absolute.","Make sure agent config/skills dirs in the project are real directories, not out-of-tree symlinks.","Assert the resolved destination stays under the project root in integration unit tests."],"tags":["configuration","filesystem","security","integrations","skills"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}