modelvet answers one question before a model loader touches a file: is this GGUF or safetensors file structurally safe to load?
It is a freestanding, strict C11 library with no heap allocation. Every
hostile length, count, offset and tensor size is validated with checked
arithmetic, in fixed memory, before any loader parses the bytes. Use it
as a static library, as one vendored .c/.h pair, as a CLI in a
pipeline, or from Python.
The CLI. Exit code 0 is a verified ACCEPT, 1 a verified REJECT, 2 means no verdict:
git clone https://github.com/tetsuo-ai/modelvet
cd modelvet && make cli
./build/modelvet model.gguf
./build/modelvet --json suspicious.safetensors | jq .violation_namePython:
pip install modelvetimport modelvet
report = modelvet.verify_path("model.safetensors")
print(report.accepted, report.violation_name)C, vendored. make amalgamation emits build/modelvet.c and
build/modelvet.h: copy both into your tree and compile as C11, no
dependencies. The integration guide covers arena
sizing, cap overrides and pipeline use.
llama.cpp (C++), Ollama (Go) and MLX (C++) each wrote their own GGUF reader, and each shipped the same class of integer overflow CVE. The 2025 llama.cpp fix was bypassed in 2026 because the patch checked each addition but not the final combined allocation. A format that everyone reimplements needs a check that runs before any loader parses the bytes. modelvet is that check.
A concrete example. A 65-byte GGUF whose tensor is shaped [4, 0]
reaches INT64_MAX / info.t.ne[1] in llama.cpp's own overflow guard,
where the dimension came from the file and only negatives were rejected.
On x86-64 that divide by zero is a SIGFPE, and the loader dies before it
reads a weight (llama.cpp issue #26366, open). modelvet rejects the same
file with a violation code and a byte offset.
The attacker controls every input byte. For every input, modelvet is designed to:
- stay within the input buffer and caller-owned arena;
- use checked arithmetic for file-derived sizes, counts, and offsets;
- keep every loop bounded by a named compile-time cap;
- avoid recursion, heap allocation, and hidden mutable state;
- return malformed files as
MVET_OKwithMVET_VERDICT_REJECTand the first precise violation.
An ACCEPT verdict is structural. It says nothing about model behavior, provenance, poisoned weights, tokenizer semantics, or bugs a downstream loader introduces after validation. See the threat model for the complete boundary.
- CVE regression corpus: one file per advisory in
tests/corpus/, each rejected with its exact violation code as a required CI gate. Root cause and enforcing invariant per advisory are in the CVE notes. - GGUF differential parity: modelvet and a real upstream GGUF loader run over the same inputs, and every divergence is triaged in the parity report. Across 249 inputs, none landed in "we accept / they reject", so modelvet is never more permissive than the loader it protects.
- safetensors differential parity: the same harness linked against the canonical Rust implementation at the audited commit. Zero more-permissive inputs, zero canonical panics, and each divergence maps to a documented policy code. Details, including the recovered Trail of Bits polyglot fixtures, are in the safetensors parity report.
- Fixed memory: the whole corpus plus both worst-case carve forcers run
through a single 64 KiB arena under the documented reduced-cap profile
(
make check-arena-64k). - Bounded work: every input-scaling loop is counted and checked against a
linear plus
n log nbudget, so hash flooding and quadratic scans fail the build (make check-work).
#include <stdint.h>
#include "modelvet.h"
uint8_t memory[MVET_GGUF_ARENA_WORST_BYTES];
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, file_bytes, file_length) != MVET_OK)
return 1;
if (report.verdict != MVET_VERDICT_ACCEPT)
return 1;MVET_GGUF_ARENA_WORST_BYTES is an exact bound for the configured caps,
including worst-case initial alignment padding. With the defaults it is
147,463 bytes on a 64-bit target. The documented 64 KiB profile lowers
MVET_MAX_TENSORS to 2048 and requires 49,159 bytes.
mvet_st_verify follows the same contract for safetensors files with the
exact bound MVET_ST_ARENA_WORST_BYTES (278,551 bytes at 64-bit defaults;
its documented 64 KiB profile needs 57,367). One arena sized to the larger
bound serves both verifiers; small inputs carve proportionally less.
Violation-code numbers are append-only ABI: never renumbered, never reused. Persist them freely. docs/RELEASE.md carries the full versioning policy.
make cli builds modelvet(1): one file per invocation, exit code =
verdict, --json single-object reports for pipelines, and content-based
format routing. See the man page at docs/modelvet.1.
$ build/modelvet --json model.gguf
{"file":"model.gguf","format":"gguf","verdict":"accept","violation":0,...}A ctypes Python binding lives in bindings/python
(verify_gguf, verify_safetensors, verify_path). The
integration guide covers vendoring the two-file
amalgamation, arena sizing, pipeline usage, and what an ACCEPT verdict
does not mean.
make lib # build build/libmodelvet.a
make test # run the hosted unit and behavior tests
make check # tests plus every repository gate below
make fuzz # build the libFuzzer + ASan targets
make amalgamation # emit and behavior-test build/modelvet.{c,h}
make check-amalg # full test sweep + CVE corpus against the amalgamation
make cli # build the modelvet(1) binary (build/modelvet)
make check-cli # CLI exit-code and JSON contract test
make test-profile-64k # verify the documented 64 KiB configuration
make test-no-assert # prove release behavior without internal assertions
make check-corpus # reject every CVE corpus file with its exact code
make check-arena-64k # whole corpus through one fixed 64 KiB arena
make check-work # bounded-work proof against a closed-form budget
make shared check-python # shared library + Python binding contract
make diff-st # safetensors differential vs the pinned canonical crate
make dist # reproducible release tarball with SHA-256Both GCC and Clang are supported. Compiler and profile flags are ordinary Make variables, for example:
make clean && make test CC=clang
make clean && make test CC=clang \
EXTRA_CFLAGS="-fsanitize=address,undefined -fno-sanitize-recover=all"The checked-in GGML dtype table is pinned to the llama.cpp revision in
tools/ggml-pin.env. Given a checkout of that source, verify it with:
make check-ggml GGML_DIR=/path/to/llama.cppThe safetensors contract (frame checks, offset tiling, dtype bit widths,
whitespace policy) mirrors the canonical Rust implementation pinned in
tools/st-pin.env. Deliberate strictness beyond canonical behavior
(duplicate keys, escapes in keys, unknown tensor fields, and the caps) is
marked "policy" on its violation codes in the public header.
modelvet performs structural verification only. It intentionally does not parse pickle/PyTorch payloads, inspect model behavior, validate tokenizer semantics, provide cryptographic signing, or run inference.
The repository's engineering guide defines the C discipline, architecture, invariants, testing matrix, and upstream drift process that support the security contract.
Pre-release, version 0.1.0. GGUF v2/v3 and safetensors structural
verification are implemented, with a modelvet(1) CLI and a Python
binding; the public API is not yet stable. Violation-code numbers are
already append-only ABI (see docs/RELEASE.md). Do not
use this release as a production security boundary without an independent
review.
MIT. Copyright (c) 2026 AgenC (tetsuo-ai). Built and maintained by the AgenC team (tetsuo-ai).


