{"record":{"id":"ac7b434b499bcb4b","repo":"affaan-m/ECC","slug":"output-directory-must-not-be-a-symlink-part","errorCode":null,"errorMessage":"output directory must not be a symlink: {part}","messagePattern":"output directory must not be a symlink: (.+?)","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"skills/taste-application/scripts/tasteforge/workflow.py","lineNumber":211,"sourceCode":"\n    def __del__(self) -> None:\n        self.close()\n\n    def _open_dir(self, parts: tuple[str, ...], *, create: bool) -> int:\n        current = os.dup(self._root_fd)\n        try:\n            for part in parts:\n                if not part or part in {\".\", \"..\"} or \"/\" in part:\n                    raise ValueError(\"output path contains an invalid component\")\n                try:\n                    metadata = os.stat(part, dir_fd=current, follow_symlinks=False)\n                except FileNotFoundError:\n                    if not create:\n                        raise ValueError(f\"missing output directory: {part}\") from None\n                    os.mkdir(part, mode=0o700, dir_fd=current)\n                    metadata = os.stat(part, dir_fd=current, follow_symlinks=False)\n                if stat.S_ISLNK(metadata.st_mode):\n                    raise ValueError(f\"output directory must not be a symlink: {part}\")\n                if not stat.S_ISDIR(metadata.st_mode):\n                    raise ValueError(f\"output intermediate must be a directory: {part}\")\n                child = os.open(\n                    part,\n                    os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,\n                    dir_fd=current,\n                )\n                os.close(current)\n                current = child\n            return current\n        except Exception:\n            os.close(current)\n            raise\n\n    def prepare(self, directories: tuple[str, ...]) -> None:\n        \"\"\"Validate every known intermediate before the first artifact write.\"\"\"\n        opened: list[int] = []\n        try:","sourceCodeStart":193,"sourceCodeEnd":229,"githubUrl":"https://github.com/affaan-m/ECC/blob/8321021c54d670126ce3b2969d5deb880b4b0c2a/skills/taste-application/scripts/tasteforge/workflow.py#L193-L229","documentation":"_open_dir stats each path component without following symlinks and rejects any component that turns out to be a symlink, even if it points to a directory within the tree. This prevents an attacker (or accident) from substituting a symlink for an expected output directory and redirecting writes outside the anchored root. The open with O_NOFOLLOW enforces the same guarantee at the fd level.","triggerScenarios":"An intermediate component of a path passed to prepare/write_json/artifact_metadata is a symlink — e.g. someone replaced out/runs with a symlink to another location, or the workflow earlier created a symlink at that name.","commonSituations":"Users pre-linking output subdirs to shared/scratch storage; a malicious or buggy prior process planting symlinks in the output tree; mounting configs via symlinked dirs inside the output root.","solutions":["Remove the symlink and let prepare recreate a real directory: os.remove(out/'runs') then safe.prepare(parts)","Point the whole output root at the real target location instead of linking individual subdirs","Investigate how the symlink appeared (shared output tree, concurrent processes) and isolate output directories per run","Keep the no-follow behavior; do not attempt to bypass it by copying into a linked path"],"exampleFix":"# before\nos.symlink('/mnt/shared/runs', 'out/runs')\nsafe.prepare(('runs', 'run1'))  # ValueError: symlink\n\n# after\nshutil.rmtree('out/runs', ignore_errors=True)\nsafe.prepare(('runs', 'run1'))  # creates a real directory inside the anchored root","handlingStrategy":"try-catch","validationCode":"def no_symlinks_in_path(root, parts):\n    cur = root\n    for p in parts:\n        cur = cur / p\n        if cur.is_symlink():\n            return False\n    return True","typeGuard":"def is_real_subdir(root, part: str) -> bool:\n    child = root / part\n    return not child.is_symlink() and child.is_dir()","tryCatchPattern":"try:\n    safe.prepare(parts)\nexcept ValueError as e:\n    if 'must not be a symlink' in str(e):\n        bad = Path(e.args[0].split(': ', 1)[1])\n        bad.unlink()\n        safe.prepare(parts)\n    else:\n        raise","preventionTips":["Never pre-create symlinks inside the output tree for shared storage","Give each run its own isolated output directory","Audit output trees for unexpected symlinks in multi-user environments","Keep the library's no-follow checks enabled; do not work around them"],"tags":["security","symlink","filesystem"],"backgroundTag":"path-traversal-blocked","analyzedSha":"8321021c54d670126ce3b2969d5deb880b4b0c2a","analyzedAt":"2026-09-16T10:08:13.343Z","contentChangedAt":"2026-09-16T10:08:13.343Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}