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

  1. Pass the real (resolved) directory path: _SafeOutput(Path('/out').resolve()) or os.path.realpath
  2. Remove the symlink and use the actual target directory as the output root
  3. If a symlink is required, point the tool at the symlink's target directly
  4. 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

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


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)