{"record":{"id":"4870d5b16680ed7a","repo":"github/spec-kit","slug":"path-rel-resolves-to-resolved-which-is-outside","errorCode":null,"errorMessage":"Path {rel} resolves to {resolved} which is outside the project root {root_resolved}","messagePattern":"Path (.+?) resolves to (.+?) which is outside the project root (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/integrations/manifest.py","lineNumber":44,"sourceCode":"    return h.hexdigest()\n\n\ndef _validate_rel_path(rel: Path, root: Path) -> Path:\n    \"\"\"Resolve *rel* against *root* and verify it stays within *root*.\n\n    Raises ``ValueError`` if *rel* is absolute, contains ``..`` segments\n    that escape *root*, or otherwise resolves outside the project root.\n    \"\"\"\n    if rel.is_absolute():\n        raise ValueError(\n            f\"Absolute paths are not allowed in manifests: {rel}\"\n        )\n    resolved = (root / rel).resolve()\n    root_resolved = root.resolve()\n    try:\n        resolved.relative_to(root_resolved)\n    except ValueError:\n        raise ValueError(\n            f\"Path {rel} resolves to {resolved} which is outside \"\n            f\"the project root {root_resolved}\"\n        ) from None\n    return resolved\n\n\ndef _manifest_path_label(root: Path, path: Path) -> str:\n    try:\n        return path.relative_to(root).as_posix()\n    except ValueError:\n        return path.as_posix()\n\n\ndef _ensure_safe_manifest_directory(root: Path, directory: Path) -> None:\n    \"\"\"Create a manifest directory without following symlinked parents.\"\"\"\n    root_resolved = root.resolve()\n    try:\n        rel = directory.relative_to(root)","sourceCodeStart":26,"sourceCodeEnd":62,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/integrations/manifest.py#L26-L62","documentation":"Raised by _validate_rel_path in the integration manifest layer when a manifest-relative path, after resolving (root / rel), no longer sits under the resolved project root. The manifest may only track files inside the project so uninstall stays non-destructive. This is the generic escape error, distinct from the absolute-path and '..'-canonicality errors raised nearby.","triggerScenarios":"Calling IntegrationManifest.record_file()/record_existing()/check_modified()/uninstall() with a rel_path containing '..' segments that escape root (e.g. '../../etc/passwd'), or a path whose intermediate directory is a symlink pointing outside the project, so resolve() lands outside root_resolved.","commonSituations":"Hand-built rel_path joining a template dir with '..' segments; a project dir containing symlinked subdirectories (monorepo shared folders, dotfile managers); passing a path computed against a different (non-resolved) project_root.","solutions":["Use a plain canonical relative path with no '..' components (e.g. '.claude/commands/speckit.build.md')","If the path was built by joining paths, normalize it first with Path(os.path.normpath(p)) and verify it stays relative to root","Check for symlinked ancestors along the path and replace the symlink with a real directory or move the target inside the project","Pass the same resolved project_root that the IntegrationManifest instance was constructed with"],"exampleFix":"// before\nmanifest.record_file(\"../shared/commands/build.md\")\n// after\nmanifest.record_file(\"shared/commands/build.md\")  # move/copy the file inside project root","handlingStrategy":"validation","validationCode":"from pathlib import Path\n\ndef safe_rel(root: Path, rel: str | Path) -> Path | None:\n    p = Path(rel)\n    if p.is_absolute() or \"..\" in p.parts:\n        return None\n    root_r = root.resolve()\n    try:\n        resolved = (root / p).resolve()\n        resolved.relative_to(root_r)\n    except (OSError, ValueError):\n        return None\n    if any((root / part).is_symlink() for part in _accum(p)):\n        return None\n    return resolved","typeGuard":"def is_canonical_rel_path(rel: str) -> bool:\n    p = pathlib.PurePosixPath(rel)\n    return not p.is_absolute() and \"..\" not in p.parts and not rel.startswith(\"~\")","tryCatchPattern":"try:\n    manifest.record_file(rel)\nexcept ValueError as exc:\n    if \"outside the project root\" in str(exc):\n        log.warning(\"skipping out-of-project path %s\", rel)\n    else:\n        raise","preventionTips":["Build manifest paths only via path.relative_to(project_root)","Never string-concatenate '..' into manifest rel paths","Reject symlinked ancestors before recording files"],"tags":["manifest","path-traversal","security","integrations"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}