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
- 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
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
- 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
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
- 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/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)