affaan-m/ECC · error · ValueError

output directory must not be a symlink

Error message

output directory must not be a symlink: {part}

What it means

_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.

Solutions

  1. Remove the symlink and let prepare recreate a real directory: os.remove(out/'runs') then safe.prepare(parts)
  2. Point the whole output root at the real target location instead of linking individual subdirs
  3. Investigate how the symlink appeared (shared output tree, concurrent processes) and isolate output directories per run
  4. Keep the no-follow behavior; do not attempt to bypass it by copying into a linked path

Example fix

# before
os.symlink('/mnt/shared/runs', 'out/runs')
safe.prepare(('runs', 'run1'))  # ValueError: symlink

# after
shutil.rmtree('out/runs', ignore_errors=True)
safe.prepare(('runs', 'run1'))  # creates a real directory inside the anchored root
Defensive patterns

Strategy: try-catch

Validate before calling

def no_symlinks_in_path(root, parts):
    cur = root
    for p in parts:
        cur = cur / p
        if cur.is_symlink():
            return False
    return True

Type guard

def is_real_subdir(root, part: str) -> bool:
    child = root / part
    return not child.is_symlink() and child.is_dir()

Try / catch

try:
    safe.prepare(parts)
except ValueError as e:
    if 'must not be a symlink' in str(e):
        bad = Path(e.args[0].split(': ', 1)[1])
        bad.unlink()
        safe.prepare(parts)
    else:
        raise

Prevention

When it happens

Trigger: 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.

Common situations: 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.

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/ac7b434b499bcb4b. Report an issue: GitHub.

Appendix: source

Thrown at skills/taste-application/scripts/tasteforge/workflow.py:211

    def __del__(self) -> None:
        self.close()

    def _open_dir(self, parts: tuple[str, ...], *, create: bool) -> int:
        current = os.dup(self._root_fd)
        try:
            for part in parts:
                if not part or part in {".", ".."} or "/" in part:
                    raise ValueError("output path contains an invalid component")
                try:
                    metadata = os.stat(part, dir_fd=current, follow_symlinks=False)
                except FileNotFoundError:
                    if not create:
                        raise ValueError(f"missing output directory: {part}") from None
                    os.mkdir(part, mode=0o700, dir_fd=current)
                    metadata = os.stat(part, dir_fd=current, follow_symlinks=False)
                if stat.S_ISLNK(metadata.st_mode):
                    raise ValueError(f"output directory must not be a symlink: {part}")
                if not stat.S_ISDIR(metadata.st_mode):
                    raise ValueError(f"output intermediate must be a directory: {part}")
                child = os.open(
                    part,
                    os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
                    dir_fd=current,
                )
                os.close(current)
                current = child
            return current
        except Exception:
            os.close(current)
            raise

    def prepare(self, directories: tuple[str, ...]) -> None:
        """Validate every known intermediate before the first artifact write."""
        opened: list[int] = []
        try:

View on GitHub (pinned to 8321021c54)