AID-H-003.006: Model SBOM & Provenance Attestation
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.
- 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.
- 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.
- AID-H-003.006-G004: Sign the artifact-bound SBOM attestation and the canonical SBOM under one pinned Sigstore release identity.
- AID-H-003.006-G005: Publish as OCI artifact with digest addressing (optional but recommended)
- 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.
- 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.
- 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.
- 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.
AID-H-003.006-G001
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
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
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
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
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.txtAction: 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
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
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
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
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
- Cosign
- in-toto
- slsa-verifier
- ORAS CLI
- jq
- sha256sum
- safetensors
- CycloneDX CLI
- cdxgen
- SPDX Python Tools (spdx/tools-python)
- OWASP Dependency-Track (CycloneDX ML-BOM monitoring; separate artifact-digest admission gate required)
Commercial / hosted tools
- JFrog Artifactory
- Databricks Models in Unity Catalog
- Amazon SageMaker AI Model Registry
- Google Vertex AI Model Registry
Threat mappings
MITRE ATLAS
- AML.T0011 User Execution
- AML.T0115 Publish Poisoned AI Artifacts
- AML.T0011.000 User Execution: Unsafe AI Artifacts
- AML.T0018.003 Manipulate AI Model: Modify Prompt Construction Logic
- AML.T0010 AI Supply Chain Compromise (SBOM and attestation protect the broader model supply chain)
- AML.T0010.003 AI Supply Chain Compromise: Model
- AML.T0018 Manipulate AI Model (provenance attestation detects tampered models)
- AML.T0018.000 Manipulate AI Model: Poison AI Model (attestation detects unauthorized model modification)
- AML.T0115.001 Publish Poisoned AI Artifacts: Models
- AML.T0074 Masquerading
- AML.T0076 Corrupt AI Model
- AML.T0109 AI Supply Chain Rug Pull
MAESTRO
- Supply Chain Attacks (Cross-Layer)
OWASP LLM Top 10 2026
- LLM04:2026 Supply Chain
- LLM05:2026 Data and Model Poisoning
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks
- ML10:2023 Model Poisoning (attestation detects unauthorized model modifications)
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities
NIST Adversarial Machine Learning 2025
- NISTAML.051 Model Poisoning (Supply Chain) (signed composition verification exposes unauthorized model-byte or producer changes)
- NISTAML.026 Model Poisoning (Integrity) (provenance attestation and digest verification detect integrity-violating model modifications)
Cisco Integrated AI Security and Safety Framework
- AISubtech-9.3.1 Malicious Package / Tool Injection (verified model composition exposes source or byte substitution; H-003.002 owns final release enforcement)
- AISubtech-9.3.3 Dependency Replacement / Rug Pull (verified model composition exposes source or byte substitution; H-003.002 owns final release enforcement)
- AITech-9.3 Dependency / Plugin Compromise (verified model composition exposes source or byte substitution; H-003.002 owns final release enforcement)
Google Secure AI Framework 2.0 - Risks
- MST: Model Source Tampering (SBOM and attestation detect tampered model source)
- MDT: Model Deployment Tampering (composition verification exposes deployment-byte substitution before the final gate)
Databricks AI Security Framework 3.0
- Model 7.3: ML Supply chain vulnerabilities
- Model Management 8.1: Model attribution (SBOM provides model attribution and provenance)
- Governance 4.1: Lack of traceability and transparency of model assets (SBOM provides traceability)