AID-H-003.006: Model SBOM & Provenance Attestation

Version 1.20261015 · Actionable sub-technique

Produce a model-centric SBOM that inventories model weights, tokenizer and special-token files, chat template, generation configuration, tool/reasoning parser configuration, model format, and loader code commit; bind every item to the exact release digest through signed in-toto/SLSA provenance.

Interoperability: keep one internal canonical manifest for enforcement, then export standards-compatible AI-BOM formats such as CycloneDX ML-BOM 1.7 and SPDX 3 AI Profile for procurement, regulator, partner exchange, and technical documentation.

Verified composition: independently verify the artifact-bound attestation and signed composition, including source status, model format, and loader claims. Supply those results to the canonical release or runtime gate, which decides whether the exact verified bytes may be promoted or loaded. This creates a tamper-evident content contract for the complete model interface, not only the weights.

Pillars: infra, model

Lifecycle: building, validation, operation

Scope boundary

Owns complete artifact-bound model composition and provenance production, publication, and cryptographic verification evidence. It supplies exact-byte, source-tombstone, format, and loader-policy verification results to the canonical release or load gate; it neither promotes a release nor grants a runtime loader access.

AID-M-001.002: AID-H-003.006 attests the composition and provenance of one exact model release artifact; AID-M-001.002 maintains the broader system dependency map and deployed release composition. A model SBOM is one artifact-level evidence object, not the complete service and trust-boundary dependency graph.

AID-H-003.002: AID-H-003.006 produces and verifies model composition and provenance evidence; AID-H-003.002 makes and enforces the final promotion, mirror-refresh, deployment, load, and reload decision. A composition, tombstone, or loader-policy verification result is a necessary input where policy requires it, not a second authorization or duplicate admission credit.

AID-H-003.007: AID-H-003.006 describes the complete model release and its components; AID-H-003.007 applies adapter and PEFT-specific identity, base-model binding, provenance, and compatibility admission. An adapter can have an independent supply chain even when listed in the model SBOM.

Implementation guidance

AID-H-003.006-G001

Create a deterministic, bounded model package by streaming all required interface members under the same signed population policy used by transfer preflight and the load gate.
Install and provision the shared verifier before packaging

Install the named model_sbom and runtime modules from G001/G006/G009 before invoking the builder: it verifies the same signed loader-policy v3 as the final gate. Install Cosign plus the pinned ONNX and safetensors parser packages. Provision the independently controlled /opt/aidefend/trust/release-policy.identity and release-policy.issuer files, and resolve VERIFIED_H003_MODEL_ADMISSION_BLOB_VERIFY_TIMEOUT_SECONDS from the verified deployment profile. Supply the signed loader/release policy files and bundles to the documented CLI. Unavailable trust, dependencies or policy verification stops packaging. Remove unapproved VCS metadata such as .gitattributes from the export; do not broaden the signed data-only interface to admit executable files.

Capture one complete immutable export

Build in an immutable private CI workspace. Install the shared module below with the packaging and loader modules. Resolve the signed loader-policy v3 with load_population_policy; pass that verified object to package(..., population_policy=policy, source_date_epoch=...). The same signed package_interface is used by transfer preflight and the final gate. It declares allowed data suffixes, documentation names, and required package names; include .md and LICENSE when present in the approved export. Reject an undeclared file before writing an archive; never silently omit configuration. Package all model weights and the exact tokenizer, chat template, generation configuration, and installed tool/reasoning-parser configuration. Take file-count, member-byte, total-byte limits and the reproducibility epoch from the reviewed build profile. Stream large shards instead of allocating a shard-sized Python buffer.

# File: model_sbom/package_model.py
from __future__ import annotations
import argparse
import hashlib
import json
import os
import stat
import tarfile
from pathlib import Path
from model_sbom.interface_population import PopulationPolicy, load_population_policy, require_member, require_population

def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()

class HashingReader:
    def __init__(self, stream):
        self.stream, self.digest, self.count = stream, hashlib.sha256(), 0
    def read(self, size=-1):
        chunk = self.stream.read(size)
        self.digest.update(chunk); self.count += len(chunk)
        return chunk


def package(root: Path, output: Path, *, population_policy: PopulationPolicy, source_date_epoch: int, max_files: int | None = None, max_file_bytes: int | None = None, max_total_bytes: int | None = None) -> dict:
    if not isinstance(population_policy, PopulationPolicy): raise ValueError("verified population policy is required")
    signed_bounds = population_policy.document["artifact_limits"]
    max_files = signed_bounds["max_files"] if max_files is None else max_files
    max_file_bytes = signed_bounds["max_file_bytes"] if max_file_bytes is None else max_file_bytes
    max_total_bytes = signed_bounds["max_total_bytes"] if max_total_bytes is None else max_total_bytes
    if (max_files > signed_bounds["max_files"] or max_file_bytes > signed_bounds["max_file_bytes"]
            or max_total_bytes > signed_bounds["max_total_bytes"]):
        raise ValueError("caller bounds cannot relax the signed loader policy")
    if any(type(v) is not int or v <= 0 for v in (max_files, max_file_bytes, max_total_bytes)):
        raise ValueError("package bounds must be positive integers")
    if root.is_symlink() or not root.is_dir() or output.exists() or type(source_date_epoch) is not int or source_date_epoch < 0:
        raise ValueError("root, output, or signed epoch is invalid")
    resolved = root.resolve(strict=True)
    if output.parent.resolve(strict=True) == resolved or output.parent.resolve(strict=True).is_relative_to(resolved):
        raise ValueError("package output must be outside model root")
    members, seen = [], set()
    for path in resolved.rglob("*"):
        if path.is_symlink():
            raise ValueError("model symlink is forbidden")
        if path.is_file():
            relative = path.relative_to(resolved).as_posix()
            require_member(relative, population_policy.document)
            if relative.casefold() in seen:
                raise ValueError("duplicate normalized path")
            seen.add(relative.casefold())
            members.append((relative, path))
            if len(members) > max_files:
                raise ValueError("model file-count exceeds builder limit")
        elif not path.is_dir():
            raise ValueError("non-regular model member is forbidden")
    members.sort(key=lambda item: item[0])
    if not members:
        raise ValueError("model population is empty")
    require_population([name for name, _path in members], population_policy.document)
    rows, total_bytes = [], 0
    with tarfile.open(output, mode="x", format=tarfile.PAX_FORMAT) as archive:
        for relative, path in members:
            flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
            fd = os.open(path, flags)
            with os.fdopen(fd, "rb", closefd=True) as handle:
                opened = os.fstat(handle.fileno())
                if not stat.S_ISREG(opened.st_mode) or opened.st_size > max_file_bytes:
                    raise ValueError("model member is not a bounded regular file")
                total_bytes += opened.st_size
                if total_bytes > max_total_bytes:
                    raise ValueError("model package exceeds total-byte limit")
                info = tarfile.TarInfo(relative)
                info.size, info.mtime = opened.st_size, source_date_epoch
                info.mode, info.uid, info.gid = 0o644, 0, 0
                info.uname = info.gname = ""
                captured = HashingReader(handle)
                archive.addfile(info, captured)
                current = os.fstat(handle.fileno())
                if (captured.count != opened.st_size or current.st_size != opened.st_size
                        or current.st_mtime_ns != opened.st_mtime_ns):
                    raise RuntimeError("model member changed during capture")
                rows.append({"path": relative, "sha256": captured.digest.hexdigest(), "size_bytes": captured.count})
    if output.stat().st_size > signed_bounds["max_artifact_bytes"]:
        raise ValueError("completed package exceeds the signed artifact-byte bound; do not publish")
    receipt = {"schema_version": "aidefend.deterministic-model-package.v1", "source_date_epoch": source_date_epoch, "loader_policy_sha256": population_policy.sha256, "file_count": len(rows), "tree_sha256": hashlib.sha256(json.dumps(rows, sort_keys=True, separators=(",", ":")).encode()).hexdigest(), "package_sha256": sha256_file(output), "package_bytes": output.stat().st_size}
    return receipt


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--model-root", required=True, type=Path)
    parser.add_argument("--output", required=True, type=Path)
    parser.add_argument("--source-date-epoch", required=True, type=int)
    for name in ("loader-policy", "loader-bundle", "release-policy", "release-bundle"):
        parser.add_argument("--" + name, required=True, type=Path)
    args = parser.parse_args()
    policy = load_population_policy(args.loader_policy, args.loader_bundle, args.release_policy, args.release_bundle)
    receipt = package(args.model_root, args.output, population_policy=policy, source_date_epoch=args.source_date_epoch)
    print(json.dumps(receipt, sort_keys=True, separators=(",", ":"), allow_nan=False))


if __name__ == "__main__":
    main()
Verify before publishing

Run twice in clean builders and require byte-identical package digests. Sign the output digest, builder image, packaging code, source tree and epoch policy. Keep the output outside the export tree; reject symlinks, special files, case-colliding names, mutation or exceeded bounds. Never publish a partial archive left by an interrupted builder.

Shared signed interface population

The release authority signs loader-policy v3. For a safetensors package, a deployable profile can use package_interface: {allowed_data_suffixes: [".json", ".txt", ".model", ".jinja", ".jinja2", ".tiktoken", ".vocab", ".merges", ".md"], allowed_data_names: ["LICENSE", "NOTICE"], required_package_names: ["config.json", "tokenizer.json"]} and loader_artifact_formats: {"transformers-safetensors-package": ".tar", "safetensors-single": ".safetensors"}. Add exact required chat-template, generation-config or installed parser files for the actual release. Documentation is retained and fingerprinted as data; it is never executed. Sign the complete policy including limits and loader flags with the pinned loader-policy identity.

# File: model_sbom/interface_population.py
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path, PurePosixPath

DATA_SUFFIXES = {".json", ".txt", ".model", ".jinja", ".jinja2", ".tiktoken", ".vocab", ".merges", ".md"}
DATA_NAMES = {"LICENSE", "NOTICE", "README", "COPYING"}

def canonical_name(name: str) -> str:
    path = PurePosixPath(name)
    if (not isinstance(name, str) or not name or path.is_absolute() or path.as_posix() != name
            or any(part in (".", "..") for part in name.split("/")) or "\\" in name or ":" in name):
        raise ValueError("interface member path is not canonical")
    return name

def validate_population_policy(policy: dict) -> None:
    if policy.get("schema_version") != "aidefend.model-loader-policy.v3":
        raise ValueError("interface policy requires reviewed loader-policy v3")
    interface = policy.get("package_interface")
    fields = {"allowed_data_suffixes", "allowed_data_names", "required_package_names"}
    if not isinstance(interface, dict) or set(interface) != fields:
        raise ValueError("package interface schema differs")
    for name in fields:
        values = interface[name]
        if (not isinstance(values, list) or any(not isinstance(value, str) or not value for value in values)
                or len(values) != len(set(values))):
            raise ValueError("interface lists must contain unique nonempty strings")
    if not set(interface["allowed_data_suffixes"]).issubset(DATA_SUFFIXES):
        raise ValueError("interface suffix does not have a supported data-only role")
    if not set(interface["allowed_data_names"]).issubset(DATA_NAMES):
        raise ValueError("extensionless interface entry has no supported documentation role")
    for name in interface["required_package_names"]: canonical_name(name)
    for name in interface["required_package_names"]: require_member(name, policy)

def require_member(name: str, policy: dict) -> str:
    name = canonical_name(name)
    path = PurePosixPath(name)
    interface = policy["package_interface"]
    weights = set(policy["allowed_static_formats"]) | set(policy["exception_formats"])
    if (path.suffix.lower() not in weights | set(interface["allowed_data_suffixes"])
            and path.name not in interface["allowed_data_names"]):
        raise PermissionError("member is outside the signed package interface: " + name)
    return name

def require_population(names, policy: dict) -> None:
    validate_population_policy(policy)
    actual = set(names)
    for name in actual: require_member(name, policy)
    if not set(policy["package_interface"]["required_package_names"]).issubset(actual):
        raise PermissionError("package omits a required signed interface member")

@dataclass(frozen=True)
class PopulationPolicy:
    document: dict
    sha256: str

def load_population_policy(loader_policy: Path, loader_bundle: Path,
                           release_policy: Path, release_bundle: Path) -> PopulationPolicy:
    # The deployment-authority trust root and loader signer are resolved by the
    # same verifier as the final load gate, never supplied by the model export.
    from runtime.verify_model_loader_policy import load_loader_policy
    policy, _trust, raw, _trust_raw = load_loader_policy(loader_policy, loader_bundle, release_policy, release_bundle)
    validate_population_policy(policy)
    return PopulationPolicy(policy, hashlib.sha256(raw).hexdigest())
Run the installed module from the source root

Install the shown modules under their declared package paths with __init__.py files, or install the package into the builder image. These commands use python -m from that package root, so sibling namespaces resolve without adding the source root to PYTHONPATH. Resolve the epoch from the signature-verified build profile; byte and population limits come directly from the verified loader policy.

python -m model_sbom.package_model \
  --model-root model_export \
  --output model.tar \
  --source-date-epoch "$VERIFIED_H003_MODEL_SOURCE_DATE_EPOCH" \
  --loader-policy policy/model-loader-policy.json \
  --loader-bundle policy/model-loader-policy.sigstore.json \
  --release-policy policy/model-release-trust.json \
  --release-bundle policy/model-release-trust.sigstore.json

AID-H-003.006-G002

Build the canonical model SBOM from captured bytes, distinguish container and weight formats, and export a signed tree/tensor fingerprint manifest for supported safetensors interfaces.
Inventory the exact captured package

The G001 PAX archive has artifact_format=.tar; its weight members can have model_format=safetensors. Hash the archive members actually published, rather than rereading the mutable export directory. The default path preserves the complete model interface. A true single-file architecture can explicitly select the single-file path; this does not attest a missing tokenizer or application configuration.

# File: model_sbom/build_sbom.py
from __future__ import annotations
import hashlib
import json
import os
import tarfile
from pathlib import Path, PurePosixPath


def build_model_sbom(artifact_path: str, *, model_format: str, artifact_format=".tar") -> dict:
    """Inventory the actual G001 archive, never the later mutable export tree."""
    artifact, files, seen = Path(artifact_path), [], set()
    if model_format not in {"safetensors", "onnx"}: raise ValueError("unsupported static weight format")
    if artifact_format == "." + model_format:
        digest = hashlib.sha256()
        with artifact.open("rb") as source:
            while chunk := source.read(1024 * 1024): digest.update(chunk)
        files = [{"path": artifact.name, "sha256": digest.hexdigest(), "size_bytes": artifact.stat().st_size}]
    elif artifact_format != ".tar":
        raise ValueError("container and weight formats disagree")
    else:
        with tarfile.open(artifact, mode="r:") as archive:
            for member in archive:
                name = member.name
                path = PurePosixPath(name)
                if (not member.isfile() or member.linkname or path.is_absolute() or path.as_posix() != name
                        or any(part in (".", "..") for part in name.split("/")) or "\\" in name or ":" in name
                        or name.casefold() in seen):
                    raise ValueError("noncanonical, linked, special, or duplicate package member")
                seen.add(name.casefold())
                digest, count = hashlib.sha256(), 0
                with archive.extractfile(member) as source:
                    while chunk := source.read(1024 * 1024): digest.update(chunk); count += len(chunk)
                if count != member.size: raise ValueError("truncated package member")
                files.append({"path": name, "sha256": digest.hexdigest(), "size_bytes": count})
    files.sort(key=lambda row: row["path"])
    if not files or not any(row["path"].endswith("." + model_format) for row in files):
        raise ValueError("package contains no declared model weights")
    digest = hashlib.sha256()
    with artifact.open("rb") as source:
        while chunk := source.read(1024 * 1024): digest.update(chunk)
    by_name = {row["path"]: row["sha256"] for row in files}
    return {
        "schema_version": "aidefend.model-sbom.v2", "artifactDigest": "sha256:" + digest.hexdigest(),
        "artifact_format": artifact_format, "model_format": model_format,
        "framework": {"name": "transformers", "version": os.environ["TRANSFORMERS_VERSION"]},
        "files": files, "tokenizer_hash": by_name.get("tokenizer.json"), "config_hash": by_name.get("config.json"),
        "loader_commit": os.environ["GIT_COMMIT_SHA"], "license": os.environ.get("MODEL_LICENSE", "unknown"),
        "source_url": os.environ["MODEL_SOURCE_URL"], "trust_remote_code": False,
    }


if __name__ == "__main__":
    sbom = build_model_sbom("./model.tar", model_format="safetensors")
    Path("model-sbom.json").write_text(json.dumps(sbom, indent=2, allow_nan=False), encoding="utf-8")
Bind and verify

Sign the canonical SBOM and attest the same artifact digest using G004. Recompute the exact archive/member population independently. The aidefend.model-sbom.v2 schema adds explicit container format; regenerate and semantically review v1 SBOMs rather than relabeling old receipts.

Export the signed ownership fingerprint for AID-H-013.002

This producer remains owned by AID-H-003.006. Use the immutable CI export that generated the admitted archive, or the canonical gate's verified member_root while its context remains open. Keep the manifest and bundle outside that root. Pin PyTorch/safetensors versions in the builder and independent consumer; canonical tensor names and bytes are independent of shard filenames. The signing worker uses the approved OIDC identity; readback resolves MODEL_FINGERPRINT_CERTIFICATE_IDENTITY and MODEL_FINGERPRINT_OIDC_ISSUER from protected deployment policy. The producer, its replay verifier and H-013.002 all use the same exact aidefend.model-fingerprint-manifest.v1 contract and certificate trust model.

# File: model_sbom/model_fingerprint_manifest.py
from __future__ import annotations
import math

import argparse
import hashlib
import json
import os
import re
import subprocess
import tempfile
from contextlib import ExitStack
from datetime import datetime, timezone
from pathlib import Path

import torch
from safetensors import safe_open

def _required_positive_seconds(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number of seconds")
    return value

MODEL_SBOM_SIGN_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H003_MODEL_SBOM_SIGN_TIMEOUT_SECONDS")
MODEL_SBOM_SIGNATURE_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H003_MODEL_SBOM_SIGNATURE_VERIFY_TIMEOUT_SECONDS")
MODEL_SBOM_INPUT_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H003_MODEL_SBOM_INPUT_VERIFY_TIMEOUT_SECONDS")


SCHEMA = "aidefend.model-fingerprint-manifest.v1"
FIELDS = {
    "schema_version", "manifest_version", "model_id", "release_id",
    "artifact_tree_sha256", "tensor_state_sha256",
    "safetensors_file_count", "tensor_count",
    "tokenizer_sha256", "config_sha256", "not_after",
}
SHA256 = re.compile(r"[a-f0-9]{64}")
NON_SAFETENSORS_WEIGHT_SUFFIXES = {
    ".bin", ".ckpt", ".gguf", ".onnx", ".pickle", ".pkl", ".pt", ".pth",
}


def canonical(value: object) -> bytes:
    return json.dumps(
        value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
    ).encode("utf-8")


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def parse_not_after(value: str) -> datetime:
    if not isinstance(value, str) or not value:
        raise ValueError("not_after must be a non-empty string")
    parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    if parsed.tzinfo is None or parsed.utcoffset() is None:
        raise ValueError("not_after must be timezone-aware")
    parsed = parsed.astimezone(timezone.utc)
    if parsed <= datetime.now(timezone.utc):
        raise ValueError("not_after must be in the future")
    return parsed


def validate_manifest(manifest: dict) -> None:
    if not isinstance(manifest, dict) or set(manifest) != FIELDS:
        raise ValueError("fingerprint manifest fields differ")
    if manifest["schema_version"] != SCHEMA:
        raise ValueError("fingerprint manifest schema differs")
    for field in ("manifest_version", "model_id", "release_id"):
        if not isinstance(manifest[field], str) or not manifest[field]:
            raise ValueError(f"{field} must be a non-empty string")
    for field in (
        "artifact_tree_sha256", "tensor_state_sha256",
        "tokenizer_sha256", "config_sha256",
    ):
        if not isinstance(manifest[field], str) or not SHA256.fullmatch(manifest[field]):
            raise ValueError(f"{field} must be lower-case SHA-256")
    for field in ("safetensors_file_count", "tensor_count"):
        if (
            isinstance(manifest[field], bool)
            or not isinstance(manifest[field], int)
            or manifest[field] <= 0
        ):
            raise ValueError(f"{field} must be a positive integer")
    parse_not_after(manifest["not_after"])


def inspect_tree(model_root: Path) -> tuple[Path, list[tuple[str, Path]], list[dict]]:
    if model_root.is_symlink():
        raise ValueError("model root must not be a symlink")
    root = model_root.resolve(strict=True)
    if not root.is_dir():
        raise ValueError("model root must be a directory")
    members: list[tuple[str, Path]] = []
    seen: set[str] = set()
    for path in root.rglob("*"):
        if path.is_symlink():
            raise ValueError(f"symlink is forbidden: {path}")
        if not path.is_file() and not path.is_dir():
            raise ValueError(f"non-regular tree member is forbidden: {path}")
        if not path.is_file():
            continue
        relative = path.relative_to(root).as_posix()
        if not relative or relative in seen:
            raise ValueError("duplicate or empty POSIX relative path")
        seen.add(relative)
        members.append((relative, path))
    members.sort(key=lambda item: item[0])
    if not members:
        raise ValueError("model tree contains no regular files")
    by_path = {relative: path for relative, path in members}
    for required in ("config.json", "tokenizer.json"):
        if required not in by_path:
            raise ValueError(f"root {required} is required")
    if any(path.suffix.lower() in NON_SAFETENSORS_WEIGHT_SUFFIXES for _, path in members):
        raise ValueError("all model weights must use safetensors")
    rows = [
        {"path": relative, "sha256": sha256_file(path), "size_bytes": path.stat().st_size}
        for relative, path in members
    ]
    return root, members, rows


def tensor_state(members: list[tuple[str, Path]]) -> tuple[str, int, int]:
    weights = [
        (relative, path) for relative, path in members
        if path.suffix.lower() == ".safetensors"
    ]
    if not weights:
        raise ValueError("safetensors population is empty")
    digest = hashlib.sha256()
    owners: dict[str, object] = {}
    with ExitStack() as stack:
        for _, path in weights:
            handle = stack.enter_context(
                safe_open(str(path), framework="pt", device="cpu")
            )
            for name in handle.keys():
                if name in owners:
                    raise ValueError(f"duplicate tensor name across shards: {name}")
                owners[name] = handle
        if not owners:
            raise ValueError("tensor population is empty")
        for name in sorted(owners):
            tensor = owners[name].get_tensor(name).detach().cpu().contiguous()
            raw = tensor.view(torch.uint8).numpy().tobytes(order="C")
            metadata = canonical({
                "name": name,
                "dtype": str(tensor.dtype).removeprefix("torch."),
                "shape": list(tensor.shape),
                "byte_length": len(raw),
            })
            digest.update(len(metadata).to_bytes(8, "big"))
            digest.update(metadata)
            digest.update(len(raw).to_bytes(8, "big"))
            digest.update(raw)
    return digest.hexdigest(), len(weights), len(owners)


def build_manifest(
    model_root: Path,
    *,
    manifest_version: str,
    model_id: str,
    release_id: str,
    not_after: str,
) -> dict:
    for name, value in {
        "manifest_version": manifest_version,
        "model_id": model_id,
        "release_id": release_id,
    }.items():
        if not isinstance(value, str) or not value:
            raise ValueError(f"{name} must be a non-empty string")
    parse_not_after(not_after)
    root, members, rows = inspect_tree(model_root)
    tensor_sha256, safetensors_count, tensor_count = tensor_state(members)
    root_after, members_after, rows_after = inspect_tree(root)
    if root_after != root or rows_after != rows:
        raise RuntimeError("model tree changed while fingerprinting")
    if [name for name, _ in members_after] != [name for name, _ in members]:
        raise RuntimeError("model population changed while fingerprinting")
    by_path = {relative: path for relative, path in members_after}
    manifest = {
        "schema_version": SCHEMA,
        "manifest_version": manifest_version,
        "model_id": model_id,
        "release_id": release_id,
        "artifact_tree_sha256": hashlib.sha256(canonical(rows)).hexdigest(),
        "tensor_state_sha256": tensor_sha256,
        "safetensors_file_count": safetensors_count,
        "tensor_count": tensor_count,
        "tokenizer_sha256": sha256_file(by_path["tokenizer.json"]),
        "config_sha256": sha256_file(by_path["config.json"]),
        "not_after": not_after,
    }
    validate_manifest(manifest)
    return manifest


def outside_model_root(path: Path, model_root: Path) -> Path:
    root = model_root.resolve(strict=True)
    resolved = path.parent.resolve(strict=True) / path.name
    if resolved == root or resolved.is_relative_to(root):
        raise ValueError("manifest and signature must be stored outside model root")
    return resolved


def write_and_sign(
    model_root: Path, manifest_path: Path, signature_path: Path, manifest: dict
) -> None:
    output = outside_model_root(manifest_path, model_root)
    signature = outside_model_root(signature_path, model_root)
    if output.exists() or signature.exists():
        raise FileExistsError("manifest or signature output already exists")
    with output.open("xb") as handle:
        handle.write(canonical(manifest))
        handle.flush()
        os.fsync(handle.fileno())
    subprocess.run([
        "cosign", "sign-blob", "--yes",
        "--bundle", str(signature), str(output),
    ], check=True, capture_output=True, text=True, timeout=MODEL_SBOM_SIGN_TIMEOUT_SECONDS)
    subprocess.run([
        "cosign", "verify-blob",
        "--certificate-identity", os.environ["MODEL_FINGERPRINT_CERTIFICATE_IDENTITY"],
        "--certificate-oidc-issuer", os.environ["MODEL_FINGERPRINT_OIDC_ISSUER"],
        "--bundle", str(signature), str(output),
    ], check=True, capture_output=True, text=True, timeout=MODEL_SBOM_SIGNATURE_VERIFY_TIMEOUT_SECONDS)


def verify_and_recompute(
    model_root: Path, manifest_path: Path, signature_path: Path
) -> dict:
    manifest_raw = manifest_path.read_bytes()
    signature_raw = signature_path.read_bytes()
    if not manifest_raw or not signature_raw:
        raise ValueError("manifest or signature is empty")
    with tempfile.TemporaryDirectory(prefix="model-fingerprint-verify-") as directory:
        snapshot = Path(directory) / "manifest.json"
        signature_snapshot = Path(directory) / "manifest.sig"
        snapshot.write_bytes(manifest_raw)
        signature_snapshot.write_bytes(signature_raw)
        os.chmod(snapshot, 0o400)
        os.chmod(signature_snapshot, 0o400)
        subprocess.run([
            "cosign", "verify-blob",
            "--certificate-identity", os.environ["MODEL_FINGERPRINT_CERTIFICATE_IDENTITY"],
        "--certificate-oidc-issuer", os.environ["MODEL_FINGERPRINT_OIDC_ISSUER"],
            "--bundle", str(signature_snapshot), str(snapshot),
        ], check=True, capture_output=True, text=True, timeout=MODEL_SBOM_INPUT_VERIFY_TIMEOUT_SECONDS)
        verified_raw = snapshot.read_bytes()
    manifest = json.loads(verified_raw.decode("utf-8", errors="strict"))
    validate_manifest(manifest)
    if verified_raw != canonical(manifest):
        raise ValueError("manifest is not canonical JSON")
    recomputed = build_manifest(
        model_root,
        manifest_version=manifest["manifest_version"],
        model_id=manifest["model_id"],
        release_id=manifest["release_id"],
        not_after=manifest["not_after"],
    )
    if recomputed != manifest:
        raise PermissionError("model fingerprint differs from signed manifest")
    return manifest


def main() -> None:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)
    create = subparsers.add_parser("create")
    create.add_argument("--model-root", type=Path, required=True)
    create.add_argument("--manifest", type=Path, required=True)
    create.add_argument("--signature", type=Path, required=True)
    create.add_argument("--manifest-version", required=True)
    create.add_argument("--model-id", required=True)
    create.add_argument("--release-id", required=True)
    create.add_argument("--not-after", required=True)
    verify = subparsers.add_parser("verify")
    verify.add_argument("--model-root", type=Path, required=True)
    verify.add_argument("--manifest", type=Path, required=True)
    verify.add_argument("--signature", type=Path, required=True)
    args = parser.parse_args()
    if args.command == "create":
        manifest = build_manifest(
            args.model_root,
            manifest_version=args.manifest_version,
            model_id=args.model_id,
            release_id=args.release_id,
            not_after=args.not_after,
        )
        write_and_sign(args.model_root, args.manifest, args.signature, manifest)
    else:
        manifest = verify_and_recompute(
            args.model_root, args.manifest, args.signature
        )
    print(json.dumps(manifest, sort_keys=True))


if __name__ == "__main__":
    main()

Canonical compatibility: Sort all normalized POSIX relative file names and hash canonical rows containing exactly path, sha256 and size_bytes. Hash globally sorted unique tensor names with canonical dtype/shape/byte-length metadata and contiguous raw bytes, each length-prefixed by eight-byte big-endian integers. H-013.002 independently recomputes every field; a valid signature does not replace population verification.

Run SBOM and fingerprint producers

From the installed package root, inventory the captured model.tar. Resolve signing timeouts, manifest validity, model/release IDs and pinned certificate identity/issuer from verified build/deployment policy. The fingerprint create and verify commands operate on an immutable matching export; a verified member_root can instead be used through the documented function while its canonical gate context remains open.

python -m model_sbom.build_sbom
python -m model_sbom.model_fingerprint_manifest create \
  --model-root model_export \
  --manifest evidence/model-fingerprint.json \
  --signature evidence/model-fingerprint.sigstore.json \
  --manifest-version "$MODEL_FINGERPRINT_MANIFEST_VERSION" \
  --model-id "$MODEL_ID" \
  --release-id "$RELEASE_ID" \
  --not-after "$MODEL_FINGERPRINT_NOT_AFTER"
python -m model_sbom.model_fingerprint_manifest verify \
  --model-root model_export \
  --manifest evidence/model-fingerprint.json \
  --signature evidence/model-fingerprint.sigstore.json

AID-H-003.006-G003

Export AI-BOM evidence in interoperable formats such as CycloneDX ML-BOM 1.7 and SPDX 3 AI Profile while keeping the enforcement manifest canonical.
Concept

The internal model SBOM is the enforcement source because admission policies and runtime guards depend on stable field names. External consumers increasingly ask for standards-native AI-BOM files. Do not maintain these by hand. Generate them from the canonical manifest, validate them in CI, and attach all three artifacts to the same release record: canonical manifest, CycloneDX ML-BOM, and SPDX 3 AI Profile export.

Step 1: Map the canonical manifest to a CycloneDX ML-BOM document
# File: model_sbom/export_aibom.py
from __future__ import annotations

import json
from pathlib import Path


def load_canonical(path: str) -> dict:
    return json.loads(Path(path).read_text(encoding="utf-8"))


def to_cyclonedx_mlbom(canonical: dict) -> dict:
    digest = canonical["artifactDigest"].removeprefix("sha256:")
    component_name = canonical.get("name", "model-artifact")
    component_version = canonical.get("version", canonical.get("loader_commit", "unknown"))
    components = []
    for file_record in canonical["files"]:
        components.append(
            {
                "type": "file",
                "name": file_record["path"],
                "hashes": [{"alg": "SHA-256", "content": file_record["sha256"]}],
                "properties": [
                    {"name": "aidefend:size_bytes", "value": str(file_record["size_bytes"])},
                ],
            }
        )

    return {
        "bomFormat": "CycloneDX",
        "specVersion": "1.7",
        "version": 1,
        "metadata": {
            "component": {
                "type": "machine-learning-model",
                "name": component_name,
                "version": component_version,
                "hashes": [{"alg": "SHA-256", "content": digest}],
                "licenses": [{"license": {"name": canonical.get("license", "unknown")}}],
                "externalReferences": [
                    {"type": "vcs", "url": canonical["source_url"]},
                ],
                "properties": [
                    {"name": "aidefend:model_format", "value": canonical["model_format"]},
                    {"name": "aidefend:framework", "value": canonical["framework"]["name"]},
                    {"name": "aidefend:framework_version", "value": canonical["framework"]["version"]},
                    {"name": "aidefend:loader_commit", "value": canonical["loader_commit"]},
                    {"name": "aidefend:trust_remote_code", "value": str(canonical["trust_remote_code"]).lower()},
                ],
            }
        },
        "components": components,
    }


def to_spdx_ai_profile_field_map(canonical: dict) -> dict:
    return {
        "profile": "SPDX-3.0-AI",
        "model_name": canonical.get("name", "model-artifact"),
        "model_version": canonical.get("version", canonical.get("loader_commit", "unknown")),
        "artifact_sha256": canonical["artifactDigest"].removeprefix("sha256:"),
        "model_format": canonical["model_format"],
        "framework": canonical["framework"],
        "source_url": canonical["source_url"],
        "license": canonical.get("license", "unknown"),
        "files": canonical["files"],
        "notes": [
            "Use official SPDX 3 tooling to serialize this field map into the selected SPDX syntax.",
            "Validate the serialized SPDX document before release; do not treat this map alone as a complete SPDX document.",
        ],
    }


def main() -> None:
    canonical = load_canonical("model-sbom.json")
    Path("model-sbom.cdx.json").write_text(
        json.dumps(to_cyclonedx_mlbom(canonical), indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )
    Path("model-sbom.spdx-ai-map.json").write_text(
        json.dumps(to_spdx_ai_profile_field_map(canonical), indent=2, sort_keys=True) + "\n",
        encoding="utf-8",
    )


if __name__ == "__main__":
    main()
Step 2: Validate before publishing
cyclonedx validate --input-file model-sbom.cdx.json

Serialize the SPDX field map with the approved SPDX 3 AI Profile tooling and validate that resulting document with the same pinned tool version. Retain the field map as transformation evidence; it is not itself a complete SPDX document.

Action: Release AI-BOMs as generated artifacts, not policy prose. Keep the canonical manifest for admission enforcement; provide CycloneDX ML-BOM and SPDX 3 AI Profile exports for exchange and procurement; fail the release if validators or field-map coverage checks fail.

Before you begin

Apply AID-H-003.006-G003 only when a model artifact is packaged, promoted, mirrored, loaded, hot-reloaded, or investigated as a released asset.

Keep the action safely bounded

deny promotion or load on any missing member, digest drift, unsafe format, invalid signature, stale policy, unverifiable provenance, or incomplete population.

AID-H-003.006-G004

Sign the artifact-bound SBOM attestation and the canonical SBOM under one pinned Sigstore release identity.
Concept:

Produce two keyless Sigstore bundles under the same pinned release identity: an in-toto attestation binding the canonical SBOM predicate to the exact model bytes, and a detached signature over the canonical SBOM bytes. Runtime consumers verify both bundles before reading any SBOM field, then recompute the actual model digest.

set -euo pipefail
: "${TARBALL:?set TARBALL}"

PREDICATE_TYPE="https://aidefend.net/attestations/model-sbom/v1"
SIGNING_IDENTITY="$(cat /opt/aidefend/trust/model-release.identity)"
OIDC_ISSUER="$(cat /opt/aidefend/trust/model-release.issuer)"

cosign attest-blob --yes \
  --type "$PREDICATE_TYPE" \
  --predicate model-sbom.json \
  --bundle model-sbom.attestation.sigstore.json \
  "$TARBALL"

cosign sign-blob --yes \
  --bundle model-sbom.signature.sigstore.json \
  model-sbom.json

cosign verify-blob-attestation \
  --bundle model-sbom.attestation.sigstore.json \
  --type "$PREDICATE_TYPE" \
  --check-claims=true \
  --certificate-identity "$SIGNING_IDENTITY" \
  --certificate-oidc-issuer "$OIDC_ISSUER" \
  "$TARBALL"

cosign verify-blob \
  --bundle model-sbom.signature.sigstore.json \
  --certificate-identity "$SIGNING_IDENTITY" \
  --certificate-oidc-issuer "$OIDC_ISSUER" \
  model-sbom.json

Action: Retain both bundles, canonical SBOM bytes, artifact digest, signer identity, issuer, and release workflow digest. A missing transparency proof, identity mismatch, predicate mismatch, or detached-SBOM signature failure blocks promotion.

Before you begin

Apply AID-H-003.006-G004 only when a model artifact is packaged, promoted, mirrored, loaded, hot-reloaded, or investigated as a released asset.

Keep the action safely bounded

deny promotion or load on any missing member, digest drift, unsafe format, invalid signature, stale policy, unverifiable provenance, or incomplete population.

AID-H-003.006-G005

Publish as OCI artifact with digest addressing (optional but recommended)
Concept

ORAS pushes a new artifact to a tag and the registry computes the OCI manifest digest. A layer digest is not a valid destination for oras push. Push to a temporary release tag, capture the returned manifest digest, and persist the resulting repository@sha256 followed by 64 lower-case hex characters reference as the immutable deployment identity.

Push, resolve, and record the immutable reference
set -euo pipefail
: "${OCI_REGISTRY:?set OCI_REGISTRY}"
: "${TARBALL:?set TARBALL}"
: "${GIT_COMMIT_SHA:?set GIT_COMMIT_SHA}"

REPOSITORY="${OCI_REGISTRY%/}/models"
RELEASE_TAG="build-${GIT_COMMIT_SHA}"
PUSH_RESULT=$(oras push \
  --artifact-type application/vnd.aidefend.model.v1 \
  --format json \
  "${REPOSITORY}:${RELEASE_TAG}" \
  "${TARBALL}:application/vnd.aidefend.model.layer.v1+tar" \
  "model-sbom.json:application/vnd.aidefend.model.sbom.v2+json")

MANIFEST_DIGEST=$(printf "%s" "$PUSH_RESULT" | jq -er ".digest | select(startswith(\"sha256:\"))")
IMMUTABLE_REF="${REPOSITORY}@${MANIFEST_DIGEST}"
oras manifest fetch "$IMMUTABLE_REF" > /dev/null
printf "%s\n" "$IMMUTABLE_REF" | tee model.oci-reference.txt

Action: Sign or attest the immutable OCI reference, then make admission and runtime consumers use the recorded digest reference. Treat the release tag as a publishing convenience, never as deployment identity.

Before you begin

Apply AID-H-003.006-G005 only when a model artifact is packaged, promoted, mirrored, loaded, hot-reloaded, or investigated as a released asset.

AID-H-003.006-G006

Cryptographically verify one gate-owned immutable artifact and every signed package member; return composition evidence without authorizing a release or load.
Composition verifier, not release authorization

Install this verifier in the protected gate image. Trust roots are deployment-owned read-only files outside the model workspace. The canonical H-003.002 gate owns one private artifact capture; signature verification and every helper use that same object. For a package, reconstruct one private tree while checking the signed path, size and digest population, and pass that exact tree to the loader verifier. No helper recaptures the archive or silently substitutes a weight-file digest for the package digest.

# File: model_sbom/admit_model_artifact.py
from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import re
import shutil
import stat
import subprocess
import tarfile
import tempfile
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath


def required_positive_seconds(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number of seconds")
    return value


MODEL_ADMISSION_BLOB_VERIFY_TIMEOUT_SECONDS = required_positive_seconds(
    "VERIFIED_H003_MODEL_ADMISSION_BLOB_VERIFY_TIMEOUT_SECONDS"
)
PREDICATE_TYPE = "https://aidefend.net/attestations/model-sbom/v1"
RELEASE_POLICY_IDENTITY = Path("/opt/aidefend/trust/release-policy.identity").read_text(encoding="utf-8").strip()
RELEASE_POLICY_ISSUER = Path("/opt/aidefend/trust/release-policy.issuer").read_text(encoding="utf-8").strip()
RELEASE_POLICY_FIELDS = {
    "schema_version", "policy_version", "artifact_signer_identity", "artifact_signer_issuer",
    "admission_receipt_identity", "admission_receipt_issuer", "loader_policy_identity",
    "loader_policy_issuer", "sandbox_verdict_identity", "sandbox_verdict_issuer",
}
SBOM_FIELDS = {
    "schema_version", "artifactDigest", "artifact_format", "model_format", "framework", "files",
    "tokenizer_hash", "config_hash", "loader_commit", "license", "source_url", "trust_remote_code",
}
SHA256 = re.compile(r"^[a-f0-9]{64}$")


def fingerprint(status):
    # Windows fstat/stat expose different legacy ctime meanings on some Python
    # builds. Identity/size/mtime are portable; POSIX also seals change time.
    return (status.st_dev, status.st_ino, status.st_size, status.st_mtime_ns,
            status.st_ctime_ns if os.name != "nt" else None)


@dataclass
class GateArtifact:
    """Gate-owned bytes, unavailable for writing through any model/agent identity."""
    path: Path
    fd: int
    sha256: str
    size: int
    seal: tuple
    member_root: Path | None = None
    member_seals: dict = field(default_factory=dict)
    bounds: dict = field(default_factory=dict)

    def assert_unchanged(self):
        opened, named = os.fstat(self.fd), self.path.lstat()
        if (not stat.S_ISREG(opened.st_mode) or not stat.S_ISREG(named.st_mode)
                or fingerprint(opened) != self.seal or fingerprint(named) != self.seal):
            raise PermissionError("gate snapshot was replaced or changed")
        for name, expected in self.member_seals.items():
            status = (self.member_root / name).lstat()
            if not stat.S_ISREG(status.st_mode) or fingerprint(status) != expected:
                raise PermissionError("verified package member changed")


@contextmanager
def gate_snapshot(source: Path, *, bounds: dict):
    """One bounded copy, one incremental digest; all helpers reuse this object."""
    for name in ("max_artifact_bytes", "max_files", "max_file_bytes", "max_total_bytes"):
        if type(bounds.get(name)) is not int or bounds[name] <= 0:
            raise ValueError(f"missing positive signed snapshot bound: {name}")
    flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
    if source.is_symlink():
        raise PermissionError("artifact symlink is forbidden")
    source_fd = os.open(source, flags)
    try:
        if not stat.S_ISREG(os.fstat(source_fd).st_mode):
            raise ValueError("artifact must be a regular file")
        with tempfile.TemporaryDirectory(prefix="approved-model-") as directory:
            snapshot = Path(directory) / "artifact"
            digest, size = hashlib.sha256(), 0
            with snapshot.open("xb") as writer:
                while chunk := os.read(source_fd, 1024 * 1024):
                    size += len(chunk)
                    if size > bounds["max_artifact_bytes"]:
                        raise PermissionError("artifact exceeds signed byte bound")
                    writer.write(chunk); digest.update(chunk)
                writer.flush(); os.fsync(writer.fileno())
            snapshot.chmod(0o400)
            fd = os.open(snapshot, flags)
            try:
                yield GateArtifact(snapshot, fd, digest.hexdigest(), size,
                                   fingerprint(os.fstat(fd)), bounds=dict(bounds))
            finally:
                os.close(fd)
                # Windows read-only files need their attribute cleared for cleanup.
                for member in Path(directory).rglob("*"):
                    if member.is_file(): member.chmod(0o600)
    finally:
        os.close(source_fd)


@contextmanager
def private_snapshot(source: Path, suffix: str):
    flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
    if source.is_symlink(): raise PermissionError("evidence symlink is forbidden")
    fd = os.open(source, flags)
    try:
        if not stat.S_ISREG(os.fstat(fd).st_mode): raise ValueError("evidence is not a regular file")
        with tempfile.TemporaryDirectory(prefix="model-evidence-") as directory:
            snapshot = Path(directory) / ("evidence" + suffix)
            with os.fdopen(fd, "rb", closefd=False) as source_handle, snapshot.open("xb") as target:
                shutil.copyfileobj(source_handle, target, length=1024 * 1024)
            yield snapshot
    finally:
        os.close(fd)


def strict_json_object(raw: bytes, label: str) -> dict:
    def unique_pairs(pairs):
        value = {}
        for key, item in pairs:
            if key in value: raise ValueError(f"duplicate {label} key: {key}")
            value[key] = item
        return value
    def reject_constant(value): raise ValueError(f"non-finite {label} number: {value}")
    result = json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=unique_pairs, parse_constant=reject_constant)
    if not isinstance(result, dict): raise ValueError(f"{label} root must be an object")
    return result


def verify_blob(snapshot: Path, bundle: Path, identity: str, issuer: str, *, attestation=False):
    command = ["cosign", "verify-blob-attestation" if attestation else "verify-blob", "--bundle", str(bundle)]
    if attestation: command += ["--type", PREDICATE_TYPE, "--check-claims=true"]
    command += ["--certificate-identity", identity, "--certificate-oidc-issuer", issuer, str(snapshot)]
    subprocess.run(command, check=True, capture_output=True, text=True, timeout=MODEL_ADMISSION_BLOB_VERIFY_TIMEOUT_SECONDS)


def verified_json_bytes(path, bundle, identity, issuer, label):
    with private_snapshot(path, ".json") as snapshot:
        raw = snapshot.read_bytes()
        verify_blob(snapshot, bundle, identity, issuer)
    return strict_json_object(raw, label), raw


def load_release_policy(path, bundle):
    policy, raw = verified_json_bytes(path, bundle, RELEASE_POLICY_IDENTITY, RELEASE_POLICY_ISSUER, "release policy")
    if set(policy) != RELEASE_POLICY_FIELDS or policy["schema_version"] != "aidefend.model-release-trust-policy.v1":
        raise ValueError("unsupported release trust policy schema")
    if any(not isinstance(policy[key], str) or not policy[key].strip() for key in RELEASE_POLICY_FIELDS):
        raise ValueError("release trust policy is incomplete")
    return policy, raw


def canonical_member(name):
    path = PurePosixPath(name)
    if (not isinstance(name, str) or not name or path.is_absolute() or any(part in (".", "..") for part in name.split("/"))
            or path.as_posix() != name or "\\" in name or ":" in name):
        raise ValueError("package member path is not canonical")
    return name


def verified_members(artifact: GateArtifact, rows: list, artifact_format: str):
    if not isinstance(rows, list) or not rows or len(rows) > artifact.bounds["max_files"]:
        raise ValueError("signed member population is empty or exceeds bounds")
    expected, folded, total = {}, set(), 0
    for row in rows:
        if not isinstance(row, dict) or set(row) != {"path", "sha256", "size_bytes"}:
            raise ValueError("signed member schema differs")
        name = canonical_member(row["path"])
        if name in expected or name.casefold() in folded or not SHA256.fullmatch(row["sha256"]):
            raise ValueError("signed member identity or digest differs")
        if type(row["size_bytes"]) is not int or not 0 <= row["size_bytes"] <= artifact.bounds["max_file_bytes"]:
            raise ValueError("signed member size exceeds bounds")
        total += row["size_bytes"]
        if total > artifact.bounds["max_total_bytes"]: raise ValueError("signed package exceeds total bounds")
        expected[name] = row; folded.add(name.casefold())
    if artifact_format != ".tar":
        if len(rows) != 1 or rows[0]["sha256"] != artifact.sha256 or rows[0]["size_bytes"] != artifact.size:
            raise PermissionError("single-file SBOM differs from exact artifact")
        return
    root = artifact.path.parent / "members"
    root.mkdir(mode=0o700)
    seen = set()
    with tarfile.open(artifact.path, mode="r:") as archive:
        for member in archive:
            name = canonical_member(member.name)
            if not member.isfile() or member.linkname or name in seen or name not in expected:
                raise PermissionError("archive has a link, duplicate, special, or undeclared member")
            row = expected[name]
            if member.size != row["size_bytes"]:
                raise PermissionError("package member size differs")
            seen.add(name)
            target = root / name; target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
            digest, count = hashlib.sha256(), 0
            with archive.extractfile(member) as source, target.open("xb") as writer:
                while chunk := source.read(1024 * 1024):
                    count += len(chunk); writer.write(chunk); digest.update(chunk)
                writer.flush(); os.fsync(writer.fileno())
            if count != row["size_bytes"] or digest.hexdigest() != row["sha256"]:
                raise PermissionError("package member differs from signed SBOM")
            target.chmod(0o400)
    if seen != set(expected): raise PermissionError("archive omits signed members")
    artifact.member_root = root
    artifact.member_seals = {name: fingerprint((root / name).lstat()) for name in expected}
    artifact.assert_unchanged()


def admit(args: argparse.Namespace, *, artifact: GateArtifact) -> dict:
    """Composition evidence only. The caller supplies the canonical gate snapshot."""
    artifact.assert_unchanged()
    policy, release_raw = load_release_policy(args.release_policy, args.release_policy_bundle)
    verify_blob(artifact.path, args.attestation_bundle, policy["artifact_signer_identity"], policy["artifact_signer_issuer"], attestation=True)
    manifest, sbom_raw = verified_json_bytes(args.sbom, args.sbom_bundle, policy["artifact_signer_identity"], policy["artifact_signer_issuer"], "model SBOM")
    if set(manifest) != SBOM_FIELDS or manifest["schema_version"] != "aidefend.model-sbom.v2":
        raise ValueError("unsupported model SBOM schema")
    if manifest["artifactDigest"] != "sha256:" + artifact.sha256:
        raise PermissionError("signed SBOM differs from exact gate artifact")
    if manifest["trust_remote_code"] is not False: raise PermissionError("remote loader code is not admissible")
    fmt = manifest["artifact_format"]
    if (not isinstance(fmt, str) or not re.fullmatch(r"\.[a-z0-9]+", fmt)
            or not isinstance(manifest["model_format"], str) or not re.fullmatch(r"[a-z0-9]+", manifest["model_format"])):
        raise ValueError("container or weight format is not canonical")
    if fmt != ".tar" and fmt != "." + manifest["model_format"]:
        raise ValueError("single-file container and weight formats disagree")
    if not isinstance(manifest["source_url"], str) or not manifest["source_url"]:
        raise ValueError("signed source URI is missing")
    verified_members(artifact, manifest["files"], fmt)
    artifact.assert_unchanged()
    return {
        "schema_version": "aidefend.model-composition-verification.v4",
        "artifact_format": fmt, "model_format": manifest["model_format"], "source_uri": manifest["source_url"],
        "artifact_digest": "sha256:" + artifact.sha256,
        "sbom_sha256": hashlib.sha256(sbom_raw).hexdigest(), "predicate_type": PREDICATE_TYPE,
        "release_policy_sha256": hashlib.sha256(release_raw).hexdigest(), "release_policy_version": policy["policy_version"],
        "artifact_signer_identity": policy["artifact_signer_identity"], "artifact_signer_issuer": policy["artifact_signer_issuer"],
    }


def main():
    parser = argparse.ArgumentParser()
    for name in ("artifact", "sbom", "attestation-bundle", "sbom-bundle", "release-policy", "release-policy-bundle", "limits", "limits-bundle"):
        parser.add_argument("--" + name, required=True, type=Path)
    args = parser.parse_args()
    policy, _raw = load_release_policy(args.release_policy, args.release_policy_bundle)
    limits, _limits_raw = verified_json_bytes(args.limits, args.limits_bundle, policy["loader_policy_identity"], policy["loader_policy_issuer"], "composition limits")
    if set(limits) != {"max_artifact_bytes", "max_files", "max_file_bytes", "max_total_bytes"}:
        raise ValueError("composition limits schema differs")
    with gate_snapshot(args.artifact, bounds=limits) as artifact:
        print(json.dumps(admit(args, artifact=artifact), sort_keys=True, allow_nan=False))


if __name__ == "__main__": main()
Offline composition evidence

Use python -m model_sbom.admit_model_artifact --artifact model.tar --sbom model-sbom.json --attestation-bundle model-sbom.attestation.sigstore.json --sbom-bundle model-sbom.signature.sigstore.json --release-policy release-trust.json --release-policy-bundle release-trust.sigstore.json --limits composition-limits.json --limits-bundle composition-limits.sigstore.json. The limits object contains the four positive integer keys max_artifact_bytes, max_files, max_file_bytes and max_total_bytes, signed by the pinned loader-policy identity. Sign and independently read back the returned composition receipt under the evidence identity; a local result is not admission.

Storage and compatibility

The gate service and its approved loader must be the only writers of the private snapshot/tree; agent identities and mounts cannot alter or delete them. POSIX mode bits alone do not protect against the service owner, and Windows requires an equivalent restricted ACL. One archive capture and one verified extraction are necessary for the complete-package path; cryptographic verifiers can also read the archive. Receipt v4 and SBOM v2 require producer/consumer review; v3 receipts are rejected, not automatically carried forward.

AID-H-003.006-G007

Verify current composition and the independently signed v4 composition receipt over the canonical gate-owned artifact and reconstructed model interface.
Runtime evidence over the same captured release

Run inside the H-003.002 gate context; do not open another model path or copy its descriptor. Re-verify producer signatures, exact container/member identity, the release trust policy and the independently signed reviewed receipt. Package identity remains the signed archive digest even when a format parser reads an extracted weight member.

# File: model_sbom/runtime_guard.py
from __future__ import annotations
import argparse
import hashlib
from model_sbom.admit_model_artifact import GateArtifact, admit, load_release_policy, verified_json_bytes

RECEIPT_FIELDS = {
    "schema_version", "artifact_digest", "artifact_format", "model_format", "source_uri", "sbom_sha256", "predicate_type",
    "release_policy_sha256", "release_policy_version", "artifact_signer_identity", "artifact_signer_issuer",
}

class StartupRejected(RuntimeError): pass

def runtime_verify(args: argparse.Namespace, *, artifact: GateArtifact) -> dict:
    try:
        composition = admit(args, artifact=artifact)
        policy, release_raw = load_release_policy(args.release_policy, args.release_policy_bundle)
        if hashlib.sha256(release_raw).hexdigest() != composition["release_policy_sha256"]:
            raise StartupRejected("release policy changed during verification")
        approved, approved_raw = verified_json_bytes(args.approved_receipt, args.approved_receipt_bundle,
            policy["admission_receipt_identity"], policy["admission_receipt_issuer"], "approved composition receipt")
        if set(approved) != RECEIPT_FIELDS or approved["schema_version"] != "aidefend.model-composition-verification.v4":
            raise StartupRejected("approved composition receipt requires v4 semantic review")
        if approved != composition: raise StartupRejected("runtime composition differs from reviewed release")
    except Exception as exc:
        raise StartupRejected("composition, signature, receipt, or digest verification failed") from exc
    artifact.assert_unchanged()
    return {
        "schema_version": "aidefend.model-runtime-verification.v4", "artifact_digest": composition["artifact_digest"],
        "artifact_format": composition["artifact_format"], "model_format": composition["model_format"],
        "source_uri": composition["source_uri"], "release_policy_version": policy["policy_version"],
        "sbom_sha256": composition["sbom_sha256"], "release_policy_sha256": hashlib.sha256(release_raw).hexdigest(),
        "approved_receipt_sha256": hashlib.sha256(approved_raw).hexdigest(),
    }
Verify the handoff

Different release, policy, manifest, schema or signed receipt must reject before loader access. The result supplies evidence to the final gate and has no deployment or authorization side effect. A v3 receipt cannot establish v4 container/member semantics.

AID-H-003.006-G008

Verify the signed current tombstone policy for the exact source URI and artifact digest, returning source-status evidence to the canonical gate.
Current source-status verifier

Use this helper when the release originates from a namespace or upstream owner with a versioned tombstone policy. Cryptographically verify that policy and reject rollback or a matching tombstone. The helper reports eligibility only; AID-H-003.002 owns the resulting refresh, transfer, promotion, deployment, and reload disposition.

# File: supply_chain/verify_tombstones.py
from __future__ import annotations
import math
import hashlib
import json
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from model_sbom.admit_model_artifact import strict_json_object

def _required_positive_seconds(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0:
        raise RuntimeError(f"{name} must be a finite positive number of seconds")
    return value

MODEL_TOMBSTONE_POLICY_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H003_MODEL_TOMBSTONE_POLICY_VERIFY_TIMEOUT_SECONDS")


def check(source_uri: str, artifact_sha256: str, policy_path: Path, signature_path: Path, version_store) -> dict:
    with tempfile.TemporaryDirectory(prefix="model-tombstones-") as directory:
        policy_snapshot, signature_snapshot = Path(directory) / "policy.json", Path(directory) / "policy.sig"
        policy_snapshot.write_bytes(policy_path.read_bytes())
        signature_snapshot.write_bytes(signature_path.read_bytes())
        subprocess.run(["cosign", "verify-blob", "--key", os.environ["MODEL_TOMBSTONE_VERIFY_KEY"], "--bundle", str(signature_snapshot), str(policy_snapshot)], check=True, capture_output=True, text=True, timeout=MODEL_TOMBSTONE_POLICY_VERIFY_TIMEOUT_SECONDS)
        raw = policy_snapshot.read_bytes()
    policy = strict_json_object(raw, "tombstone policy")
    if set(policy) != {"schema_version", "policy_version", "not_after", "tombstones"} or policy["schema_version"] != "aidefend.model-tombstones.v1":
        raise ValueError("tombstone policy schema differs")
    expiry = datetime.fromisoformat(policy["not_after"].replace("Z", "+00:00"))
    if expiry.tzinfo is None or expiry <= datetime.now(timezone.utc):
        raise PermissionError("tombstone policy is expired")
    if not version_store.accept_monotonic(policy["policy_version"]):
        raise PermissionError("tombstone policy rollback detected")
    for item in policy["tombstones"]:
        if set(item) != {"source_uri", "artifact_sha256", "reason_code", "revoked_at"}:
            raise ValueError("tombstone entry schema differs")
        if item["source_uri"] == source_uri and item["artifact_sha256"] == artifact_sha256:
            raise PermissionError("source and artifact are tombstoned: " + item["reason_code"])
    return {"policy_sha256": hashlib.sha256(raw).hexdigest(), "policy_version": policy["policy_version"], "source_uri": source_uri, "artifact_sha256": artifact_sha256, "status": "not_tombstoned"}
Handoff and verification

The canonical gate supplies the source URI from the signed SBOM and the digest of its own opened artifact bytes. Its policy-version store is an authenticated durable monotonic authority, not an in-process counter. A matching, missing, stale, rolled-back, malformed, or unverifiable policy cannot yield eligible evidence. This method neither removes registry routes nor stops active workloads; active retirement remains AID-M-010.003. Replay exact-source/digest, expired-policy, rollback, and unrelated-source fixtures.

AID-H-003.006-G009

Verify the signed weight format and complete loader options over the gate-owned single-file artifact or its verified package members, including bounded signed sandbox exceptions.
Container and weight format are separate

A tar package is not itself a safetensors file. For artifact_format=.tar, reuse the composition verifier's private tree, analyze every declared weight member with the actual parser, and retain the package digest as the release identity. For a single-file artifact, parse the same gate-owned snapshot. Unsupported executable members, missing weights or unsafe remote-code paths cannot silently become static data.

# File: runtime/verify_model_loader_policy.py
from __future__ import annotations

import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path

from model_sbom.interface_population import require_population, validate_population_policy

import onnx
from safetensors import safe_open

from model_sbom.admit_model_artifact import (
    load_release_policy,
    GateArtifact,
    verified_json_bytes,
)


LOADER_POLICY_FIELDS = {
    "schema_version", "policy_version", "allowed_static_formats",
    "exception_formats", "required_loader_flags", "artifact_limits",
    "package_interface", "loader_artifact_formats",
}
VERDICT_FIELDS = {
    "schema_version", "profile", "artifact_digest", "artifact_format",
    "loader_name", "verdict", "verified_at", "expires_at",
    "loader_policy_sha256",
}


def parse_time(value: str) -> datetime:
    parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    if parsed.tzinfo is None:
        raise ValueError("timestamp must include a timezone")
    return parsed.astimezone(timezone.utc)


def load_loader_policy(
    path: Path,
    bundle: Path,
    release_policy_path: Path,
    release_policy_bundle: Path,
) -> tuple[dict, dict, bytes, bytes]:
    release, release_raw = load_release_policy(
        release_policy_path, release_policy_bundle
    )
    policy, policy_raw = verified_json_bytes(
        path,
        bundle,
        release["loader_policy_identity"],
        release["loader_policy_issuer"],
        "loader policy",
    )
    if set(policy) != LOADER_POLICY_FIELDS:
        raise ValueError("loader policy schema differs")
    if policy["schema_version"] != "aidefend.model-loader-policy.v3":
        raise ValueError("loader policy version is unsupported")
    if not isinstance(policy["policy_version"], str) or not policy["policy_version"]:
        raise ValueError("loader policy version is missing")
    static_formats = policy["allowed_static_formats"]
    exception_formats = policy["exception_formats"]
    if (
        not isinstance(static_formats, list)
        or not isinstance(exception_formats, list)
        or len(static_formats) != len(set(static_formats))
        or len(exception_formats) != len(set(exception_formats))
        or set(static_formats) & set(exception_formats)
    ):
        raise ValueError("format lists must be unique and disjoint")
    if not set(static_formats).issubset({".onnx", ".safetensors"}):
        raise ValueError("a claimed static format lacks an implemented parser")
    if not all(
        isinstance(value, str) and value.startswith(".")
        for value in static_formats + exception_formats
    ):
        raise ValueError("format entries are invalid")
    if not isinstance(policy["required_loader_flags"], dict):
        raise ValueError("required_loader_flags must be an object")
    validate_population_policy(policy)
    containers = policy["loader_artifact_formats"]
    if (not isinstance(containers, dict) or set(containers) != set(policy["required_loader_flags"])
            or any(value not in {".tar"} | set(static_formats) | set(exception_formats) for value in containers.values())):
        raise ValueError("each signed loader profile must bind one actual container format")
    limits = policy["artifact_limits"]
    if (not isinstance(limits, dict) or set(limits) != {"max_artifact_bytes", "max_files", "max_file_bytes", "max_total_bytes"}
            or any(type(value) is not int or value <= 0 for value in limits.values())):
        raise ValueError("signed artifact limits differ")
    return policy, release, policy_raw, release_raw


def verify_signed_verdict(
    verdict_path: Path,
    bundle_path: Path,
    artifact_digest: str,
    artifact_format: str,
    loader_name: str,
    loader_policy_sha256: str,
    release: dict,
) -> dict:
    verdict, _verdict_raw = verified_json_bytes(
        verdict_path,
        bundle_path,
        release["sandbox_verdict_identity"],
        release["sandbox_verdict_issuer"],
        "sandbox verdict",
    )
    if set(verdict) != VERDICT_FIELDS:
        raise ValueError("sandbox verdict schema differs")
    if verdict["schema_version"] != "aidefend.model-loader-analysis.v1":
        raise ValueError("sandbox verdict version is unsupported")
    expected = {
        "profile": "model-loader-analysis",
        "artifact_digest": artifact_digest,
        "artifact_format": artifact_format,
        "loader_name": loader_name,
        "loader_policy_sha256": loader_policy_sha256,
    }
    if any(verdict[field] != value for field, value in expected.items()):
        raise ValueError("sandbox verdict binding differs")
    now = datetime.now(timezone.utc)
    if (
        verdict["verdict"] != "allow"
        or parse_time(verdict["verified_at"]) > now
        or parse_time(verdict["expires_at"]) <= now
    ):
        raise PermissionError("sandbox verdict does not currently allow this load")
    return verdict


def verify_onnx(artifact: Path) -> None:
    model = onnx.load(str(artifact), load_external_data=False)
    onnx.checker.check_model(model)
    if any(tensor.external_data for tensor in model.graph.initializer):
        raise PermissionError("ONNX external data is not approved")
    standard_domains = {"", "ai.onnx", "ai.onnx.ml"}
    if any(node.domain not in standard_domains for node in model.graph.node):
        raise PermissionError("ONNX custom operator domain is not approved")


def verify_safetensors(artifact: Path) -> None:
    with safe_open(str(artifact), framework="np") as handle:
        if not list(handle.keys()):
            raise ValueError("safetensors artifact contains no tensors")


def verify_model(
    loader_policy_path: Path, loader_policy_bundle: Path,
    release_policy_path: Path, release_policy_bundle: Path,
    loader_name: str, loader_kwargs: dict,
    verdict_path: Path | None = None, verdict_bundle: Path | None = None,
    *, artifact: GateArtifact, composition: dict,
) -> dict:
    policy, release, policy_raw, release_raw = load_loader_policy(
        loader_policy_path, loader_policy_bundle, release_policy_path, release_policy_bundle)
    artifact.assert_unchanged()
    artifact_digest = "sha256:" + artifact.sha256
    if composition["artifact_digest"] != artifact_digest:
        raise PermissionError("loader and composition use different gate bytes")
    container, suffix = composition["artifact_format"], "." + composition["model_format"]
    policy_digest = hashlib.sha256(policy_raw).hexdigest()
    if suffix not in set(policy["allowed_static_formats"]) | set(policy["exception_formats"]):
        raise PermissionError("weight format is not declared by signed policy")
    flags = policy["required_loader_flags"].get(loader_name)
    if not isinstance(flags, dict) or not flags or loader_kwargs != flags:
        raise PermissionError("actual loader options differ from the complete signed profile")
    if policy["loader_artifact_formats"].get(loader_name) != container:
        raise PermissionError("loader profile is bound to another container format")
    # The package digest remains the admission identity; every extracted member
    # was matched to the signed inventory by the composition verifier.
    exception_members = []
    static_paths = []
    if container == ".tar":
        if artifact.member_root is None: raise PermissionError("package was not reconstructed by the gate")
        require_population(artifact.member_seals, policy)
        paths = []
        for name in artifact.member_seals:
            path = artifact.member_root / name
            if path.suffix.lower() == suffix: paths.append(path)
            if path.suffix.lower() in policy["exception_formats"]: exception_members.append(name)
            if path.suffix.lower() in policy["allowed_static_formats"]: static_paths.append((path, path.suffix.lower()))

        if not paths: raise PermissionError("package contains no declared weight members")
    elif container == suffix:
        paths = [artifact.path]
        if suffix in policy["allowed_static_formats"]: static_paths = [(artifact.path, suffix)]
    else:
        raise PermissionError("container and model formats disagree")
    if suffix in policy["exception_formats"] or exception_members:
        if verdict_path is None or verdict_bundle is None:
            raise PermissionError("executable-format exception lacks signed evidence: " + (sorted(exception_members)[0] if exception_members else suffix))
        verify_signed_verdict(verdict_path, verdict_bundle, artifact_digest,
                              container, loader_name, policy_digest, release)
    # Every loader-visible static weight is checked, including auxiliary formats
    # in a package whose declared primary weights use another format.
    for path, static_suffix in static_paths:
        if static_suffix == ".onnx": verify_onnx(path)
        elif static_suffix == ".safetensors": verify_safetensors(path)
        else: raise PermissionError("static weight format lacks an implemented parser")
    artifact.assert_unchanged()
    return {
        "schema_version": "aidefend.model-loader-verification.v4",
        "artifact_digest": artifact_digest, "artifact_format": container,
        "model_format": composition["model_format"], "verified_weight_members": len(paths),
        "loader_policy_sha256": policy_digest, "loader_policy_version": policy["policy_version"],
        "release_policy_sha256": hashlib.sha256(release_raw).hexdigest(), "loader_name": loader_name,
    }
Configure one signed loader profile

The signed document has exactly eight fields: schema_version: aidefend.model-loader-policy.v3, policy_version, allowed_static_formats, exception_formats, required_loader_flags, artifact_limits, package_interface and loader_artifact_formats. The shared G001 interface module validates the allowed data suffixes, documentation names and required package members; loader_artifact_formats binds each exact loader profile name to its container format. The four limits are positive integers; for example a versioned 140 GB package profile can authorize 200 GiB artifact bytes, 10,000 files, 16 GiB per member and 200 GiB total members. Select limits for the actual model rather than adopting these numbers universally. For transformers-safetensors-package, bind the exact flags {trust_remote_code:false, local_files_only:true, use_safetensors:true}. Loader-policy v1 or v2 must be reviewed and republished as v3. Use the identical transformers-safetensors-package profile name in G001, this policy and AID-H-003.002; bind its container to .tar.

Verify safely

Unsupported formats, absent parsers, custom ONNX operators, ONNX external tensors, invalid signatures, caller-selected roots, changed snapshots or expired sandbox evidence reject. Every loader-visible member with an exception-format suffix requires a currently valid signed sandbox verdict, even when the declared weights are static. The verdict binds the complete artifact digest, container format, installed loader and signed policy. All loader-visible static weight members, including auxiliary formats different from the declared primary weights, still undergo their normal parser checks; a sandbox verdict never waives those checks. The canonical gate passes the same captured object to the installed loader and keeps the context alive for any lazy reads or memory mapping.

Policy compatibility: Loader-policy v2 lacks the signed interface population and per-loader container binding. Reissue and review it as v3; do not carry an old policy as approval. The package builder, transfer preflight and parser gate consume the identical signed v3 blob. An explicitly allowed README or LICENSE remains ordinary non-executable data.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-003.006: Model SBOM & Provenance Attestation | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version