{"record":{"id":"7694f3222d569122","repo":"datawhalechina/hello-agents","slug":"path-escapes-repo-root-rel-path","errorCode":null,"errorMessage":"Path escapes repo_root: {rel_path}","messagePattern":"Path escapes repo_root: (.+?)","errorType":"exception","errorClass":"PatchApplyError","httpStatus":null,"severity":"error","filePath":"Co-creation-projects/YYHDBL-HelloCodeAgentCli/code_agent/executors/apply_patch_executor.py","lineNumber":204,"sourceCode":"        \"\"\"\n        验证路径安全性，防止路径遍历攻击 (Path Traversal)。\n        确保目标路径在 repo_root 目录下，防止访问仓库外的文件。\n        \n        参数:\n            rel_path: 相对路径字符串\n            \n        返回:\n            Path: 安全的绝对路径对象\n            \n        异常:\n            PatchApplyError: 当路径是绝对路径、包含特殊字符或试图访问仓库外时抛出\n        \"\"\"\n        if rel_path.startswith(\"/\") or rel_path.startswith(\"~\"):\n            raise PatchApplyError(f\"Absolute paths are not allowed: {rel_path}\")\n        target = (self.repo_root / rel_path).resolve()\n        # 检查解析后的路径是否以 repo_root 开头\n        if not str(target).startswith(str(self.repo_root.resolve()) + os.sep) and target != self.repo_root.resolve():\n            raise PatchApplyError(f\"Path escapes repo_root: {rel_path}\")\n        if target.exists() and target.is_symlink():\n            raise PatchApplyError(f\"Refusing to modify symlink: {rel_path}\")\n        return target\n\n    def _enforce_suffix(self, target: Path) -> None:\n        \"\"\"\n        检查目标文件的后缀是否在允许的列表中。\n        防止意外修改二进制文件、配置文件或其他敏感文件。\n        \n        参数:\n            target: 目标文件路径对象\n            \n        异常:\n            PatchApplyError: 当文件后缀不在允许列表中时抛出\n        \"\"\"\n        if target.suffix and target.suffix not in self.allowed_write_suffixes:\n            raise PatchApplyError(f\"Disallowed file suffix for write: {target.suffix}\")\n","sourceCodeStart":186,"sourceCodeEnd":222,"githubUrl":"https://github.com/datawhalechina/hello-agents/blob/606a07d341a47be773fab7f4b71177f53f96b2c3/Co-creation-projects/YYHDBL-HelloCodeAgentCli/code_agent/executors/apply_patch_executor.py#L186-L222","documentation":"Raised by _safe_path when (repo_root / rel_path).resolve() falls outside repo_root after symlink resolution — the classic path-traversal defense. It fires even though the raw string is relative, because components like '../' (or a symlinked directory pointing outside) resolve to a target not under repo_root. Note the check compares string prefixes with os.sep appended, which also rejects a sibling directory whose name starts with repo_root's name.","triggerScenarios":"rel_path containing '../' sequences (e.g. '../../etc/cron.d/x'), a path that resolves to repo_root itself being rejected only when it equals neither condition, or a symlink inside the repo pointing to an external directory that resolve() follows.","commonSituations":"LLM patches copying '../../../' paths from stack traces; repos containing convenience symlinks to shared code outside the repo; repo_root passed as a non-canonical path (e.g. './proj' vs '/abs/proj') so prefix comparison behaves unexpectedly.","solutions":["Rewrite the patch path to a clean repo-relative path with no '..' components.","Ensure repo_root is passed to the executor as a fully resolved absolute path (Path(...).resolve()).","If a symlink inside the repo must be traversed, replace it with the real location inside repo_root or move the dependency into the repo.","Pre-validate rel_path with posixpath normalization and reject '..' before invoking apply."],"exampleFix":"# before\nrel_path = '../../shared/lib.py'  # escapes repo_root\n\n# after\nimport posixpath\nrel_path = posixpath.normpath(rel_path)\nassert not rel_path.startswith('..'), 'path escapes repo'\n# use 'shared/lib.py' copied into the repo instead","handlingStrategy":"validation","validationCode":"import posixpath\n\ndef safe_rel(rel_path: str) -> str:\n    if rel_path.startswith(('/', '~')):\n        raise ValueError('absolute path')\n    norm = posixpath.normpath(rel_path)\n    if norm.startswith('..') or posixpath.isabs(norm):\n        raise ValueError(f'escapes repo_root: {rel_path}')\n    return norm","typeGuard":"def stays_in_repo(rel_path: str, repo_root: Path) -> bool:\n    target = (repo_root / rel_path).resolve()\n    root = repo_root.resolve()\n    return target == root or str(target).startswith(str(root) + os.sep)","tryCatchPattern":"try:\n    executor.apply(patch)\nexcept PatchApplyError as e:\n    if 'escapes repo_root' in str(e):\n        raise PermissionError(f'rejected traversal attempt: {e}')  # do not retry\n    raise","preventionTips":["Pass repo_root as an already-resolved absolute Path.","Normalize and reject '..' components before building patches.","Treat this error as a security signal, never as a retryable failure."],"tags":["patch","path-traversal","security","apply-patch"],"backgroundTag":null,"analyzedSha":"606a07d341a47be773fab7f4b71177f53f96b2c3","analyzedAt":"2026-08-14T22:57:27.446Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}