{"record":{"id":"f19e91e2481ce466","repo":"github/spec-kit","slug":"output-path-candidate-r-escapes-directory-base","errorCode":null,"errorMessage":"Output path {candidate!r} escapes directory {base!r}","messagePattern":"Output path (.+?) escapes directory (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"src/specify_cli/agents.py","lineNumber":578,"sourceCode":"    @staticmethod\n    def _ensure_inside(candidate: Path, base: Path) -> None:\n        \"\"\"Validate that a write target stays within the expected base directory.\n\n        Uses lexical normalization so traversal via ``..`` or absolute paths is\n        rejected while intentionally symlinked sub-directories remain\n        supported.\n\n        Args:\n            candidate: Path that will be written.\n            base: Directory the write must remain within.\n\n        Raises:\n            ValueError: If the normalized candidate path escapes ``base``.\n        \"\"\"\n        normalized = Path(os.path.normpath(candidate))\n        base_normalized = Path(os.path.normpath(base))\n        if not normalized.is_relative_to(base_normalized):\n            raise ValueError(f\"Output path {candidate!r} escapes directory {base!r}\")\n\n    @staticmethod\n    def _is_safe_command_name(name: str) -> bool:\n        \"\"\"Reject names that could escape the commands directory via path traversal.\"\"\"\n        if os.path.sep in name or \"/\" in name or \"\\\\\" in name:\n            return False\n        return os.path.normpath(name) == name\n\n    @staticmethod\n    def _same_lexical_path(left: Path, right: Path) -> bool:\n        \"\"\"Compare paths after lexical normalization without resolving symlinks.\"\"\"\n        return os.path.normcase(os.path.normpath(os.fspath(left))) == os.path.normcase(\n            os.path.normpath(os.fspath(right))\n        )\n\n    @staticmethod\n    def _active_skills_agent(project_root: Path) -> Optional[str]:\n        \"\"\"Return the initialized skills-backed agent, if skills mode is active.\"\"\"","sourceCodeStart":560,"sourceCodeEnd":596,"githubUrl":"https://github.com/github/spec-kit/blob/bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c/src/specify_cli/agents.py#L560-L596","documentation":"CommandRegistrar._ensure_inside() is the path-containment backstop: it lexically normalizes both candidate and base (os.path.normpath) and raises ValueError unless the candidate is relative to the base directory. It exists to stop generated command/prompt files (or extension-provided names) from writing outside the target commands directory via traversal segments like '..'.","triggerScenarios":"A command name or resolved output path containing '..' (e.g. name '../../../etc/cron.d/x'), an absolute path, or a symlink-style traversal that survives normpath; write_copilot_prompt calls _ensure_inside(prompt_file, prompts_dir) after building prompts_dir / f\"{cmd_name}.prompt.md\", so a traversal-laden cmd_name trips it.","commonSituations":"Malicious or buggy extension manifests declaring command names with '../' segments; user-edited configuration injecting traversal; names built by string concatenation that accidentally include '..' or a leading slash. Note normpath does not resolve symlinks — a symlinked base could still be a gap, but the lexical check catches the common cases.","solutions":["Inspect the command/prompt name being registered and remove any '..', leading '/', or absolute-path components.","Validate extension-provided names before install using the same rule (os.path.normpath(candidate).is_relative_to(base)).","If you maintain the extension, keep names to lowercase kebab-case identifiers with no path separators."],"exampleFix":"# before\nregistrar.register(project, agent, commands=[{\"name\": \"../../evil\", \"file\": \"x.md\"}])\n# after\nregistrar.register(project, agent, commands=[{\"name\": \"my-cmd\", \"file\": \"x.md\"}])","handlingStrategy":"validation","validationCode":"import os\nfrom pathlib import Path\n\ndef path_stays_inside(candidate: str, base: str) -> bool:\n    return Path(os.path.normpath(candidate)).is_relative_to(Path(os.path.normpath(base)))\n\n# reject before calling the registrar\nassert path_stays_inside(str(commands_dir / f\"{name}.md\"), str(commands_dir))","typeGuard":"import os\nfrom pathlib import Path\n\ndef is_safe_output_path(candidate: os.PathLike | str, base: os.PathLike | str) -> bool:\n    \"\"\"True when the normalized candidate stays lexically within base.\"\"\"\n    return Path(os.path.normpath(candidate)).is_relative_to(Path(os.path.normpath(base)))","tryCatchPattern":"try:\n    registrar.register_commands(...)\nexcept ValueError as exc:\n    if \"escapes directory\" in str(exc):\n        raise SystemExit(f\"unsafe output path rejected: {exc}\") from exc\n    raise","preventionTips":["Keep command/prompt names free of '..', separators, and absolute prefixes.","Validate extension-provided names against the base directory before install.","Never build output paths by concatenating untrusted strings; join and then containment-check."],"tags":["security","path-traversal","validation","commands"],"backgroundTag":null,"analyzedSha":"bf88c9f9a82fa370c7a7257aa2b3cf10b457b65c","analyzedAt":"2026-08-14T19:43:37.150Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}