affaan-m/ECC · error · ValueError

artifact path must be canonical and absolute

Error message

artifact path must be canonical and absolute

What it means

`_read_local` refuses to open any artifact whose path string is not already a canonical absolute path. It rejects relative paths, symlink-style segments such as `..`, and non-canonical spellings (e.g. trailing slashes, `//`, `/a/./b`). The library throws this to guarantee that the TOCTOU-safe `dir_fd`-based reading logic operates on exactly one unambiguous filesystem location.

Solutions

  1. Normalize and absolutize first: `raw = str(Path(raw).expanduser().resolve())`, ensuring it does not escape the intended directory.
  2. Use `os.path.realpath` to canonicalize, then confirm `".." not in Path(raw).parts`.
  3. Reject or resolve relative paths at the configuration boundary (CLI parser / config loader) before they reach the artifact reader.

Example fix

// before
load_application_request("../out/request.json")
// after
from pathlib import Path
canonical = str(Path("../out/request.json").expanduser().resolve())
load_application_request(canonical)
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path, PurePosixPath
def canonical_absolute(raw: str) -> str:
    p = Path(raw).expanduser().resolve()
    s = str(p)
    if not p.is_absolute() or s != raw and ".." in PurePosixPath(raw).parts:
        raise ValueError(f"path must be canonical and absolute: {raw!r}")
    return s

Type guard

def is_canonical_absolute(raw: str) -> bool:
    from pathlib import Path
    p = Path(raw)
    return p.is_absolute() and str(p) == raw and ".." not in p.parts

Try / catch

try:
    req = load_application_request(raw_path)
except ValueError as e:
    if str(e) == "artifact path must be canonical and absolute":
        req = load_application_request(str(Path(raw_path).expanduser().resolve()))
    else:
        raise

Prevention

When it happens

Trigger: Passing a relative path like `build/artifact.json`; a path containing `..` (e.g. `/tmp/build/../artifact.json`); a non-normalized string such as `/tmp//artifact.json` or `/tmp/artifact.json/` to `_read_local` via `_artifact` or `load_application_request`.

Common situations: Building paths by string concatenation instead of `os.path.abspath`/`Path.resolve()`; accepting a user-supplied path from a CLI flag or config and forwarding it as-is; environment-relative defaults (`~`, `$VAR`) left unexpanded.

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

Appendix: source

Thrown at skills/taste-application/scripts/tasteforge/integration.py:116

def _parent_fd(path: Path) -> int:
    flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_DIRECTORY
    parent = os.open(path.anchor, flags)
    try:
        for part in path.parts[1:-1]:
            child = os.open(part, flags, dir_fd=parent)
            os.close(parent)
            parent = child
        return parent
    except BaseException:
        os.close(parent)
        raise


def _read_local(raw: str, *, parse_json: bool, expected_size: int | None = None,
                expected_hash: str | None = None) -> Any:
    path = Path(raw)
    if not path.is_absolute() or str(path) != raw or ".." in path.parts:
        raise ValueError("artifact path must be canonical and absolute")
    parent = descriptor = None
    try:
        flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
        parent = _parent_fd(path)
        before = os.stat(path.name, dir_fd=parent, follow_symlinks=False)
        if not stat.S_ISREG(before.st_mode) or getattr(before, "st_flags", 0) & 0x40000000:
            raise ValueError("artifact must be a resident regular file")
        if expected_size is None:
            expected_size = before.st_size
        if parse_json and expected_size > _MAX_JSON:
            raise ValueError("JSON artifact exceeds local size limit")
        if before.st_size != expected_size:
            raise ValueError("artifact byte count mismatch")
        descriptor = os.open(path.name, flags, dir_fd=parent)
        if _identity(before) != _identity(os.fstat(descriptor)):
            raise ValueError("artifact changed before reading")
        digest, chunks, count = hashlib.sha256(), [], 0
        while data := os.read(descriptor, 65536):

View on GitHub (pinned to 8321021c54)