{"record":{"id":"7729abdcfb9ed6fe","repo":"affaan-m/ECC","slug":"output-root-must-not-be-a-symlink","errorCode":null,"errorMessage":"output root must not be a symlink","messagePattern":"output root must not be a symlink","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"skills/taste-application/scripts/tasteforge/workflow.py","lineNumber":178,"sourceCode":"        or float(cast(float, sample[\"time\"])) < 0\n        or float(cast(float, sample[\"time\"])) > float(duration)\n        for sample in samples\n    ):\n        raise ValueError(\"reference style evidence times must be finite and within duration\")\n    return float(duration)\n\n\nclass _SafeOutput:\n    \"\"\"Descriptor-bound output tree with no-follow traversal and atomic writes.\"\"\"\n\n    def __init__(self, root: Path) -> None:\n        self._root_fd = -1\n        if not hasattr(os, \"O_NOFOLLOW\") or not hasattr(os, \"O_DIRECTORY\"):\n            raise RuntimeError(\"secure output requires O_NOFOLLOW and O_DIRECTORY\")\n        if root.exists() or root.is_symlink():\n            metadata = root.lstat()\n            if stat.S_ISLNK(metadata.st_mode):\n                raise ValueError(\"output root must not be a symlink\")\n            if not stat.S_ISDIR(metadata.st_mode):\n                raise ValueError(\"output root must be a directory\")\n        else:\n            if not root.parent.is_dir():\n                raise ValueError(\"output parent directory must already exist\")\n            root.mkdir(mode=0o700)\n        self.root = root\n        self._root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)\n        self._written: list[str] = []\n\n    def close(self) -> None:\n        if self._root_fd >= 0:\n            os.close(self._root_fd)\n            self._root_fd = -1\n\n    def __del__(self) -> None:\n        self.close()\n","sourceCodeStart":160,"sourceCodeEnd":196,"githubUrl":"https://github.com/affaan-m/ECC/blob/8321021c54d670126ce3b2969d5deb880b4b0c2a/skills/taste-application/scripts/tasteforge/workflow.py#L160-L196","documentation":"_SafeOutput refuses to use an output root that is a symbolic link, even when it points to a directory. Because all writes are anchored to an opened directory descriptor with O_NOFOLLOW, a symlinked root would defeat the no-follow protections and could redirect output outside the intended tree. The check uses lstat, so it detects symlinks that os.path.exists would follow and miss.","triggerScenarios":"Creating _SafeOutput(Path('/out')) where /out is a symlink to another directory, e.g. a convenience link like ~/outputs/tasteforge or a Docker/deployment path replaced with a symlink.","commonSituations":"Deployments that symlink output dirs to a mounted volume; macOS symlinked temp dirs (/tmp -> /private/tmp) on the root path; users passing a symlinked workspace folder as --output.","solutions":["Pass the real (resolved) directory path: _SafeOutput(Path('/out').resolve()) or os.path.realpath","Remove the symlink and use the actual target directory as the output root","If a symlink is required, point the tool at the symlink's target directly","Recreate the output directory as a real directory instead of a link"],"exampleFix":"# before\nout = _SafeOutput(Path('~/out'))          # ~/out is a symlink\n\n# after\nout = _SafeOutput(Path('~/out').expanduser().resolve())  # real path, not a symlink","handlingStrategy":"validation","validationCode":"import os\ndef ensure_real_dir(p):\n    p = p.expanduser()\n    if p.is_symlink():\n        raise ValueError(f'{p} is a symlink; pass the real path')\n    return p.resolve()","typeGuard":"def is_real_existing_dir(p) -> bool:\n    return p.exists() and not p.is_symlink() and p.is_dir()","tryCatchPattern":"try:\n    out = _SafeOutput(root)\nexcept ValueError as e:\n    if 'symlink' in str(e):\n        root = root.expanduser().resolve()\n        out = _SafeOutput(root)\n    else:\n        raise","preventionTips":["Always pass Path(...).resolve() as the output root","Do not symlink deployment output directories; bind-mount or configure the real path instead","Be aware /tmp is a symlink on macOS (/private/tmp) — resolve before use","Check for symlinks in shared CI artifact directories before runs"],"tags":["security","filesystem","symlink"],"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"}