affaan-m/ECC · error · ValueError
output root must not be a symlink
Error message
output root must not be a symlink
What it means
_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.
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
Example fix
# before
out = _SafeOutput(Path('~/out')) # ~/out is a symlink
# after
out = _SafeOutput(Path('~/out').expanduser().resolve()) # real path, not a symlink Defensive patterns
Strategy: validation
Validate before calling
import os
def ensure_real_dir(p):
p = p.expanduser()
if p.is_symlink():
raise ValueError(f'{p} is a symlink; pass the real path')
return p.resolve() Type guard
def is_real_existing_dir(p) -> bool:
return p.exists() and not p.is_symlink() and p.is_dir() Try / catch
try:
out = _SafeOutput(root)
except ValueError as e:
if 'symlink' in str(e):
root = root.expanduser().resolve()
out = _SafeOutput(root)
else:
raise Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.
Related errors
- gate.variant_invalid
- output artifact must be a regular file
- output bundle contains a symlink
- output bundle root must not be a symlink
- output destination must not be a symlink
AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16).
Data as JSON: /api/errors/7729abdcfb9ed6fe.
Report an issue: GitHub.
Appendix: source
Thrown at skills/taste-application/scripts/tasteforge/workflow.py:178
or float(cast(float, sample["time"])) < 0
or float(cast(float, sample["time"])) > float(duration)
for sample in samples
):
raise ValueError("reference style evidence times must be finite and within duration")
return float(duration)
class _SafeOutput:
"""Descriptor-bound output tree with no-follow traversal and atomic writes."""
def __init__(self, root: Path) -> None:
self._root_fd = -1
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
raise RuntimeError("secure output requires O_NOFOLLOW and O_DIRECTORY")
if root.exists() or root.is_symlink():
metadata = root.lstat()
if stat.S_ISLNK(metadata.st_mode):
raise ValueError("output root must not be a symlink")
if not stat.S_ISDIR(metadata.st_mode):
raise ValueError("output root must be a directory")
else:
if not root.parent.is_dir():
raise ValueError("output parent directory must already exist")
root.mkdir(mode=0o700)
self.root = root
self._root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
self._written: list[str] = []
def close(self) -> None:
if self._root_fd >= 0:
os.close(self._root_fd)
self._root_fd = -1
def __del__(self) -> None:
self.close()
View on GitHub (pinned to 8321021c54)