affaan-m/ECC · error · ValueError

artifact path must stay beneath output root

Error message

artifact path must stay beneath output root

What it means

write_json validates that the requested relative artifact path is relative, names a real file component, and contains no '.' or '..' segments. This is a fail-closed path-traversal guard so artifacts can never escape the output root.

Solutions

  1. Ensure the path passed to write_json is relative to the output root and contains no '..' or '.' segments
  2. Use pathlib Path arithmetic on a relative base and assert not path.is_absolute() before calling
  3. Strip/normalize user-supplied filename fragments before constructing artifact paths

Example fix

# before
writer.write_json(os.path.join(base_dir, "manifest.json"), payload)  # absolute path
# after
rel = Path("manifest.json")
assert not rel.is_absolute() and ".." not in rel.parts
writer.write_json(str(rel), payload)
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path
def safe_relative(rel: str) -> Path:
    p = Path(rel)
    if p.is_absolute() or not p.name or any(part in {".", ".."} for part in p.parts):
        raise ValueError(f"unsafe artifact path: {rel}")
    return p

Type guard

def is_safe_artifact_path(rel: str) -> bool:
    p = Path(rel)
    return (not p.is_absolute() and bool(p.name)
            and not any(part in {".", ".."} for part in p.parts))

Try / catch

try:
    writer.write_json(relative, payload)
except ValueError as e:
    logging.error("rejected artifact path %r: %s", relative, e)
    raise

Prevention

When it happens

Trigger: Passing an absolute path (e.g. '/etc/x.json'), a path with a '..' segment, a trailing-segment-only path like 'dir/' (empty name), or a path with '.' components to write_json(relative, payload).

Common situations: Building artifact paths by string concatenation from user/config input; joining an absolute base path with a relative name using os.path.join (which yields the absolute path when the second arg is absolute); template configs that include '../' segments.

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

Appendix: source

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

            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:
            for directory in directories:
                opened.append(self._open_dir((directory,), create=True))
        finally:
            for descriptor in opened:
                os.close(descriptor)

    def write_json(self, relative: str, payload: Any) -> None:
        path = Path(relative)
        if path.is_absolute() or not path.name or any(part in {".", ".."} for part in path.parts):
            raise ValueError("artifact path must stay beneath output root")
        parent_fd = self._open_dir(tuple(path.parts[:-1]), create=False)
        temporary = f".{path.name}.tmp-{secrets.token_hex(8)}"
        descriptor = -1
        try:
            try:
                existing = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False)
            except FileNotFoundError:
                existing = None
            if existing is not None and not stat.S_ISREG(existing.st_mode):
                raise ValueError(f"output artifact must be a regular file: {relative}")
            data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n"
            descriptor = os.open(
                temporary,
                os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
                0o600,
                dir_fd=parent_fd,
            )
            view = memoryview(data)

View on GitHub (pinned to 8321021c54)