{"record":{"id":"d13dfbfef4a49fad","repo":"github/spec-kit","slug":"integration-destination-dest-escapes-project-roo-d13dfb","errorCode":null,"errorMessage":"Integration destination {dest} escapes project root {project_root_resolved}","messagePattern":"Integration destination (.+?) escapes project root (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/integrations/forge/__init__.py","lineNumber":133,"sourceCode":"        Extends MarkdownIntegration.setup() to inject Forge-specific transformations\n        after standard template processing.\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        dest = self.commands_dest(project_root).resolve()\n        try:\n            dest.relative_to(project_root_resolved)\n        except ValueError as exc:\n            raise ValueError(\n                f\"Integration destination {dest} escapes \"\n                f\"project root {project_root_resolved}\"\n            ) from exc\n        dest.mkdir(parents=True, exist_ok=True)\n\n        script_type = opts.get(\"script_type\", \"sh\")\n        arg_placeholder = self.registrar_config.get(\"args\", \"{{parameters}}\")\n        created: list[Path] = []\n\n        for src_file in templates:\n            raw = src_file.read_text(encoding=\"utf-8\")\n            # Process template with standard MarkdownIntegration logic\n            processed = self.process_template(\n                raw, self.key, script_type, arg_placeholder,\n                invoke_separator=self.invoke_separator,\n                project_root=project_root,\n            )\n","sourceCodeStart":115,"sourceCodeEnd":151,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/integrations/forge/__init__.py#L115-L151","documentation":"Raised by ForgeIntegration.setup() (src/specify_cli/integrations/forge/__init__.py:133) when self.commands_dest(project_root).resolve() is not under project_root_resolved (relative_to raised ValueError, chained as the cause). The guard blocks Forge command installation from writing .forge/commands files anywhere outside the project — normally only triggerable via a subclassed destination or a symlinked directory.","triggerScenarios":"A Forge integration subclass whose commands_dest returns an absolute/external path; or a project where .forge or .forge/workflows is a symlink pointing outside the project — resolve() then lands outside project_root_resolved and the guard fires.","commonSituations":"Users symlinking dotfile directories (.forge -> dotfiles repo elsewhere); custom integrations extending ForgeIntegration and overriding commands_dest; inconsistent project_root arguments between manifest creation and setup.","solutions":["Make commands_dest() return a path inside project_root (e.g. project_root / \".forge\" / \"workflows\").","Replace destination symlinks with real directories (or point them inside the project).","Ensure project_root passed to setup matches the manifest root.","Run via 'specify init --integration forge' for the standard, in-tree layout."],"exampleFix":"# before\nln -s /home/u/dotfiles/.forge .forge\nspecify init --integration forge\n\n# after\nrm .forge\nmkdir .forge\nspecify init --integration forge","handlingStrategy":"validation","validationCode":"root = project_root.resolve()\ndest = integration.commands_dest(project_root).resolve()\nassert dest.relative_to(root) is not None or True\ntry:\n    dest.relative_to(root)\nexcept ValueError:\n    raise SystemExit(\"forge destination escapes project root\")","typeGuard":null,"tryCatchPattern":"try:\n    integration.setup(project_root, manifest)\nexcept ValueError as e:\n    if \"escapes project root\" in str(e):\n        # replace symlinked .forge with a real directory and retry once\n        (project_root / \".forge\").unlink(missing_ok=True)\n        integration.setup(project_root, manifest)\n    else:\n        raise","preventionTips":["Do not symlink .forge to external dotfile locations; keep it a real directory.","Keep commands_dest() in-tree when subclassing ForgeIntegration.","Verify containment with resolve().relative_to() before calling setup()."],"tags":["filesystem","path-containment","security","integration-setup","spec-kit","forge"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}