Integrating modelvet

August 4, 2026 ยท View on GitHub

modelvet is a verify-before-load boundary: it decides whether a GGUF or safetensors file is structurally safe to hand to a model loader, in fixed memory, before any loader touches the bytes. This guide covers the four ways to consume it (vendoring the two-file amalgamation, linking the static library, running the modelvet(1) CLI in a pipeline, and the Python binding) plus the arena sizing table and the exact limits of an ACCEPT verdict.

What ACCEPT does not mean

Read this first; it is the honest boundary of the tool.

ACCEPT means the file's structure was verified: every length, count, offset, and size was checked against bounded arithmetic and the format's rules, so a conforming loader will not be driven out of bounds by this file's structure. ACCEPT does not mean:

  • the model's behavior is safe, aligned, or unpoisoned;
  • the weights or tokenizer do what the publisher claims;
  • the file's provenance is trusted or its contents signed;
  • every loader will accept the file (some loaders enforce their own, sometimes tighter, limits, e.g. llama.cpp's 64-byte tensor-name buffer against modelvet's default 256-byte cap);
  • a loader with bugs after structural parsing is protected.

REJECT is precise: the report carries the first violation's code, byte offset, and two per-code detail values. A REJECT is a structural fact about the file, not a malware verdict.

Pickle-based formats (PyTorch .pt/.pkl) are out of scope by design and cannot be made structurally safe; do not route them here expecting a meaningful verdict.

make amalgamation emits build/modelvet.c and build/modelvet.h: one source file, one header, no dependencies beyond a freestanding C11 toolchain. Copy both files into your tree and compile the .c like any other file:

cc -std=c11 -Wall -Wextra -Werror -c modelvet.c

Properties your build can rely on:

  • Freestanding: no allocation, no stdio, no errno, no filesystem calls, no writable global state. The library writes only to the caller's arena, the report, and output parameters.
  • The pair is byte-stable: regenerating from the same source tree produces identical bytes, so vendored copies diff cleanly against releases.
  • -ffreestanding is supported but not required.

Release tarballs (modelvet-X.Y.Z.tar.gz, see RELEASE.md) ship the same pair plus this guide, with a SHA-256 checksum and a detached signature.

Linking the static library

make lib          # build/libmodelvet.a
cc -std=c11 -Iinclude your_scanner.c build/libmodelvet.a

Same code, same guarantees; use this when your build prefers archives over vendored sources.

Calling the API

#include <stdint.h>

#include "modelvet.h"

static uint8_t memory[MVET_GGUF_ARENA_WORST_BYTES > MVET_ST_ARENA_WORST_BYTES
                          ? MVET_GGUF_ARENA_WORST_BYTES
                          : MVET_ST_ARENA_WORST_BYTES];

int scan(const uint8_t *bytes, size_t length)
{
    mvet_arena_t arena = {0};
    mvet_report_t report = {0};

    if (mvet_arena_bind(&arena, memory, sizeof(memory)) != MVET_OK)
        return -1;
    if (mvet_gguf_verify(&report, &arena, bytes, length) != MVET_OK)
        return -1; /* API misuse or arena too small: no verdict */
    return report.verdict == MVET_VERDICT_ACCEPT ? 0 : 1;
}

Contract points that matter to integrators:

  • A malformed file is not an error: the call returns MVET_OK and the report says MVET_VERDICT_REJECT with the first violation. MVET_ERR_* means your code misused the API or under-sized the arena; treat it as "not verified", never as a verdict.
  • The report is fail-closed: it is cleared to all-zero (REJECT, no violation) before anything else happens.
  • One arena serves both verifiers; every call resets it. The library never touches memory outside the arena, the report, and the input.
  • Thread safety: no global state, so concurrent calls are safe as long as each thread uses its own arena and report.
  • Violation-code numbers are append-only ABI; persist them freely (RELEASE.md carries the full policy).

Arena sizing

The bounds are exact closed forms over the compile-time caps, including worst-case alignment padding; the macros compute them for whatever cap overrides you build with. At the defaults on a 64-bit target:

ConfigurationGGUFsafetensors
Default caps147,463 B (MVET_GGUF_ARENA_WORST_BYTES)278,551 B (MVET_ST_ARENA_WORST_BYTES)
Documented 64 KiB profile49,159 B (-DMVET_MAX_TENSORS=2048u)57,367 B (-DMVET_MAX_ST_TENSORS=1536u -DMVET_MAX_ST_METADATA_ENTRIES=512u)

One arena sized to the larger bound serves both formats. Small inputs carve proportionally less; the bound is the worst case, not the typical demand. Every cap is an #ifndef-overridable MVET_MAX_* define; raising a cap only raises the bound, never the API. The reduced-cap 64 KiB profiles are CI-proven against the whole CVE corpus (make check-arena-64k).

The CLI in pipelines

make cli builds build/modelvet. One file per invocation; the exit code is the verdict:

ExitMeaning
0verified ACCEPT
1verified REJECT
2usage, I/O, or API error. No verdict; never treat as ACCEPT
modelvet --json downloaded.gguf

emits one JSON object with fixed field order:

{"file":"downloaded.gguf","format":"gguf","verdict":"accept",
 "violation":0,"violation_name":"MVET_V_NONE","offset":0,
 "detail":[0,0],"version":"0.1.0"}

offset and detail are unsigned 64-bit; parse with a 64-bit-capable JSON reader if you need exact values above 2532^{53}. Fields are append-only across releases. See the man page (docs/modelvet.1) for the full contract.

Format detection

--format auto (the default) routes by content, not extension: a file whose first four bytes are the GGUF magic (GGUF) goes to the GGUF verifier; everything else goes to the safetensors verifier. The rule cannot misroute a valid file of either format: safetensors has no magic of its own, and a safetensors file that starts with the GGUF magic would declare a header length whose low 32 bits are 0x46554747 (over 1.1 GB), far past the canonical 100 MB header cap, so it is invalid regardless of routing. Force --format gguf|safetensors when your pipeline already knows the container type.

The same pair of checks (the 4-byte magic for GGUF; for safetensors, a plausible little-endian u64 header length within the cap and within the file, with the first header byte being { or JSON whitespace) is a sound recognition heuristic for scanners that must type files without extensions. Recognition is cheap triage only; a verdict still requires running the full verifier.

Python (hub-side pipelines)

bindings/python/ wraps the C library with ctypes:

import modelvet

report = modelvet.verify_path("model.safetensors")   # auto-routes
if not report.accepted:
    print(report.violation_name, hex(report.offset), report.details)

verify_gguf(data) and verify_safetensors(data) take bytes-like objects; verify_path maps the file and routes with the magic rule. API misuse and arena exhaustion raise exceptions; a malformed file returns a report, mirroring the C contract. Point MODELVET_LIBRARY at a specific shared library (make shared builds build/libmodelvet.so), or install the package, which builds the native module from the vendored amalgamation.

Antivirus / file-type scanners

For engines that scan by file type (ClamAV's CL_TYPE_AI_MODEL slot is the shape this was designed for): call the verifier from the type's handler with a windowed read of the whole object, report REJECT with the violation code as the detection detail, and treat exit-2-class API failures as scan errors rather than detections. The library's whole-buffer entry maps directly onto fmap-style access; memory is fixed and bounded by the table above.