From 3299de6ede480d2cabd032f307d16410056ac9e5 Mon Sep 17 00:00:00 2001 From: mr-r0b0t <60682530+am423@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:16:51 -0700 Subject: [PATCH 1/2] Fix audit coverage, preserve comparison evidence, and add evidence rubric --- .github/workflows/test.yml | 2 + README.md | 74 +++- docs/limitations.md | 39 +- docs/reading-a-report.md | 32 +- docs/safe-workflow.md | 41 +- examples/regenerate_samples.sh | 54 ++- model_audit.py | 748 ++++++++++++++++++++------------- report.py | 251 +++++++---- tests/test_cli.py | 304 ++++++++++++++ tests/test_report.py | 196 ++++++++- tests/test_safetensors.py | 16 +- 11 files changed, 1310 insertions(+), 447 deletions(-) create mode 100644 tests/test_cli.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a5e05d..96c7ba7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,8 @@ jobs: run: python3 tests/test_safetensors.py - name: Run report rendering self-tests run: python3 tests/test_report.py + - name: Run CLI and rubric regression tests + run: python3 tests/test_cli.py - name: Smoke-test CLI help run: | python3 model_audit.py --help > /dev/null diff --git a/README.md b/README.md index 2cb4fc9..3cbd655 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ [![self-test](https://github.com/ExileAI/model-audit/actions/workflows/test.yml/badge.svg)](https://github.com/ExileAI/model-audit/actions/workflows/test.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/) +[![python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/) **Before you run a model someone else made, check what's inside it.** `model-audit` is a supply-chain auditor for model files. You point it at a `.gguf` or -`.safetensors` file you downloaded, and it tells you whether anything hostile is hiding -in it — a booby-trapped chat template, hidden instructions, a file that claims to be one -thing but is another — then prints a plain-language report you can actually read. +`.safetensors` file you downloaded, and reports known suspicious patterns, inconsistent metadata, and structural +problems in the inputs it successfully inspects. It then prints a plain-language +report; it cannot establish that a model is safe. It exists because "uncensored" builds from small uploaders are exactly where a hostile actor would hide tampering, and the average user has no way to check. @@ -18,7 +18,8 @@ actor would hide tampering, and the average user has no way to check. first bytes; no flag to remember. - `.safetensors` auditing needs **no extra packages** (standard-library reader only). - The report is written for non-technical readers, not for security engineers. -- Nothing here phones home, and nothing is uploaded: it reads the file you already have. +- Local checks read your files without uploading them. The optional `--hf` check + queries Hugging Face metadata for the repository you specify. > **Not comfortable with a terminal?** You need three commands, they are copy-paste > below, and the report opens in your web browser like any other web page. That's the @@ -34,7 +35,7 @@ cd model-audit pip install -r requirements.txt ``` -That's the whole install. You need `python3` (3.8 or newer; CI runs 3.11). The +That's the whole install. You need `python3` (3.10 or newer; CI runs 3.11). The `pip install` pulls in the GGUF library and the Hugging Face client used by `--hf` — if you only audit `.safetensors` files you can skip it entirely. @@ -52,7 +53,8 @@ python3 model_audit.py ~/Downloads/my-model.gguf python3 report.py ~/Downloads/my-model.gguf ``` -The report lands in the `reports/` folder. Open it the way you'd open any web page — +The HTML/JSON pair lands in a unique run directory under `reports/`; the CLI prints +the HTML path. Finding exit codes 1 and 2 can accompany a successfully written report. Open it the way you'd open any web page — double-click it, or drag it into your browser. Everything below is an optional extra. ### Worked examples — both formats @@ -86,11 +88,11 @@ Every file gets one of three banners, in plain language: | banner | in one line | |---|---| -| ✅ **NO RED FLAGS FOUND** | nothing hostile was found in the file | +| ✅ **NO RED FLAGS FOUND** | no known suspicious patterns in the inputs successfully inspected | | ⚠️ **USE WITH AWARENESS** | read the warnings — many are harmless, from tool-calling models | | 🛑 **DO NOT RUN THIS FILE** | a critical problem was found — delete or quarantine it | -"No red flags" means nothing hostile was found — it is **not** a promise the weights are +"No red flags" describes only completed checks — it is **not** a promise the weights are safe, and **not** a statement about the uploader. See `docs/reading-a-report.md` for a full plain-language walkthrough, and `examples/` for real sample reports. @@ -117,8 +119,10 @@ full plain-language walkthrough, and `examples/` for real sample reports. python3 model_audit.py python3 model_audit.py -# also write per-tensor SHA-256 baseline (for comparing builds) +# write a baseline without replacing an existing reference python3 model_audit.py --tensor-hashes +# after a re-download, preserve the original and choose a new destination +python3 model_audit.py --tensor-hashes --baseline-out model.new.json # cross-check local hash against the Hugging Face repo it came from python3 model_audit.py --hf / --revision @@ -129,7 +133,7 @@ for f in /path/to/models/*/*.gguf; do python3 model_audit.py "$f"; done # compare two tensor-fingerprint baselines (see docs/safe-workflow.md) python3 model_audit.py --diff model.old.json model.new.json -# did this build come from that source tree? audit both, then compare across formats +# compare fingerprint evidence across formats (not proof of conversion history) python3 model_audit.py --tensor-hashes # the source python3 model_audit.py --tensor-hashes # the derived build python3 model_audit.py --diff .tensorhashes.json \ @@ -152,6 +156,31 @@ python3 model_audit.py --version Both scripts expose `--version`. Scripts and CI can branch on the exit code; the per-tensor diff mode (`--diff`) uses the same scale. +## Evidence rubric + +HTML and JSON include four dimensions: **Structure**, **Template inspection**, +**Provenance evidence**, and **Baseline comparison**. Each records PASS, CONCERN, +FAIL, NOT CHECKED, or justified N/A, with a reason and scope. Requested-check +coverage is shown separately as COMPLETE or INCOMPLETE. There is no numeric safety +score: four passes would still not prove safe weights. + +- PASS is scoped agreement or a completed inspection, never automatic uploader trust +- CONCERN means suspicious or inconclusive evidence, not a proven attack +- FAIL means an established invalid structure or unambiguous reference mismatch +- NOT CHECKED distinguishes absent, unrequested, failed, and incomplete inspection +- N/A requires a specific exemption, such as an embedded template in a clip/mmproj file + +Finding severity remains independent: a critical signature can show CONCERN in the +rubric while retaining exit 2. Optional unrequested checks do not change exit status. +Same-format baseline agreement covers bytes and recorded shapes only; dtype, byte +order, and tensor roles remain NOT VERIFIED. Generating a baseline is not comparing +one. See [the report guide](docs/reading-a-report.md). + +The core `--json` output remains one findings array. Reports retain `file` and +`findings`, with additive rubric data. Existing baseline files remain readable; +baseline writes now refuse existing destinations. Use `--baseline-out` for a new +snapshot and retain the original reference. + ## Sample reports `examples/` holds committed reports generated from real files, so you can see the output before running anything (click to open): @@ -175,23 +204,23 @@ See `examples/README.md` for a plain-language walkthrough of each. | Chat template | `tokenizer.chat_template` inside the file | **sidecars only**: `chat_template.jinja`, `chat_template` in `tokenizer_config.json` (all entries), conflict between the two | | Code execution path | — | `auto_map` in config/tokenizer_config, shipped scripts | | Per-tensor hashes | `--tensor-hashes` | `--tensor-hashes` (cheaper — offsets come from the header; both record a value-level hash too, see below) | -| Cross-format comparison | `--diff`: byte-identical blobs first, then value-identical ones (a converter upcasting bf16 norms to f32 keeps the numbers, not the bytes) | Quantized blocks are not comparable this way — the diff says so instead of guessing | +| Cross-format comparison | `--diff`: one-to-one byte matches, then explicitly inconclusive normalized-hash candidates | Quantized blocks are not comparable this way — the diff says so instead of guessing | | Remote check | `--hf`, by LFS SHA-256 first | same, any LFS-tracked file | | Uploader checksums | a shipped `MANIFEST.txt`/`SHA256SUMS`/`*.sha256` next to the file: does it still agree with this file, or has the file been renamed under it | nothing about safety — it is the uploader's own claim. Agreement is internal consistency; disagreement is the drift a swap leaves behind | ## Threat model & limits -- Metadata/template/structure checks catch: hostile chat templates, hidden instructions, - label mismatches, missing provenance, sidecar executables, remote file swaps, and - self-contradicting or appended-to file structures. +- Metadata/template/structure checks look for known suspicious signatures, label + mismatches, missing provenance, sidecar executables, reference mismatches, and + contradictory file structures. Incomplete checks are not a clean result. - A safetensors header is text the uploader wrote. It agreeing with the file's length and with itself proves consistency, not safety — the weights themselves are still unaudited. - Per-tensor hashes catch: swapped or edited weight blocks **when compared against a trusted baseline of the same base model**. -- A baseline also answers one provenance question directly: whether a conversion was - lossless. A source → derived pair matches on byte-identical blobs *and* on value-identical - blobs (a converter upcasting bf16 norms to f32 preserves the numbers, not the bytes), so - `--diff` can show a build carries the same weights as a tree you audited — and it says - outright when a comparison is not meaningful (block-quantized weights). +- Baselines retain byte and recorded-shape evidence. Cross-format comparison consumes + each tensor at most once and reports both unmatched sides. Normalized-hash matches + are candidates, not proof of equal numerical values: the existing baseline format + does not record dtype or byte order. Tensor roles, model equivalence, and conversion + history remain unverified. - A shipped checksum list (`MANIFEST.txt`, `SHA256SUMS`) is checked against the file, in both directions: agreement is internal consistency, a mismatch under the file's own name is a red flag, and the hash appearing under a *different* name means the file was renamed @@ -206,9 +235,8 @@ See `examples/README.md` for a plain-language walkthrough of each. (`ffn_down`/`ffn_out`/`attn_o`) vs. spread changes (broad re-training). - **Quantized lineage** — proving a Q4_K_M came from a specific fp source means re-deriving the quantization: K-quant block scales are a deterministic function of the source weights, - so recompute them and compare against the scales stored in the file. Until then, a - quantized build can only be tied to its source through the tensors quantization leaves - untouched (norms), which the diff already reports. + so recompute them and compare against the scales stored in the file. Until then, matching + unchanged tensors provide limited fingerprint evidence, not proof of a source lineage. - **Remote-only audit** — audit a Hugging Face repo's file list and hashes straight from the API, without downloading multi-GB files. diff --git a/docs/limitations.md b/docs/limitations.md index ac8ee2a..af8dd81 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -9,10 +9,10 @@ eliminate it. |---|---|---| | SHA-256 (local) | This exact byte stream is what you hashed | That those bytes are safe | | `--hf` remote check | Your copy matches what the repo publishes **at that moment/revision** | That the repo's copy was ever safe — you inherit whatever trust the uploader has | -| `--revision` pin | Match against a specific, immutable commit | Anything about commits *before* it; a repo can publish poison at v1 and swap at v2 | +| `--revision` pin | A full resolved commit identifies a snapshot; symbolic refs remain mutable | Anything about commits *before* it; a repo can publish poison at v1 and swap at v2 | | Template scan | The chat instructions carried inside the file contain no (detected) hostile code, hidden chars, or exfiltration patterns | That the scanner's pattern list is complete. Novel attacks evading these signatures will pass | | Metadata checks | Internal labels agree with filename claims; provenance KVs exist | That the labels are truthful — an uploader can set `general.name` to anything | -| Tensor census | Structure is complete and sane (no missing/empty blocks) | That tensor *values* are what they should be | +| Tensor census | Implemented inventory checks completed; legal empty tensors remain valid | That tensor *values* are what they should be | | safetensors header checks | The header parses, its declared lengths fit the file, each tensor's dtype × shape equals its declared byte span, spans never overlap, and no bytes are unaccounted for (gap or trailing data) | That the weights are what the header says — the header is text the uploader wrote. A perfectly self-consistent file can be a perfectly tampered one | | Template sidecar scan (safetensors) | The templates shipped next to the weights (`chat_template.jinja`, every `chat_template` entry in `tokenizer_config.json`) contain no (detected) hostile code, and two disagreeing sources are flagged | That those are the templates that will actually be used — your app may supply its own — or that the pattern list is complete | | `*.index.json` check | The shard description matches the shard it describes (weights present, `total_size` right) | That the other shards in the set, or the tensors' contents, are honest | @@ -34,7 +34,7 @@ eliminate it. 3. **See upstream history that was never pinned.** If nobody recorded the commit SHA (or the pre-conversion safetensors state) at download time, a force-push is - invisible in hindsight. HF is not git — history can simply vanish. This is why the + invisible in hindsight. A remote reference may become unavailable; a failed lookup alone does not establish why. This is why the workflow is: pin the commit **at download**, hash immediately, re-check against the pin later. @@ -51,17 +51,20 @@ eliminate it. re-check them on every re-download. A clean weights hash says nothing about the text your model reads its instructions from. -6. **Verify a quantized GGUF against its safetensors source.** Names and shapes can - always be matched against the upstream source tree, and `--tensor-hashes` baselines now - carry a **value-level** fingerprint as well as a byte-level one, so a lossless conversion - is provable across a storage-type change: llama.cpp upcasts bf16 norms to f32, which - changes the bytes while preserving the numbers exactly (verified at the bit level — the - f32 value is `bf16 << 16`). A real source→BF16 pair matched on 666 of 667 tensors this - way, the outlier being a table the converter computes. - Block-quantized weights (Q4_K_M and friends) are numerically transformed by design, so - their blobs match neither way — only the tensors quantization leaves untouched (norms, - usually) still tie the build to its source. `--diff` warns when you compare baselines made - from different formats; do not read those differences as tampering. +6. **Prove model equivalence or conversion history with anonymous hashes.** + The existing baseline records bytes, shapes, and optional normalized hashes, + not dtype, byte order, or semantic tensor roles. Cross-format comparison counts + each tensor once and reports byte matches separately from inconclusive + normalized-hash candidates. Even full correspondence does not prove numerical + equivalence, a correct conversion, or historical provenance. Quantized values + usually do not match; that alone is not evidence of tampering. + +7. **Guarantee one immutable snapshot or bounded hostile-input resource use.** + Audit stable completed files. Exact-range hashing rejects short reads, but + separate reads are not an atomic snapshot against a concurrent writer. Sidecar + size budgets, parser isolation, and hostile-directory confinement are not + provided. GGUF template inspection is embedded-only; index inspection is + limited to the selected local shard, not a complete bundle validation. ## What strong verification actually looks like @@ -78,8 +81,10 @@ The gold standard, in order: ## Practical guidance from verdicts -- `DO NOT RUN` — hostile template or structural breakage found. Don't load it. +- `DO NOT RUN` — a critical signal, invalid input, or hard failure needs resolution. + This is a conservative action recommendation, not proof of malicious intent. - `USE WITH AWARENESS` — read the specific warnings. Tool-calling models trip "file I/O" style patterns legitimately; the explanation text tells you when. -- `NO RED FLAGS` — the file is honest about what it is, **not** that it is safe. - For anything you care about, generate `--tensor-hashes` and keep them. +- `NO RED FLAGS` — completed checks found no known suspicious patterns. Read the + coverage/rubric for anything absent or uninspected. This proves neither honesty + nor safety. Preserve a baseline if comparison matters. diff --git a/docs/reading-a-report.md b/docs/reading-a-report.md index cb306a9..f8a2158 100644 --- a/docs/reading-a-report.md +++ b/docs/reading-a-report.md @@ -1,8 +1,8 @@ # How to read a model-audit report You don't need to know anything about model files to understand a report. It -answers one question — *is there anything hidden in this file that could hurt me?* -— and it is careful about what it claims. +shows the evidence the scanner collected and which checks completed. It cannot +answer whether a model is safe in general. ## The verdict, at the top of each file @@ -10,7 +10,7 @@ Every audited file gets one of three banners: | banner | meaning | what to do | |---|---|---| -| **NO RED FLAGS FOUND** | nothing hostile was found in the metadata, chat template, or structure | nothing required; save a fingerprint baseline if the model matters to you | +| **NO RED FLAGS FOUND** | completed checks found no known suspicious patterns; read coverage too | nothing required; save a fingerprint baseline if the model matters to you | | **USE WITH AWARENESS** | something needs your attention, but the file isn't clearly hostile | read each warning's plain-language note; many are false positives from tool-calling models | | **DO NOT RUN THIS FILE** | a critical problem was found | delete or quarantine it; get the model from a source that documents its build, or make it yourself | @@ -18,13 +18,29 @@ The three are a *scale of evidence*, not a scale of blame. "NO RED FLAGS" means nothing hostile was found — it is **not** a promise that the weights are safe, and **not** a statement that the uploader is trustworthy. See the bottom of the report. +## Evidence rubric and coverage + +Four rows separate Structure, Template inspection, Provenance evidence, and +Baseline comparison. PASS means a specific check completed and matched its +criteria; CONCERN means suspicious or inconclusive evidence; FAIL means a +confirmed structural/reference contradiction. NOT CHECKED records why a check +was absent, unrequested, unavailable, or incomplete. N/A is an explicit exemption. + +COMPLETE/INCOMPLETE describes requested applicable coverage, not model safety. +Optional unrequested remote checks do not count as failures. A template signature +can be CONCERN while its original severity remains Critical. A checksum PASS +means only agreement with the named source, not that the source is trusted. +Baseline PASS is limited to recorded bytes and shapes: dtype, byte order, tensor +roles, and model behavior remain unverified. There is no overall numeric score. + ## The identity card A small table describes what the file *claims* to be: - **Model type** — the architecture and quantization (e.g. `gemma4 · quant Q4_K_M`). A mismatch with the filename or the model card is worth noticing. -- **Chat template** — whether the instructions the model runs on are clean. +- **Chat template** — whether supported instructions were inspected and known + suspicious patterns were found; uninspected is never clean. - **SHA-256 fingerprint** and **Size** — the file's identity. Write these down; if the file ever changes, they won't match. @@ -32,8 +48,8 @@ A small table describes what the file *claims* to be: Below the banner, every finding is tagged: -- 🔴 **Critical** — a concrete hostile pattern (e.g. invisible characters hidden in - the chat template). These set the verdict to DO NOT RUN. +- 🔴 **Critical** — a critical signal, invalid structure, or hard error (for example, + invisible template characters). These set DO NOT RUN; they do not prove intent. - 🟠 **Warning** — something unusual that deserves a look. Often benign; read the plain-language note under it before worrying. - ⚪ **Info** — a fact about the file, recorded for completeness. Not a problem. @@ -43,8 +59,8 @@ that note is usually all you need. ## What the report can and cannot tell you -- It **can** show what the file claims to be, whether anything hostile hides in the - instructions it carries, and whether its structure contradicts itself. +- It **can** show self-reported labels, known suspicious instruction patterns, + structural contradictions, and the scope actually inspected. - It **cannot** prove the mathematical weights are free of subtle tampering. For that, generate per-tensor fingerprints (`--tensor-hashes`) and compare against a build you trust. diff --git a/docs/safe-workflow.md b/docs/safe-workflow.md index 0cc7fd9..1b82a47 100644 --- a/docs/safe-workflow.md +++ b/docs/safe-workflow.md @@ -13,8 +13,8 @@ Two or three "no" answers → treat the file as untrusted no matter what any aud ## 1. At download: pin and record -Record three things in a note next to the model — the moment you download is the only -time you can pin history: +Record three things in a note next to the model — recording the resolved commit at download preserves the +snapshot you intended to compare: ``` repo: / @@ -29,14 +29,15 @@ python3 model_audit.py model.gguf --hf / --revision python3 model_audit.py model.gguf --tensor-hashes ``` -- The first command proves your copy is what the repo published at that commit. +- A successful first check establishes a byte match to the identified remote file + at that commit; a failed or unavailable lookup does not. (It works the same for `model.safetensors` — the format is auto-detected.) - If the repo ships its own hash list (`MANIFEST.txt`, `SHA256SUMS`, `*.sha256`), the audit checks your file against it and tells you if the file was renamed after hashing. A mismatch under the file's own name is a red flag. - The second writes `model.gguf.tensorhashes.json` — the per-weight fingerprint. - **Keep it next to the model, forever.** It is your evidence that the weights you - run today are the weights you audited today. + **Keep that original reference.** Existing destinations are refused rather than + overwritten. This fingerprints the audited bytes; it does not prove the weights safe. Read the report. CRIT findings → do not load the model, full stop. Warnings → read each explanation; tool-calling models trip "file I/O"-style patterns legitimately. @@ -51,10 +52,10 @@ on their own, so hash them alongside the weights: sha256sum model.safetensors *.jinja tokenizer_config.json config.json > SIDECARS.sha256 ``` -The audit already scans every one of those templates for hostile content — but the +The audit scans supported template representations for known suspicious patterns — but the hash is what lets you prove later that the file you read is still the file in place. -### 2c. If you have the source tree, prove the conversion +### 2c. Compare source and converted fingerprint evidence When a build ships both the fp/bf16 source and a converted GGUF, fingerprint both and compare across formats: @@ -65,10 +66,11 @@ python3 model_audit.py model-Q8_0.gguf --tensor-hashes python3 model_audit.py --diff model.safetensors.tensorhashes.json model-Q8_0.gguf.tensorhashes.json ``` -- A **lossless** conversion matches on every blob: byte-identical where the container type - is the same, and value-identical where the converter changed only the storage type (bf16 - norms widened to f32 keep their numbers exactly). That is the strongest statement this - tool can make about provenance: the build carries the weights of a tree you audited. +- Byte matches and normalized-hash candidates are counted separately, consuming each + source and destination tensor at most once. Both unmatched sides are reported. The + current baseline lacks dtype and byte-order evidence, so normalized matches are + inconclusive; even a complete fingerprint match does not prove a lossless conversion + or correct tensor-role mapping. - A **quantized** build matches only on the tensors quantization leaves untouched (usually the norms). The rest is reported as "not comparable this way" — quantization transforms values by design, so that is not a red flag. @@ -77,7 +79,17 @@ python3 model_audit.py --diff model.safetensors.tensorhashes.json model-Q8_0.ggu ## 3. Whenever you re-download or copy the model -Re-run both commands. The `--tensor-hashes` JSON now pays off: +Preserve the original baseline and write a distinct new one: + +```bash +python3 model_audit.py model.gguf --hf / --revision +python3 model_audit.py model.gguf --tensor-hashes --baseline-out model.new.json +python3 model_audit.py --diff model.gguf.tensorhashes.json model.new.json +``` + +Choose a fresh destination for each snapshot; neither output replaces your reference. +Finding exit codes are expected when differences or warnings are present. Read the +findings rather than treating exit 1/2 as successful verification. - Same file SHA-256 → identical copy. - Different SHA-256 but you need to know *what* changed → compare tensor baselines @@ -94,8 +106,9 @@ Re-run both commands. The `--tensor-hashes` JSON now pays off: - Never enable `trust_remote_code` for a model you have not read. If the audit reports `auto_map` in `config.json` or `tokenizer_config.json`, the repo ships Python that your loader will execute — that is code you are running, not weights you are loading. -- Re-check the repo occasionally: if the pinned revision's files change upstream, - your copy is now the only honest one — and the uploader has some explaining to do. +- Retain the full resolved upstream commit. A mismatch or unavailable reference + needs investigation; it alone does not prove a force-push, malicious replacement, + or that either copy is honest. ## What this workflow still cannot do diff --git a/examples/regenerate_samples.sh b/examples/regenerate_samples.sh index 278cd59..93f8882 100755 --- a/examples/regenerate_samples.sh +++ b/examples/regenerate_samples.sh @@ -23,40 +23,66 @@ WARN_SRC="${WARN_SRC:-}" REDACT="${REDACT:-}" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT -one() { # one - local d="$1" name="$2"; shift 2 +# Finding statuses are expected for the WARN/CRIT examples. Accept only the +# requested status and a complete artifact pair; never hide execution failures. +collect() { # collect + local expected="$1" cwd="$2" d="$3" name="$4"; shift 4 + local html status=0 json mkdir -p "$WORK/$d" - ( cd "$MODELS" && python3 "$REPO/report.py" -o "$WORK/$d" "$@" ) >/dev/null - mv "$WORK/$d"/*.html "$HERE/$name.html" - mv "$WORK/$d"/*.json "$HERE/$name.json" + html="$(cd "$cwd" && python3 "$REPO/report.py" -o "$WORK/$d" "$@")" || status=$? + if [ "$status" -ne "$expected" ]; then + echo "unexpected report status $status (expected $expected): $name" >&2 + return 1 + fi + json="${html%.html}.json" + if [[ "$html" != "$WORK/$d/"* || "$html" != *.html || ! -f "$html" || ! -f "$json" ]]; then + echo "report did not produce a complete pair: $name" >&2 + return 1 + fi + # A deliberately critical sample must contain the intended template finding, + # not merely a missing-file/parser failure that also returns exit 2. + python3 - "$json" "$expected" <<'CHECK' +import json, sys +with open(sys.argv[1], encoding="utf-8") as f: + reports = json.load(f) +items = [item for report in reports for item in report["findings"]] +if any(item["section"] == "audit" and item["severity"] == "CRIT" for item in items): + raise SystemExit("sample audit failed") +if sys.argv[2] == "2" and not any(item["section"] == "template" and item["severity"] == "CRIT" for item in items): + raise SystemExit("expected critical template specimen was not detected") +CHECK + mv "$html" "$HERE/$name.html" + mv "$json" "$HERE/$name.json" +} +one() { # one + local expected="$1" d="$2" name="$3"; shift 3 + collect "$expected" "$MODELS" "$d" "$name" "$@" } # 01 — clean flagship (also the honest, properly-attributed case) -one 01 report-01-clean-gemma-12b-obliterated \ +one 0 01 report-01-clean-gemma-12b-obliterated \ "OBLITERATUS/Gemma-4-12B-OBLITERATED/Gemma-4-12B-OBLITERATED-v2-Q4_K_M.gguf" # 02 — USE WITH AWARENESS (source + uploader handle kept out of this repo) if [ -n "$WARN_SRC" ]; then mkdir -p "$WORK/warn-src" ln -sf "$WARN_SRC" "$WORK/warn-src/uncensored-20b-mxfp4.gguf" - ( cd "$WORK/warn-src" && python3 "$REPO/report.py" -o "$WORK/02" "uncensored-20b-mxfp4.gguf" ) >/dev/null - mv "$WORK/02"/*.html "$HERE/report-02-warn-mxfp4-anonymous.html" - mv "$WORK/02"/*.json "$HERE/report-02-warn-mxfp4-anonymous.json" + collect 1 "$WORK/warn-src" 02 report-02-warn-mxfp4-anonymous "uncensored-20b-mxfp4.gguf" python3 - "$REDACT" "$HERE/report-02-warn-mxfp4-anonymous.html" "$HERE/report-02-warn-mxfp4-anonymous.json" <<'PY' import sys, pathlib tokens = [t for t in sys.argv[1].split() if t] for f in sys.argv[2:]: - p = pathlib.Path(f); t = p.read_text() + p = pathlib.Path(f); t = p.read_text(encoding="utf-8") for tok in tokens: t = t.replace(tok, "REDACTED") - p.write_text(t) + p.write_text(t, encoding="utf-8") PY else echo "skip 02 (set WARN_SRC=/path.gguf REDACT=Token to regenerate the WARN sample)" fi # 03 — two uploaders, same AEON lineage (derivative-looking pair) -one 03 report-03-aeon-pair-two-uploaders \ +one 0 03 report-03-aeon-pair-two-uploaders \ "vcruz305/Qwen3.8-27B-AEON-ULTIMATE-UNCENSORED-GGUF/Qwen3.8-27B-AEON-ULTIMATE-UNCENSORED-Q4_K_M.gguf" \ "Abiray/Qwen3.6-27B-AEON-Ultimate-Uncensored-GGUF/qwen3.6-27b-uncensored-q4_k_s.gguf" @@ -68,9 +94,7 @@ python3 "$HERE/synthesize_do_not_run.py" \ "$REPO/templates/tampered/03-invisible-instructions.j2" \ "$WORK/dnr/Gemma-4-12B-OBLITERATED-demo-tampered-template.gguf" cp -n "$SRCDIR"/*.json "$WORK/dnr/" 2>/dev/null || true -( cd "$WORK/dnr" && python3 "$REPO/report.py" -o "$WORK/04" "Gemma-4-12B-OBLITERATED-demo-tampered-template.gguf" ) >/dev/null -mv "$WORK/04"/*.html "$HERE/report-04-do-not-run-template-swap.html" -mv "$WORK/04"/*.json "$HERE/report-04-do-not-run-template-swap.json" +collect 2 "$WORK/dnr" 04 report-04-do-not-run-template-swap "Gemma-4-12B-OBLITERATED-demo-tampered-template.gguf" echo "regenerated samples in $HERE" if grep -rIl --include='*.html' --include='*.json' "/home/\|/neo" "$HERE"; then echo "LEAK — see above"; exit 1; else echo "leak check: clean"; fi diff --git a/model_audit.py b/model_audit.py index ef4cff9..a2364f1 100644 --- a/model_audit.py +++ b/model_audit.py @@ -17,12 +17,13 @@ enable baseline diffing against a known-good quant of the same base. 5. Sidecars: flags any scripts/executables shipped next to the GGUF. 6. Optional remote check: --hf / compares local SHA-256 against - the LFS hash HF publishes (detects force-push / file swap since download). + the LFS hash HF publishes (reference agreement, not proof of history). Exit codes: 0 = clean, 1 = findings, 2 = hard errors. """ -import argparse, hashlib, json, os, re, struct, sys, unicodedata +import argparse, hashlib, json, math, os, re, struct, sys, unicodedata from pathlib import Path +from collections import defaultdict, deque CHUNK = 1 << 24 try: @@ -36,6 +37,12 @@ class Report: def __init__(self): self.items = [] # (severity, section, message) + self.observations = {k: [] for k in ("structure", "templates", "provenance", "baseline")} + self.template_coverage = [] + def observe(self, domain, status, reason, scope, required=True): + self.observations[domain].append(dict(status=status, reason=reason, scope=scope, required=required)) + def exit_code(self): + return 2 if any(s == "CRIT" for s, _, _ in self.items) else (1 if any(s == "WARN" for s, _, _ in self.items) else 0) def add(self, sev, section, msg): self.items.append((sev, section, msg)) def print(self, as_json=False): @@ -45,7 +52,75 @@ def print(self, as_json=False): return order = {"CRIT": 0, "WARN": 1, "INFO": 2, "OK": 3} for sev, sec, msg in sorted(self.items, key=lambda x: order.get(x[0], 9)): - print(f"[{sev:4}] {sec:12} {msg}") + print(terminal_text(f"[{sev:4}] {sec:12} {msg}")) + +def terminal_text(text): + """Keep stored evidence intact; escape controls only at the terminal boundary.""" + return "".join(c if c.isprintable() else ascii(c)[1:-1] for c in text) + + +def evidence_rubric(rep): + """Summarize explicit observations, never infer coverage from finding prose.""" + domains, incomplete = {}, [] + priority = {"FAIL": 0, "NOT CHECKED": 1, "CONCERN": 2, "PASS": 3, "N/A": 4} + for domain, observations in rep.observations.items(): + if not observations: + domains[domain] = dict(status="NOT CHECKED", reason="Not requested or not inspected", scope=domain) + continue + state = min(observations, key=lambda o: priority[o["status"]])["status"] + domains[domain] = dict(status=state, + reason="; ".join(sorted({o["reason"] for o in observations})), + scope="; ".join(sorted({o["scope"] for o in observations}))) + if any(o["required"] and o["status"] == "NOT CHECKED" for o in observations): + incomplete.append(domain) + return dict(domains=domains, coverage=dict(status="INCOMPLETE" if incomplete else "COMPLETE", + reason=("Required checks incomplete: " + ", ".join(incomplete)) if incomplete else "Requested applicable checks completed; no safety guarantee", + scope="Only requested, implemented checks; optional unrequested checks are excluded")) + + +def add_rubric(rep): + rubric = evidence_rubric(rep) + for domain, row in rubric["domains"].items(): + rep.add("INFO", "rubric", f"{domain}: {row['status']} — {row['reason']} (scope: {row['scope']})") + rep.add("INFO", "rubric", "coverage: " + rubric["coverage"]["status"] + " — " + rubric["coverage"]["reason"]) + + +def strict_json(text): + def pairs(items): + result = {} + for key, value in items: + if key in result: + raise ValueError(f"duplicate JSON key: {key}") + result[key] = value + return result + def constant(value): + raise ValueError(f"non-finite JSON number: {value}") + def finite_float(value): + result = float(value) + if not math.isfinite(result): + raise ValueError("non-finite JSON number") + return result + try: + return json.loads(text, object_pairs_hook=pairs, parse_constant=constant, parse_float=finite_float) + except RecursionError as exc: + raise ValueError("JSON nesting exceeds the parser limit") from exc + + +def json_object(path): + obj = strict_json(Path(path).read_text("utf-8")) + if not isinstance(obj, dict): + raise ValueError("top level must be a JSON object") + return obj + + +def valid_shape(shape): + return isinstance(shape, list) and all(type(d) is int and d >= 0 for d in shape) + + +def validate_range(offset, length): + if type(offset) is not int or type(length) is not int or offset < 0 or length < 0: + raise ValueError("tensor range requires nonnegative integer offset and length") + # ---------------- streaming hashes ---------------- def file_hashes(path): @@ -62,6 +137,7 @@ def file_hashes(path): def sha256_at(path, offset, length): """Read exactly [offset, offset+length) without loading the whole file.""" + validate_range(offset, length) h = hashlib.sha256() with open(path, "rb") as f: f.seek(offset) @@ -69,7 +145,7 @@ def sha256_at(path, offset, length): while remaining > 0: b = f.read(min(CHUNK, remaining)) if not b: - break + raise ValueError("short read while hashing tensor range") h.update(b) remaining -= len(b) return h.hexdigest() @@ -82,6 +158,8 @@ def sha256_at(path, offset, length): def _to_f32_bytes(chunk, dtype): """Widen a chunk of BF16/F16/F32 little-endian bytes to f32, exactly.""" + if dtype not in EXPANDABLE or len(chunk) % EXPANDABLE[dtype]: + raise ValueError("unsupported dtype or misaligned floating-point bytes") if dtype == "F32": return chunk if dtype == "BF16": @@ -100,6 +178,9 @@ def tensor_fingerprints(path, offset, length, dtype): value_sha256 is the hash of the tensor's values widened to f32, or None for dtypes that cannot be widened (quantized blocks). For F32 tensors it equals the byte hash. """ + validate_range(offset, length) + if dtype in EXPANDABLE and length % EXPANDABLE[dtype]: + raise ValueError("misaligned floating-point tensor range") h_bytes = hashlib.sha256() h_vals = hashlib.sha256() if dtype in EXPANDABLE else None step = EXPANDABLE.get(dtype, 1) @@ -109,7 +190,7 @@ def tensor_fingerprints(path, offset, length, dtype): while remaining > 0: b = f.read(min(CHUNK, remaining)) if not b: - break + raise ValueError("short read while fingerprinting tensor range") remaining -= len(b) h_bytes.update(b) if h_vals is not None: @@ -118,7 +199,7 @@ def tensor_fingerprints(path, offset, length, dtype): h_vals.update(_to_f32_bytes(b[:cut], dtype)) leftover = b[cut:] if h_vals is not None and leftover: - h_vals.update(leftover) + raise ValueError("incomplete floating-point element") return h_bytes.hexdigest(), (h_vals.hexdigest() if h_vals else None) # ---------------- template tamper scan ---------------- @@ -145,6 +226,9 @@ def scan_template(tpl, rep): rep.add("CRIT", "template", "No tokenizer.chat_template in file — loaders fall back to" " card config or defaults; behavior not pinned by this artifact.") return + if not isinstance(tpl, str): + rep.add("WARN", "template", "template invalid: expected text") + return zwc = [c for c in tpl if c in ZERO_WIDTH] if zwc: rep.add("CRIT", "template", f"{len(zwc)} zero-width/invisible chars embedded in template " @@ -186,9 +270,7 @@ def scan_template(tpl, rep): "system_message", "system_prompt", "tools", "documents", "prompt", "strip", "tojson", "messages_json"}: rep.add("INFO", "template", f"references non-standard variable: {var}") - print("\n--- chat template (verbatim from file) ---") - print(tpl) - print("--- end template ---\n") + # ---------------- architecture sanity ---------------- EXPECTED_PREFIX = {"llama": "blk", "qwen2": "blk", "gemma": None} # loose; count-checked below @@ -333,13 +415,15 @@ def read_st_header(path, rep): rep.add("CRIT", "st-header", f"header unreadable: declared length {hlen:,} bytes is implausible " f"(> 256 MiB) — fabricated or corrupt") return None - if hlen >= size - 8: + if hlen > size - 8: rep.add("CRIT", "st-header", f"header unreadable: declared length {hlen:,} exceeds the file size " f"{size:,} — truncated or fabricated file") return None blob = f.read(hlen) try: - hdr = json.loads(blob.decode("utf-8")) + if len(blob) != hlen: + raise ValueError("short header read") + hdr = strict_json(blob.decode("utf-8")) except Exception as e: rep.add("CRIT", "st-header", f"header invalid: not valid JSON ({e})") return None @@ -355,9 +439,15 @@ def read_st_header(path, rep): def st_tensors(hdr, data_start, size, rep): """Validate every declared tensor and return shims the shared checks can use.""" + rep.structure_valid = True + rep.structure_complete = True + rep.structure_concern = False + rep.baseline_eligible = True meta = hdr.get("__metadata__") - if meta is not None and not isinstance(meta, dict): - rep.add("CRIT", "st-header", "__metadata__ present but not a JSON object") + if "__metadata__" in hdr and (not isinstance(meta, dict) or not all(isinstance(v, str) for v in meta.values())): + rep.structure_valid = False + rep.baseline_eligible = False + rep.add("CRIT", "st-header", "__metadata__ must be a string-to-string JSON object") elif isinstance(meta, dict): for k, v in sorted(meta.items()): if isinstance(v, str) and len(v) <= 200: @@ -366,22 +456,35 @@ def st_tensors(hdr, data_start, size, rep): rep.add("INFO", "provenance", f"__metadata__[{k}] present (non-string or oversized)") entries = {k: v for k, v in hdr.items() if k != "__metadata__"} if not entries: - rep.add("CRIT", "st-header", "no tensors declared in header") + rep.add("INFO", "st-header", "no tensors declared in header (empty artifact)") tensors, spans = [], [] for name, ent in entries.items(): if not isinstance(ent, dict) or not {"dtype", "shape", "data_offsets"} <= set(ent): + rep.structure_valid = False + rep.baseline_eligible = False rep.add("CRIT", "st-header", f"tensor entry missing dtype/shape/data_offsets: {name}") continue dtype, shape, off = ent["dtype"], ent["shape"], ent["data_offsets"] - if not (isinstance(off, list) and len(off) == 2 and all(isinstance(x, int) for x in off)): + if not isinstance(dtype, str) or not valid_shape(shape): + rep.structure_valid = False + rep.baseline_eligible = False + rep.add("CRIT", "st-header", f"tensor entry invalid dtype or shape: {name}") + continue + if not (isinstance(off, list) and len(off) == 2 and all(type(x) is int for x in off)): + rep.structure_valid = False + rep.baseline_eligible = False rep.add("CRIT", "st-header", f"tensor entry has malformed data_offsets: {name} ({off!r})") continue begin, end = off if begin < 0 or end < begin: + rep.structure_valid = False + rep.baseline_eligible = False rep.add("CRIT", "st-header", f"tensor entry has nonsensical offsets: {name} [{begin}, {end})") continue n_bytes = end - begin if data_start + end > size: + rep.structure_valid = False + rep.baseline_eligible = False rep.add("CRIT", "st-tensors", f"tensor size: {name} declares data ending at " f"{data_start + end:,}, past the end of the file ({size:,}) — truncated") continue @@ -390,68 +493,79 @@ def st_tensors(hdr, data_start, size, rep): for d in shape: numel *= d if unit is None: + rep.structure_complete = False + rep.baseline_eligible = False rep.add("WARN", "st-tensors", f"tensor size: {name} dtype {dtype!r} is not a known type — " f"its byte size cannot be verified") elif int(numel * unit) != n_bytes: + rep.structure_valid = False + rep.baseline_eligible = False rep.add("CRIT", "st-tensors", f"tensor size: {name} is {dtype} {list(shape)}, which implies " f"{int(numel * unit):,} bytes, but the header declares {n_bytes:,} — " f"the header contradicts itself") if n_bytes == 0 or any(d == 0 for d in shape): - rep.add("WARN", "st-tensors", f"tensor size: {name} is empty ({dtype} {list(shape)})") + rep.add("INFO", "st-tensors", f"tensor size: {name} is empty ({dtype} {list(shape)})") tensors.append(STTensor(name, shape, dtype, data_start + begin, n_bytes)) spans.append((begin, end, name)) spans.sort() prev_end = 0 for begin, end, name in spans: if begin < prev_end: + rep.structure_valid = False + rep.baseline_eligible = False rep.add("CRIT", "st-tensors", f"tensor layout: {name} starts inside another tensor's data — " f"the declared layout is not valid safetensors") elif begin > prev_end: + rep.structure_concern = True + rep.baseline_eligible = False rep.add("WARN", "st-tensors", f"undeclared gap: {begin - prev_end:,} bytes between tensors before " f"{name} that no header entry accounts for") prev_end = max(prev_end, end) if prev_end < size - data_start: + rep.structure_concern = True + rep.baseline_eligible = False rep.add("WARN", "st-tensors", f"trailing data: {size - data_start - prev_end:,} bytes after the last " f"declared tensor that the header does not account for") return tensors + def check_st_config(path, rep): - """config.json is where a safetensors file's claimed identity and provenance live.""" - fp = path.parent / "config.json" - if not fp.exists(): - rep.add("INFO", "meta", "no config.json in this directory — architecture and provenance are not " - "pinned by any sidecar here") - return - try: - cfg = json.loads(fp.read_text("utf-8")) - except Exception as e: - rep.add("WARN", "sidecar", f"config.json present but unreadable: {e}") - return - archs = cfg.get("architectures") or [] - name = cfg.get("_name_or_path") or cfg.get("name_or_path") - name_txt = repr(name) if name else "(not stated in config.json)" - rep.add("INFO", "meta", f"architecture={archs[0] if archs else cfg.get('model_type', '?')} " - f"name={name_txt} file_type={cfg.get('torch_dtype', '?')} (safetensors)") - for key in ("model_type", "torch_dtype", "transformers_version", "base_model", - "quantized_by", "converted_by", "source"): - if cfg.get(key): - rep.add("INFO", "meta", f"{key} = {cfg[key]}") - if cfg.get("quantization_config"): - qc = cfg["quantization_config"] - method = qc.get("quant_method", "?") if isinstance(qc, dict) else "?" - rep.add("INFO", "provenance", f"config.json carries quantization_config ({method}) — these weights are " - f"not raw fp/bf16, and their provenance is the uploader's word") - for fp2, label in ((fp, "config.json"), (path.parent / "tokenizer_config.json", "tokenizer_config.json")): - if not fp2.exists(): + """Inspect config and tokenizer config independently, including remote-code hints.""" + for name in ("config.json", "tokenizer_config.json"): + fp = path.parent / name + if not fp.exists(): + if name == "config.json": + rep.add("INFO", "meta", "no config.json in this directory — provenance is not pinned here") continue try: - other = cfg if fp2 == fp else json.loads(fp2.read_text("utf-8")) - except Exception: + cfg = json_object(fp) + archs = cfg.get("architectures", []) + if not isinstance(archs, list) or not all(isinstance(x, str) for x in archs): + raise ValueError("architectures must be a list of strings") + if "auto_map" in cfg and (not isinstance(cfg["auto_map"], dict) or not all( + isinstance(v, str) or (isinstance(v, list) and all(x is None or isinstance(x, str) for x in v)) for v in cfg["auto_map"].values())): + raise ValueError("auto_map must map names to text or lists of text/null") + if "quantization_config" in cfg and not isinstance(cfg["quantization_config"], dict): + raise ValueError("quantization_config must be an object") + for key in ("model_type", "torch_dtype", "transformers_version", "_name_or_path", "name_or_path"): + if key in cfg and cfg[key] is not None and not isinstance(cfg[key], str): + raise ValueError(f"{key} must be text") + except (OSError, ValueError, UnicodeError) as e: + rep.add("WARN", "sidecar", f"{name} present but unreadable: {e}") + rep.observe("provenance", "NOT CHECKED", "Present configuration could not be validated", name + " self-reported configuration") continue - if other.get("auto_map"): - rep.add("WARN", "sidecar", f"auto_map declared in {label}: loading this model with " - f"trust_remote_code runs Python the uploader ships — a separate supply " - f"chain from the weights, and the usual way a 'model' executes code") + if name == "config.json": + model_name = cfg.get("_name_or_path") or cfg.get("name_or_path") + rep.add("INFO", "meta", f"architecture={archs[0] if archs else cfg.get('model_type', '?')} " + f"name={model_name!r} file_type={cfg.get('torch_dtype', '?')} (safetensors)") + for key in ("model_type", "torch_dtype", "transformers_version", "base_model", "quantized_by", "converted_by", "source"): + if cfg.get(key): + rep.add("INFO", "meta", f"{key} = {cfg[key]}") + if cfg.get("quantization_config"): + rep.add("INFO", "provenance", "config.json carries quantization_config — provenance is self-reported") + if cfg.get("auto_map"): + rep.add("WARN", "sidecar", f"auto_map declared in {name}: trust_remote_code may execute uploader Python") + def _scan_template_labeled(text, rep, label): before = len(rep.items) @@ -460,46 +574,85 @@ def _scan_template_labeled(text, rep, label): sev, sec, msg = rep.items[i] rep.items[i] = (sev, sec, f"{label}: {msg}") + +def record_template(text, rep, label, logical_name): + before = len(rep.items) + _scan_template_labeled(text, rep, label) + suspicious = any(s in ("WARN", "CRIT") for s, _, _ in rep.items[before:]) + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + rep.template_coverage.append(dict(label=label, logical_name=logical_name, sha256=digest, state="inspected")) + rep.observe("templates", "CONCERN" if suspicious else "PASS", + "Signature findings require review" if suspicious else "All inspected text passed implemented signatures", + label + " (signature scan only)") + rep.add("INFO", "template", f"template sidecar: {label} ({len(text.encode('utf-8')):,} bytes)") + + +def template_incomplete(rep, label, reason): + rep.template_coverage.append(dict(label=label, logical_name=None, sha256=None, state="incomplete")) + rep.observe("templates", "NOT CHECKED", reason, label) + rep.add("WARN", "template", f"template invalid: {label}: {reason}") + + def scan_st_templates(path, rep): - """The template is not in the artifact — it is a sidecar. Scan every one we find.""" + """Scan every supported source, preserving incomplete coverage and logical names.""" d = path.parent - jinjas = [p for p in sorted(d.glob("*.jinja")) + sorted(d.glob("*.jinja2")) if p.is_file()] - cfg_templates = [] + jinjas = sorted(set(d.glob("*.jinja")) | set(d.glob("*.jinja2")) | set((d / "additional_chat_templates").glob("*.jinja"))) for fp in jinjas: - rep.add("INFO", "template", f"template sidecar: {fp.name} ({fp.stat().st_size:,} bytes)") + label = str(fp.relative_to(d)) + logical = fp.stem if fp.parent.name == "additional_chat_templates" else ("default" if fp.stem == "chat_template" else fp.stem) try: - _scan_template_labeled(fp.read_text("utf-8", errors="replace"), rep, fp.name) - except Exception as e: - rep.add("WARN", "template", f"template sidecar: {fp.name} unreadable: {e}") - tcfg = d / "tokenizer_config.json" - if tcfg.exists(): + record_template(fp.read_text("utf-8"), rep, label, logical) + except (OSError, UnicodeError) as e: + template_incomplete(rep, label, str(e)) + for filename in ("tokenizer_config.json", "chat_template.json"): + fp = d / filename + if not fp.exists(): + continue try: - tcfg_json = json.loads(tcfg.read_text("utf-8")) - except Exception as e: - tcfg_json = {} - rep.add("WARN", "sidecar", f"tokenizer_config.json present but unreadable: {e}") - tpl = tcfg_json.get("chat_template") + obj = json_object(fp) + except (OSError, ValueError, UnicodeError) as e: + template_incomplete(rep, filename, str(e)) + continue + if "chat_template" not in obj: + continue + tpl = obj["chat_template"] + if filename == "chat_template.json" and not isinstance(tpl, str): + template_incomplete(rep, filename, "legacy chat_template.json requires a chat_template string") + continue + entries = [] if isinstance(tpl, str): - cfg_templates.append(("tokenizer_config.json", tpl)) - elif isinstance(tpl, list): - cfg_templates += [(f"tokenizer_config.json[chat_template[{i}]]", t) - for i, t in enumerate(tpl) if isinstance(t, str)] + entries = [("default", tpl, filename)] elif isinstance(tpl, dict): - cfg_templates += [(f"tokenizer_config.json[chat_template.{k}]", v) - for k, v in tpl.items() if isinstance(v, str)] - for label, text in cfg_templates: - rep.add("INFO", "template", f"template sidecar: {label} ({len(text):,} bytes)") - _scan_template_labeled(text, rep, label) - if jinjas and cfg_templates: - jtxt = jinjas[0].read_text("utf-8", errors="replace") - if any(text != jtxt for _, text in cfg_templates): - rep.add("WARN", "template", "both a chat_template.jinja sidecar and a chat_template in " - "tokenizer_config.json exist and they differ — which one a loader uses " - "is runtime-dependent, so the model's behavior is not pinned by either") - if not jinjas and not cfg_templates: - rep.add("INFO", "template", "no chat template sidecar in this directory (chat_template.jinja or " - "tokenizer_config.json) — this format never carries the template inside the " - "file, so whatever loads the model supplies it") + entries = [(key, val, f"{filename}[chat_template.{key}]") for key, val in tpl.items()] + elif isinstance(tpl, list): + for i, entry in enumerate(tpl): + label = f"{filename}[chat_template[{i}]]" + if isinstance(entry, str): + entries.append(("default" if i == 0 else f"legacy_{i}", entry, label)) + elif isinstance(entry, dict) and isinstance(entry.get("name"), str) and isinstance(entry.get("template"), str): + entries.append((entry["name"], entry["template"], label)) + else: + template_incomplete(rep, label, "expected text or a named {name, template} record") + else: + template_incomplete(rep, filename, "chat_template must be text, a map, or a list") + if isinstance(tpl, (dict, list)) and not tpl: + template_incomplete(rep, filename, "empty template collection") + for logical, text, label in entries: + if not isinstance(text, str) or not logical: + template_incomplete(rep, label, "template name and text must be strings") + else: + record_template(text, rep, label, logical) + by_name = {} + for source in rep.template_coverage: + if source["state"] == "inspected": + by_name.setdefault(source["logical_name"], set()).add(source["sha256"]) + for logical, hashes in sorted(by_name.items()): + if len(hashes) > 1: + rep.add("WARN", "template", f"template sources for {logical!r}: they differ — loader selection is runtime-dependent") + rep.observe("templates", "CONCERN", "Sources for the same logical template differ", logical) + if not rep.template_coverage: + rep.add("INFO", "template", "no chat template sidecar in this directory — the loader supplies it") + rep.observe("templates", "NOT CHECKED", "No template source was available", "supported safetensors sidecars") def check_st_index(path, tensors, rep): """Multi-shard models are described by model.safetensors.index.json — check it agrees.""" @@ -508,9 +661,16 @@ def check_st_index(path, tensors, rep): return fp = idxs[0] try: - idx = json.loads(fp.read_text("utf-8")) + idx = json_object(fp) + wmap = idx.get("weight_map") + meta = idx.get("metadata", {}) + if not isinstance(wmap, dict) or not all(isinstance(v, str) for v in wmap.values()): + raise ValueError("weight_map must map tensor names to shard strings") + if not isinstance(meta, dict) or ("total_size" in meta and (type(meta["total_size"]) is not int or meta["total_size"] < 0)): + raise ValueError("metadata.total_size must be a nonnegative integer") except Exception as e: rep.add("WARN", "sidecar", f"index mismatch: {fp.name} unreadable ({e})") + rep.observe("structure", "NOT CHECKED", "Present shard index could not be validated", fp.name) return wmap = idx.get("weight_map") or {} shards = sorted(set(wmap.values())) @@ -520,15 +680,18 @@ def check_st_index(path, tensors, rep): missing = sorted(listed_here - mine) unlisted = sorted(mine - listed_here) if missing: + rep.observe("structure", "FAIL", "Index references tensors absent from this shard", fp.name) rep.add("CRIT", "index", f"index mismatch: {len(missing)} tensor(s) the index routes to this shard are " f"absent from it (e.g. {', '.join(missing[:3])})") if unlisted: + rep.observe("structure", "CONCERN", "Shard tensors are not listed in selected index", fp.name) rep.add("WARN", "index", f"index mismatch: {len(unlisted)} tensor(s) present here are not listed in " f"{fp.name} (e.g. {', '.join(unlisted[:3])}) — added or renamed after indexing") total = (idx.get("metadata") or {}).get("total_size") if len(shards) == 1 and isinstance(total, int): declared = sum(t.n_bytes for t in tensors) if total != declared: + rep.observe("structure", "FAIL", "Selected single-shard index total_size disagrees", fp.name) rep.add("CRIT", "index", f"index mismatch: metadata.total_size is {total:,} bytes but this file " f"declares {declared:,} — the index describes a different file") @@ -550,74 +713,82 @@ def check_manifest(path, sha, rep): if p.is_file()), None) if fp is None: return + scope = f"Uploader-supplied checksum {fp.name}; consistency only, not independent trust" entries = {} - for line in fp.read_text("utf-8", errors="replace").splitlines(): + try: + lines = fp.read_text("utf-8").splitlines() + except (OSError, UnicodeError) as e: + rep.add("WARN", "manifest", f"manifest unreadable: {e}") + rep.observe("provenance", "NOT CHECKED", "Checksum list could not be read", scope) + return + unsupported = False + for line in lines: + if not line.strip() or line.lstrip().startswith("#"): + continue parts = line.split() - if len(parts) < 2: + if len(parts) != 2: + unsupported = True continue a, b = parts[0], parts[-1].lstrip("*") if re.fullmatch(r"[0-9a-fA-F]{64}", a): - entries[b] = a.lower() + entries.setdefault(b, []).append(a.lower()) elif re.fullmatch(r"[0-9a-fA-F]{64}", b): - entries[a] = b.lower() + entries.setdefault(a, []).append(b.lower()) + else: + unsupported = True + if unsupported: + rep.add("WARN", "manifest", f"ambiguous manifest: {fp.name} contains unsupported records") + rep.observe("provenance", "CONCERN", "Unsupported records prevent an unambiguous selected checksum", scope) + return if not entries: - rep.add("INFO", "manifest", f"{fp.name} present but no readable SHA-256 lines") + rep.add("WARN", "manifest", f"{fp.name} present but no readable SHA-256 lines") + rep.observe("provenance", "NOT CHECKED", "No usable checksums", scope) return if path.name in entries: - if entries[path.name] == sha: + hashes = entries[path.name] + if len(hashes) != 1: + rep.add("WARN", "manifest", f"ambiguous manifest: duplicate entries for {path.name}") + rep.observe("provenance", "CONCERN", "Duplicate selected filename entries", scope) + elif hashes[0] == sha: rep.add("OK", "manifest", f"SHA-256 matches the uploader's own entry for {path.name} in {fp.name}") + rep.observe("provenance", "PASS", "Selected unique filename checksum matches", scope) else: - rep.add("CRIT", "manifest", f"MANIFEST MISMATCH: this file is {sha[:16]}… but {fp.name} lists " - f"{entries[path.name][:16]}… for {path.name} — the file differs from " - f"what the uploader published hashes for") + rep.add("CRIT", "manifest", f"MANIFEST MISMATCH: {path.name} differs from its selected checksum") + rep.observe("provenance", "FAIL", "Selected unique filename checksum differs", scope) + return + names = [name for name, hashes in entries.items() if sha in hashes] + if names: + rep.add("INFO", "manifest", f"renamed since the manifest was written: matching content is listed as {', '.join(sorted(names))}; naming history is not independently verified") + rep.observe("provenance", "CONCERN", "Content matches another name, not the selected filename", scope) return - for name, h in entries.items(): - if h == sha: - rep.add("INFO", "manifest", f"renamed since the manifest was written: this file's SHA-256 appears " - f"in {fp.name} as {name} — content unchanged, name is not") - return rep.add("INFO", "manifest", f"{fp.name} present ({len(entries)} entries) but this file is not listed in it") + rep.observe("provenance", "NOT CHECKED", "Checksum list does not cover this filename", scope, required=False) # ---------------- HF remote check ---------------- def hf_check(repo, local_sha, path, rep, revision=None): + scope = f"HF {repo}; repository consistency only, not uploader trust" try: from huggingface_hub import HfApi - except ImportError: - rep.add("WARN", "remote", "huggingface_hub not installed; cannot do remote hash check") - return - try: - api = HfApi() - if revision: - rep.add("INFO", "remote", f"pinned revision: {revision}") - info = api.model_info(repo, files_metadata=True, revision=revision) - match = None - for s in info.siblings: - # any LFS-tracked file, not just .gguf: the artifact's identity is its hash - if s.lfs and s.lfs.sha256 == local_sha: - match = s - break - if match is None: - # try exact filename match for a clearer diagnostic - byname = [s for s in info.siblings - if s.lfs and Path(s.rfilename).name == path.name] - if byname: - s = byname[0] - rep.add("CRIT", "remote", - f"SHA-256 MISMATCH vs {repo}@{str(info.sha)[:12]}: local {local_sha[:16]}… " - f"vs repo {s.lfs.sha256[:16]}… for {s.rfilename} — file was swapped, " - f"re-quantized upstream, or this copy did not come from this repo") - else: - rep.add("WARN", "remote", f"no file with this SHA-256 or filename in {repo} — " - f"repo contents may have been replaced (force-push)") - return - rep.add("OK", "remote", f"local SHA-256 matches HF LFS for {repo}@{str(info.sha)[:12]} ({match.rfilename})") - # warn if the repo ships its own chat template sidecar - names = {s.rfilename for s in info.siblings} - if "chat_template.jinja" in names: - rep.add("INFO", "remote", f"{repo} ships chat_template.jinja alongside the weights — " - f"loaders may prefer it over any template inside the artifact") + info = HfApi().model_info(repo, files_metadata=True, revision=revision) + scope += " at resolved commit " + str(info.sha) + rep.add("INFO", "remote", f"resolved revision: {info.sha} (requested {revision or 'default branch'})") + matches = [s for s in info.siblings if s.lfs and s.lfs.sha256 == local_sha] + byname = [s for s in info.siblings if s.lfs and Path(s.rfilename).name == path.name] + if len(matches) == 1: + rep.add("OK", "remote", f"local SHA-256 matches HF LFS for {repo}@{info.sha} ({matches[0].rfilename})") + rep.observe("provenance", "PASS", "One repository LFS entry matches the selected file hash", scope) + elif len(matches) > 1 or len(byname) > 1: + rep.add("WARN", "remote", "ambiguous remote reference: multiple matching hashes or basenames") + rep.observe("provenance", "CONCERN", "Remote reference is ambiguous", scope) + elif len(byname) == 1: + rep.add("CRIT", "remote", f"SHA-256 MISMATCH vs {repo}@{info.sha}: {byname[0].rfilename}") + rep.observe("provenance", "FAIL", "Unique basename reference has a different hash", scope) + else: + rep.add("WARN", "remote", f"no file with this SHA-256 or filename in {repo}") + rep.observe("provenance", "NOT CHECKED", "No applicable LFS reference found", scope) except Exception as e: rep.add("WARN", "remote", f"HF fetch failed: {e}") + rep.observe("provenance", "NOT CHECKED", "Remote lookup failed", scope) # ---------------- baseline tensor hash dump / compare ---------------- def dump_tensor_hashes(tensors, path): @@ -631,24 +802,36 @@ def dump_tensor_hashes(tensors, path): vals[t.name] = hv return out, vals -def write_baseline(tensors, path, sha, fmt, rep): +def write_baseline(tensors, path, sha, fmt, rep, baseline_out=None): hashes, values = dump_tensor_hashes(tensors, path) shapes = {t.name: [int(d) for d in t.shape] for t in tensors} - outp = path.with_suffix(path.suffix + ".tensorhashes.json") - outp.write_text(json.dumps({"file_sha256": sha, "format": fmt, - "shapes": shapes, "tensors": hashes, "values": values}, indent=1)) + outp = Path(baseline_out) if baseline_out is not None else path.with_suffix(path.suffix + ".tensorhashes.json") + created = False + try: + with outp.open("x", encoding="utf-8") as stream: + created = True + json.dump({"file_sha256": sha, "format": fmt, "shapes": shapes, + "tensors": hashes, "values": values}, stream, indent=1) + except Exception: + if created: + outp.unlink() + raise rep.add("OK", "baseline", f"per-tensor SHA-256 written to {outp}") # ---------------- main ---------------- -def _audit_gguf(path, rep, sha, size, do_hashes, hf_repo, hf_revision): +def _audit_gguf(path, rep, sha, size, do_hashes, hf_repo, hf_revision, baseline_out=None): rep.add("INFO", "file", "format=gguf") if gguf is None: + rep.observe("structure", "NOT CHECKED", "GGUF dependency unavailable", "GGUF artifact") + rep.observe("templates", "NOT CHECKED", "GGUF could not be inspected", "GGUF embedded template only") rep.add("CRIT", "file", "the gguf python package is not installed (pip install gguf) — " "GGUF files cannot be parsed without it") return try: rdr = gguf.GGUFReader(str(path)) except Exception as e: + rep.observe("structure", "NOT CHECKED", "GGUF parser failed", "GGUF artifact") + rep.observe("templates", "NOT CHECKED", "GGUF could not be inspected", "GGUF embedded template only") rep.add("CRIT", "file", f"GGUF header unreadable: {e}") return md = {} @@ -668,40 +851,61 @@ def _audit_gguf(path, rep, sha, size, do_hashes, hf_repo, hf_revision): md[k] = field.parts[field.data[-1]].tolist() except Exception as e: md[k] = f"" + start = len(rep.items) arch = check_meta(md, path, rep) tpl = md.get("tokenizer.chat_template") if tpl is None and arch == "clip": rep.add("INFO", "template", "No tokenizer.chat_template — normal for vision projector (mmproj) files") + rep.observe("templates", "N/A", "Vision projector has no chat template by design", "GGUF embedded template only") + elif isinstance(tpl, str): + record_template(tpl, rep, "GGUF embedded tokenizer.chat_template", "default") else: scan_template(tpl, rep) + rep.observe("templates", "NOT CHECKED", "Embedded chat template missing or invalid", "GGUF embedded template only") tensors = list(rdr.tensors) check_tensors(tensors, arch, rep) + invalid = any(sev == "CRIT" and sec != "template" for sev, sec, _ in rep.items[start:]) + rep.observe("structure", "FAIL" if invalid else "PASS", "GGUF reader and implemented tensor checks only", "GGUF structural checks, not all format invariants") check_sidecars(path, rep) check_manifest(path, sha, rep) - if do_hashes: - write_baseline(tensors, path, sha, "gguf", rep) + if do_hashes and not invalid: + write_baseline(tensors, path, sha, "gguf", rep, baseline_out) if hf_repo: hf_check(hf_repo, sha, path, rep, revision=hf_revision) -def _audit_st(path, rep, sha, size, do_hashes, hf_repo, hf_revision): +def _audit_st(path, rep, sha, size, do_hashes, hf_repo, hf_revision, baseline_out=None): rep.add("INFO", "file", "format=safetensors") parsed = read_st_header(path, rep) if parsed is None: + rep.observe("structure", "FAIL", "Invalid or truncated header", "safetensors header") + rep.observe("templates", "NOT CHECKED", "Artifact parsing failed before template inspection", "supported safetensors sidecars") return hdr, data_start, fsize = parsed + start = len(rep.items) tensors = st_tensors(hdr, data_start, fsize, rep) + invalid = not rep.structure_valid + incomplete = not rep.structure_complete + warned = rep.structure_concern + state = "FAIL" if invalid else ("NOT CHECKED" if incomplete else ("CONCERN" if warned else "PASS")) + rep.observe("structure", state, "Implemented header and tensor-layout checks" + (" incomplete for unsupported dtype" if incomplete else " completed"), "safetensors file only") + if invalid and incomplete: + rep.observe("structure", "NOT CHECKED", "Unsupported dtype prevents complete size validation", "safetensors tensor sizes") check_tensors(tensors, "?", rep, naming="hf") check_st_config(path, rep) scan_st_templates(path, rep) check_st_index(path, tensors, rep) check_sidecars(path, rep) check_manifest(path, sha, rep) - if do_hashes: - write_baseline(tensors, path, sha, "safetensors", rep) + structure_complete = rep.baseline_eligible and all( + o["status"] in ("PASS", "N/A") for o in rep.observations["structure"]) + if do_hashes and structure_complete: + write_baseline(tensors, path, sha, "safetensors", rep, baseline_out) + elif do_hashes: + rep.add("CRIT", "baseline", "baseline refused: tensor structure was not fully validated") if hf_repo: hf_check(hf_repo, sha, path, rep, revision=hf_revision) -def audit(path, rep, do_hashes=False, hf_repo=None, hf_revision=None): +def audit(path, rep, do_hashes=False, hf_repo=None, hf_revision=None, baseline_out=None): rep.add("INFO", "file", f"{path}") sha, size = file_hashes(path) rep.add("INFO", "file", f"sha256={sha}") @@ -709,180 +913,146 @@ def audit(path, rep, do_hashes=False, hf_repo=None, hf_revision=None): with open(path, "rb") as f: magic = f.read(4) if magic == b"GGUF": - return _audit_gguf(path, rep, sha, size, do_hashes, hf_repo, hf_revision) + return _audit_gguf(path, rep, sha, size, do_hashes, hf_repo, hf_revision, baseline_out) if looks_like_safetensors(path): - return _audit_st(path, rep, sha, size, do_hashes, hf_repo, hf_revision) + return _audit_st(path, rep, sha, size, do_hashes, hf_repo, hf_revision, baseline_out) + rep.observe("structure", "FAIL", "Unrecognized artifact format", "file format") + rep.observe("templates", "NOT CHECKED", "Artifact format not recognized", "template scan") rep.add("CRIT", "file", f"bad magic {magic!r} — not a GGUF or safetensors file") +def match_blobs(A, B): + """Consume tensor identities once using stable hash buckets, byte matches first.""" + free_a, free_b = set(A["tensors"]), set(B["tensors"]) + exact, candidates = [], [] + buckets = defaultdict(deque) + for name in sorted(free_a): + buckets[A["tensors"][name]].append(name) + for name in sorted(free_b): + bucket = buckets[B["tensors"][name]] + if bucket: + source = bucket.popleft() + exact.append((source, name)); free_a.remove(source) + free_b.difference_update(name for _, name in exact) + buckets = defaultdict(deque) + for name in sorted(free_a): + for digest in sorted({A["tensors"][name], A.get("values", {}).get(name)} - {None}): + buckets[digest].append(name) + for name in sorted(free_b): + for digest in sorted({B["tensors"][name], B.get("values", {}).get(name)} - {None}): + bucket = buckets[digest] + while bucket and bucket[0] not in free_a: + bucket.popleft() + if bucket: + source = bucket.popleft() + candidates.append((source, name)); free_a.remove(source) + break + free_b.difference_update(name for _, name in candidates) + return exact, candidates, sorted(free_a), sorted(free_b) + + def blob_identity(A, B): - """Match weight blobs across formats by content, as a multiset. + """Compatibility tuple; legacy hashes cannot establish numeric or shape agreement.""" + exact, candidates, _, unmatched = match_blobs(A, B) + return len(exact), len(candidates), len(B["tensors"]), 0, 0, unmatched - Names differ between formats (model.layers.0.self_attn.q_proj vs blk.0.attn_q), and so - can storage types: ggml writes 2-D dims in the opposite order, and converters upcast - bf16 norms to f32. So we match in two passes — byte-identical first, then by value - (a tensor whose values widened to f32 hash equal to one of A's). - Returns (exact, value_equal, total_in_b, shape_agree_exact, numel_agree_value, unmatched). - """ - exact = {} - for h in A["tensors"].values(): - exact[h] = exact.get(h, 0) + 1 - # A's value hashes, plus its byte hashes (an f32 tensor's value hash IS its byte hash) - pool = dict(exact) - for h in (A.get("values") or {}).values(): - pool[h] = pool.get(h, 0) + 1 - shapes_a = A.get("shapes") or {} - numel_a = {} - for src in (A["tensors"], A.get("values") or {}): - for name, h in src.items(): - n = 1 - for d in shapes_a.get(name, []): - n *= d - numel_a.setdefault(h, set()).add(n) - shapes_b = B.get("shapes") or {} - values_b = B.get("values") or {} - n_exact = n_value = n_shape = n_numel = 0 - unmatched = [] - for name, hb in B["tensors"].items(): - numel_b = 1 - for d in shapes_b.get(name, []): - numel_b *= d - if exact.get(hb, 0) > 0: - exact[hb] -= 1 - pool[hb] -= 1 - n_exact += 1 - if numel_b in numel_a.get(hb, set()): - n_shape += 1 - continue - hv = values_b.get(name, hb) - if pool.get(hv, 0) > 0: - pool[hv] -= 1 - n_value += 1 - if numel_b in numel_a.get(hv, set()): - n_numel += 1 +def read_baseline(path): + base = json_object(path) + digest = lambda h: isinstance(h, str) and re.fullmatch(r"[0-9a-fA-F]{64}", h) is not None + if not isinstance(base.get("tensors"), dict) or not all(digest(v) for v in base["tensors"].values()): + raise ValueError("baseline tensors must map names to SHA-256 digests") + if "file_sha256" in base and not digest(base["file_sha256"]): + raise ValueError("invalid file_sha256") + if "format" in base and not isinstance(base["format"], str): + raise ValueError("baseline format must be text") + for key, validator in (("values", digest), ("shapes", valid_shape)): + if key not in base: continue - unmatched.append(name) - return n_exact, n_value, len(B["tensors"]), n_shape, n_numel, sorted(unmatched) + if not isinstance(base[key], dict) or not set(base[key]) <= set(base["tensors"]) or not all(validator(v) for v in base[key].values()): + raise ValueError(f"invalid baseline {key} mapping") + for key in ("tensors", "values"): + if key in base: + base[key] = {name: value.lower() for name, value in base[key].items()} + return base def diff_baselines(file_a, file_b, rep): - """Compare two .tensorhashes.json baselines of the same base model.""" - A = json.loads(Path(file_a).read_text()) - B = json.loads(Path(file_b).read_text()) + A, B = read_baseline(file_a), read_baseline(file_b) ta, tb = A["tensors"], B["tensors"] - rep.add("INFO", "diff", f"A: {file_a} (file sha {A.get('file_sha256','?')[:16]}…)") - rep.add("INFO", "diff", f"B: {file_b} (file sha {B.get('file_sha256','?')[:16]}…)") - only_a = sorted(set(ta) - set(tb)) - only_b = sorted(set(tb) - set(ta)) - changed = sorted(k for k in set(ta) & set(tb) if ta[k] != tb[k]) - fa, fb = A.get("format", "?"), B.get("format", "?") - if fa != fb: - rep.add("WARN", "diff", f"comparing a {fa} baseline with a {fb} one — names and precision differ " - f"between formats, so a per-name comparison is not meaningful and the " - f"abliteration/re-training classification is skipped. Use the content match " - f"below instead") - rep.add("INFO", "diff", f"name sets: {len(only_a)} only in A, {len(only_b)} only in B, " - f"{len(set(ta) & set(tb))} in common (per-name listing skipped for cross-format)") - matched, value_equal, total, shape_ok, numel_ok, unmatched = blob_identity(A, B) - if total and matched + value_equal == total: - rep.add("OK", "diff", f"content match: all {total} weight blobs in B carry values identical to " - f"blobs in A ({matched} byte-identical, {value_equal} identical after a " - f"storage-type change such as a bf16 norm upcast to f32) — consistent with " - f"a lossless conversion of the same weights: naming and container changed, " - f"the numbers did not") - else: - rep.add("INFO", "diff", f"content match: {matched} of {total} weight blobs in B are byte-identical " - f"to blobs in A, {value_equal} more carry identical values in a different " - f"storage type, and {len(unmatched)} differ. Quantized formats transform " - f"values by design, so a low count means 'not comparable this way', not " - f"'tampered' — only a lossless conversion is expected to match") - for name in unmatched[:8]: - rep.add("INFO", "diff", f"not matched: {name}") - if len(unmatched) > 8: - rep.add("INFO", "diff", f"… and {len(unmatched) - 8} more unmatched blobs") - if unmatched: - rep.add("INFO", "diff", "an unmatched blob can be a table the converter computes rather than a " - "weight (rope frequency tables), a block whose values quantization " - "transformed by design, or a genuinely different weight — this tool " - "cannot tell those apart, so it names them and stops there") - if matched: - rep.add("INFO", "diff", f"shape agreement: {shape_ok} of the {matched} byte-identical blobs also " - f"agree on shape (dimension order differs between formats, so this compares " - f"dims as a set)") - if value_equal: - rep.add("INFO", "diff", f"value agreement: {numel_ok} of the {value_equal} value-identical blobs " - f"hold the same number of elements — the difference is the storage type, " - f"not the content") - return - same = len(set(ta) & set(tb)) - len(changed) - for k in only_a: - rep.add("WARN", "diff", f"tensor only in A (removed in B): {k}") - for k in only_b: - rep.add("WARN", "diff", f"tensor only in B (added/renamed): {k}") - for k in changed: - rep.add("CRIT", "diff", f"TENSOR CONTENT CHANGED: {k} ({ta[k][:12]}… -> {tb[k][:12]}…)") - rep.add("INFO", "diff", f"{same} tensors identical, {len(changed)} changed, " - f"{len(only_a)} removed, {len(only_b)} added") - total = len(changed) + len(only_a) + len(only_b) - if total == 0: - rep.add("OK", "diff", "baselines are byte-identical tensor-wise") + rep.add("INFO", "diff", f"A: {file_a}; B: {file_b}") + scope = "Recorded tensor bytes and shapes only; dtype, byte order, semantic roles and trust NOT VERIFIED" + fa, fb = A.get("format"), B.get("format") + if fa not in ("gguf", "safetensors") or fb not in ("gguf", "safetensors") or fa != fb: + rep.add("WARN", "diff", "comparing across formats or legacy unknown formats: normalized hash matches are candidates only; numeric equivalence and lossless conversion are not established") + exact, candidates, only_a, only_b = match_blobs(A, B) + rep.add("INFO", "diff", f"content match: {len(exact)} of {len(tb)} B blobs are byte-identical; {len(candidates)} normalized-hash candidates (INCONCLUSIVE); {len(only_a)} unmatched in A, {len(only_b)} unmatched in B") + rep.add("INFO", "diff", "Nonmatches are not comparable this way; quantization can change values by design. No model identity, numeric equivalence, role mapping or tampering conclusion follows.") + for side, names in (("A", only_a), ("B", only_b)): + for name in names[:8]: + rep.add("INFO", "diff", f"not matched in {side}: {name}") + rep.observe("baseline", "CONCERN", "Cross-format or legacy comparison is inconclusive, including empty or subset matches", scope) return - # classify: abliteration-style (few tensors, at common edit sites) vs broad change - edit_sites = sum(1 for k in changed if any(s in k for s in ABLIT_SITES)) - if 0 < len(changed) <= 8 and not only_a and not only_b and edit_sites >= max(1, len(changed) // 2): - rep.add("INFO", "diff", f"pattern matches targeted weight edits (abliteration-class: " - f"{edit_sites}/{len(changed)} at ffn_down/ffn_out/attn_o sites)") - elif total > 8: - rep.add("CRIT", "diff", f"BROAD tensor differences ({total}: {len(changed)} changed, " - f"{len(only_a)} removed, {len(only_b)} added) — consistent with " - f"re-quantization, post-quantization training, or large-scale " - f"tampering. Without a trusted chain of custody, treat B as a " - f"different model, not a copy.") + common = set(ta) & set(tb) + only_a, only_b = sorted(set(ta) - set(tb)), sorted(set(tb) - set(ta)) + changed = sorted(k for k in common if ta[k] != tb[k]) + sa, sb = A.get("shapes", {}), B.get("shapes", {}) + missing = sorted(k for k in common if k not in sa or k not in sb) + reshaped = sorted(k for k in common if k in sa and k in sb and sa[k] != sb[k]) + for name in only_a: + rep.add("WARN", "diff", f"tensor only in A (removed in B): {name}") + for name in only_b: + rep.add("WARN", "diff", f"tensor only in B (added/renamed): {name}") + for name in changed: + rep.add("CRIT", "diff", f"TENSOR CONTENT CHANGED: {name} — cause not determined") + for name in reshaped: + rep.add("CRIT", "diff", f"TENSOR SHAPE CHANGED: {name} — recorded shape differs") + if missing or not ta or not tb: + rep.add("WARN", "diff", "baseline comparison incomplete: missing shape metadata or empty tensor set") + differences = bool(changed or reshaped or only_a or only_b) + status = "FAIL" if differences else ("CONCERN" if missing or not ta else "PASS") + rep.observe("baseline", status, "Recorded bytes/shapes differ" if differences else ("Legacy or empty baseline is inconclusive" if status == "CONCERN" else "Both tensor sets agree on recorded bytes and shapes"), scope) + rep.add("INFO", "diff", scope) + if status == "PASS": + rep.add("OK", "diff", "baselines agree on recorded tensor bytes and shapes; this does not prove safety or model equivalence") def cmd_diff(args): + return run_cli_report(lambda rep: diff_baselines(args.baseline_a, args.baseline_b, rep), args.json, "baseline") + + +def run_cli_report(operation, as_json, domain): rep = Report() - diff_baselines(args.baseline_a, args.baseline_b, rep) - rep.print(as_json=args.json) - crits = sum(1 for s, *_ in rep.items if s == "CRIT") - warns = sum(1 for s, *_ in rep.items if s == "WARN") - print(f"\n== diff: {crits} critical, {warns} warnings ==") - sys.exit(2 if crits else (1 if warns else 0)) + try: + operation(rep) + except (OSError, ValueError, TypeError, OverflowError) as e: + rep.add("CRIT", "audit", f"input error: {e}") + rep.observe(domain, "NOT CHECKED", "Requested operation could not complete", "input/output error") + add_rubric(rep) + rep.print(as_json=as_json) + return rep.exit_code() def main(): ap = argparse.ArgumentParser(description="audit a GGUF or safetensors file for supply-chain tampering") ap.add_argument("--version", action="version", version=f"model-audit {VERSION}") ap.add_argument("model", type=Path, nargs="?", help="model file to audit (GGUF or safetensors; format auto-detected)") - ap.add_argument("--tensor-hashes", action="store_true", help="emit per-tensor SHA-256 baseline JSON") + ap.add_argument("--tensor-hashes", action="store_true", help="emit a new per-tensor SHA-256 baseline JSON; never overwrite") + ap.add_argument("--baseline-out", type=Path, help="new baseline destination (requires --tensor-hashes)") ap.add_argument("--hf", metavar="USER/REPO", help="cross-check local sha256 against HF LFS hash") - ap.add_argument("--revision", metavar="COMMIT_SHA", default=None, - help="pin the HF commit to compare against (never trust 'main' long-term)") + ap.add_argument("--revision", metavar="REVISION", default=None, help="HF revision to resolve; output records the full resolved commit") ap.add_argument("--json", action="store_true") - ap.add_argument("--diff", metavar=("A", "B"), nargs=2, default=None, - help="compare two .tensorhashes.json baselines instead of auditing") - + ap.add_argument("--diff", metavar=("A", "B"), nargs=2, default=None, help="compare two .tensorhashes.json baselines") a = ap.parse_args() + if a.baseline_out is not None and (not a.tensor_hashes or a.diff): + ap.error("--baseline-out requires --tensor-hashes and an audit, not --diff") if a.diff: - rep = Report() - diff_baselines(a.diff[0], a.diff[1], rep) - rep.print(as_json=a.json) - crits = sum(1 for s, *_ in rep.items if s == "CRIT") - warns = sum(1 for s, *_ in rep.items if s == "WARN") - print(f"\n== diff: {crits} critical, {warns} warnings ==") - sys.exit(2 if crits else (1 if warns else 0)) + return run_cli_report(lambda rep: diff_baselines(a.diff[0], a.diff[1], rep), a.json, "baseline") if not a.model: ap.print_help() - sys.exit(0) - if not a.model.exists(): - sys.exit(f"not found: {a.model}") - rep = Report() - audit(a.model, rep, do_hashes=a.tensor_hashes, hf_repo=a.hf, hf_revision=a.revision) - rep.print(as_json=a.json) - crits = sum(1 for s, *_ in rep.items if s == "CRIT") - warns = sum(1 for s, *_ in rep.items if s == "WARN") - print(f"\n== {a.model.name}: {crits} critical, {warns} warnings ==") - sys.exit(2 if crits else (1 if warns else 0)) + return 0 + return run_cli_report(lambda rep: audit(a.model, rep, do_hashes=a.tensor_hashes, + hf_repo=a.hf, hf_revision=a.revision, baseline_out=a.baseline_out), a.json, "structure") + if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/report.py b/report.py index bf42f60..efa93c6 100644 --- a/report.py +++ b/report.py @@ -7,14 +7,36 @@ Usage: python3 report.py [file2 ...] [-o reports/] """ -import argparse, json, sys, html +import argparse, json, sys, html, tempfile +from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) -from model_audit import Report, audit as _audit +from model_audit import Report, audit as _audit, evidence_rubric # plain-language translations for every finding we emit PLAIN = { + "audit failed": "The audit stopped because an operation failed. Some requested checks may not " + "have run, so this report cannot supply a complete result for the file.", + "input error": "An input could not be read or validated, or an output could not be written. " + "Resolve the reported problem and rerun the requested check.", + "template invalid": "A template source could not be read or did not have the expected text " + "structure. Its instructions were not fully inspected; other readable sources do not " + "make this missing coverage complete.", + "template coverage incomplete": "No readable template was available for the requested scan. " + "The application may supply one at runtime, and its instructions remain outside this audit.", + "template sources for": "Files provide different text for the same named template. The loader " + "chooses which source to use at runtime, so review both and resolve unintended differences.", + "tensor entry invalid dtype or shape": "A weight entry uses an invalid type or shape description. " + "The file's header does not describe that weight in the required format.", + "baseline refused": "A fingerprint baseline could not be saved because the required structural " + "checks did not validate the tensor data. Resolve those findings before creating a baseline.", + "baseline comparison incomplete": "A baseline is missing comparison information or contains no " + "weights. Matching the information that remains cannot establish a complete comparison.", + "TENSOR CONTENT CHANGED": "The recorded bytes for a weight block differ between the baselines. " + "This identifies a difference, not its cause or whether either version is safe.", + "TENSOR SHAPE CHANGED": "The recorded dimensions of a weight block differ between the baselines. " + "Review whether this change was intended; the comparison does not determine its cause.", "No tokenizer.chat_template": ( "This file has NO chat template inside it. The template is what tells the model " "how to hold a conversation — without one baked in, whatever app you load it with " @@ -33,8 +55,8 @@ "exec/OS wording": "The template mentions running programs or OS commands. Like the above: often legitimate " "tool-calling vocabulary, but the surrounding text should make sense.", "environment/config access": "The template reaches for environment or configuration data.", - "suspicious namespace() usage": "The template abuses a template feature in a way that references system " - "objects — not a pattern honest templates use.", + "suspicious namespace() usage": "The template uses a feature to reference system " + "objects. This deserves review for unintended access or code execution.", "zero-width": "Invisible characters are hidden in the template. These can smuggle instructions past a human " "reviewer or alter model behavior without any visible change.", "confusable": "The template contains look-alike characters (e.g. Cyrillic 'а' inside English text). " @@ -46,20 +68,21 @@ "At best sloppy labeling; at worst misrepresentation.", "no general.architecture": "The file doesn't even say what kind of model it is — nonstandard or stripped build.", "executable/script shipped alongside": "A program/script ships in the same folder as the model. The model " - "weights are safe data — anything executable next to them is a separate supply-chain risk. Don't run it.", - "SHA-256 MISMATCH": "The file's fingerprint does NOT match what the Hugging Face repo currently publishes — " - "it was swapped, re-uploaded, or this copy didn't come from the repo.", + "weights do not establish what this program does. Executables next to them are a separate " + "supply-chain risk; review them before running anything.", + "SHA-256 MISMATCH": "The file's fingerprint does not match the selected Hugging Face reference. " + "That establishes a difference, not why the difference exists or whether either copy is safe.", "MANIFEST MISMATCH": "The uploader published a list of hashes with this model, and this file does not match " "the entry under its own name. Either the file was changed after it was hashed, or it was replaced. " "Treat that file as untrustworthy until the uploader explains it.", - "renamed since the manifest was written": "The uploader's own hash list still has this file under its old " - "name, and the content matches. That is a rename without a content change — harmless in itself, and it " - "shows the hash list is still accurate.", + "renamed since the manifest was written": "These bytes match a checksum listed under another name. " + "That does not establish rename history or validate the whole checksum list.", "present but no readable SHA-256 lines": "A checksum file ships with this model but this tool could not " "read any hashes out of it.", "but this file is not listed in it": "A checksum file ships with this model but does not cover this file, " "so it can say nothing about it either way.", - "local SHA-256 matches HF LFS": "The file's fingerprint matches what Hugging Face publishes right now.", + "local SHA-256 matches HF LFS": "The file's fingerprint matches the Hugging Face reference that was checked. " + "Matching bytes do not establish that the uploader or weights are trustworthy.", "per-tensor SHA-256 written": "A per-weight-block fingerprint baseline was saved for future comparison.", "config file present": "A config file sits next to the model; its chat settings could differ from what's " "inside the GGUF. If they disagree, trust the GGUF.", @@ -81,8 +104,10 @@ "the file's internal map is broken.", "tensor entry has nonsensical offsets": "A weight entry declares an impossible data range (it ends " "before it starts, or starts before zero).", - "tensor size:": "A weight block's declared size does not match what its type and shape require. The " - "header contradicts itself, which is what a hand-edited or grafted file looks like.", + "is not a known type": "This tool does not recognize the declared data type, so its byte size " + "could not be verified. This is an incomplete check, not a demonstrated contradiction.", + "tensor size:": "This finding concerns the declared type, shape, or byte range of a weight block. " + "Read the details to distinguish a mismatch from an unperformed check; the cause is not established.", "tensor layout:": "Two weight blocks claim the same bytes — the file's layout is not a valid " "safetensors layout.", "undeclared gap": "There is a stretch of data inside the file that no header entry claims. It could be " @@ -102,9 +127,9 @@ "the claim comes from the uploader — nothing here verifies how they were made.", "index mismatch": "The index file that describes a multi-shard model disagrees with this shard: it " "lists weights that are not here, or misses weights that are. Shards and index must match exactly.", - "template sidecar": "This template file sits next to the weights and is what actually shapes the " - "conversation. A slow, careful review of this one small file covers the same attack surface as " - "reviewing the whole model.", + "template sidecar": "This template file sits next to the weights and may shape the " + "conversation when a loader chooses it. Reviewing it covers the instructions in that file, " + "not the model weights or templates supplied by another application.", "no chat template sidecar": "This format never carries the chat template inside the file, and no " "template sidecar is present. Whatever you load the model with supplies the template, so its " "behavior is not pinned by anything you audited here.", @@ -118,9 +143,9 @@ "corrupt, truncated, or not really a GGUF.", "bad magic": "The file's first bytes match no known model format — it is not a GGUF or safetensors " "file, whatever its name says.", - "comparing a": "These two fingerprints were made from different formats (for example a GGUF against a " - "safetensors file). Names and precision differ between formats, so the differences listed below are " - "expected and the verdict is not meaningful.", + "comparing a": "The baselines use different formats or lack format information. Names, storage " + "types, and numerical representations may differ. Matching normalized fingerprints are only " + "candidates for further review; they do not establish numeric equivalence or a lossless conversion.", } SEV_COLOR = {"CRIT": "#c0392b", "WARN": "#d68910", "INFO": "#5d6d7e", "OK": "#1e8449"} @@ -132,36 +157,71 @@ "st-header": "File header", "st-tensors": "Weight structure", "index": "Shard index", "manifest": "Uploader checksum", "sidecar": "Sidecar files", "provenance": "Provenance", "remote": "Remote check", "baseline": "Fingerprint baseline", "diff": "Comparison", - "audit": "Audit", + "audit": "Audit", "rubric": "Evidence coverage", } def plain_for(sev, sec, msg): for k, v in PLAIN.items(): if msg.startswith(k) or k in msg[:40] or k in msg: return v + if sev in ("CRIT", "WARN"): + return ("This check found a problem or could not finish. Read the detail above and the " + "evidence coverage below to see what was established and what remains unknown.") return "" + def verdict(crits, warns): if crits: - return ("DO NOT RUN THIS FILE", SEV_COLOR["CRIT"], - "Critical findings mean this file is unsafe or untrustworthy as-is.", - "What to do: delete or quarantine this file. If you want this model, get it from " - "a source that documents its quantization (or quantize it yourself), then re-audit.") + return ("CRITICAL FINDINGS: REVIEW BEFORE LOADING", SEV_COLOR["CRIT"], + "A check found a critical problem or the audit could not complete. " + "The details establish the issue, not the intent behind it.", + "What to do: review the critical findings before loading this file. Resolve " + "unreadable files, structural problems, or suspicious instructions and re-audit.") if warns: - return ("USE WITH AWARENESS", SEV_COLOR["WARN"], - "Warnings mean something needs your attention, but the file isn't clearly hostile. Read the details below.", - "What to do: read each warning's explanation below — some are false positives from " - "tool-calling models. If the model matters to you, run python3 model_audit.py " - "<file> --tensor-hashes to save a per-weight fingerprint baseline.") - return ("NO RED FLAGS FOUND", SEV_COLOR["OK"], - "Nothing hostile was detected. Note: this audit can't prove weights are 'good' — " - "only that nothing obviously bad was found in metadata and the chat template. " - "Per-weight fingerprints (tensor hashes) are needed to compare against a trusted build.", - "What to do: if this model matters to you, save a fingerprint baseline now: " - "python3 model_audit.py <file> --tensor-hashes. Keep the JSON next " - "to the model — it lets you prove later that the weights haven't changed.") + return ("WARNINGS: REVIEW THE DETAILS", SEV_COLOR["WARN"], + "Warnings identify differences, risks, or incomplete checks that need attention.", + "What to do: read each warning and its scope below. Some template patterns can " + "be legitimate tool-calling vocabulary; a finding alone does not prove hostile intent.") + return ("NO RED FLAGS IN COMPLETED CHECKS", SEV_COLOR["OK"], + "No warning or critical findings were reported in the checks that completed. " + "See the evidence coverage below for checks that were unavailable or not requested. " + "This result does not prove that the model weights are benign.", + "What to do: compare fingerprints against a reference you trust when available. " + "Matching fingerprints establish consistency with that reference, not safety.") + + +RUBRIC_LABELS = {"structure": "Structure", "templates": "Templates", + "provenance": "Provenance", "baseline": "Baseline"} + + +def render_rubric(rubric): + """Render the shared evidence contract without inferring checks from findings.""" + rows = [] + for key, label in RUBRIC_LABELS.items(): + evidence = rubric["domains"][key] + rows.append(f""" + {label} + {html.escape(evidence['status'])} + {html.escape(evidence['reason'])} + {html.escape(evidence['scope'])} + """) + coverage = rubric["coverage"] + return f""" +

Evidence coverage

+

Requested coverage: {html.escape(coverage['status'])}
+ {html.escape(coverage['reason'])}
+ Scope: {html.escape(coverage['scope'])}

+ + + {''.join(rows)} +
DomainStatusReasonScope
""" -def render_file_block(name, items): + +def render_file_block(name, items, rubric=None): + if rubric is None: + rep = Report() + rep.items = list(items) + rubric = evidence_rubric(rep) crits = sum(1 for s, *_ in items if s == "CRIT") warns = sum(1 for s, *_ in items if s == "WARN") v, color, vt, action = verdict(crits, warns) @@ -195,26 +255,15 @@ def render_file_block(name, items): if sec == "file" and m.startswith("format=")), "") prov = [m for m in id_rows if "architecture=" not in m] tpl_findings = [(s, m) for s, sec, m in items if sec == "template"] - tpl_crits = sum(1 for s, _ in tpl_findings if s == "CRIT") - tpl_warns = sum(1 for s, _ in tpl_findings if s == "WARN") tpl_sources = [m.split("template sidecar: ", 1)[1].split(" (")[0] for s, m in tpl_findings if s == "INFO" and m.startswith("template sidecar: ")] - if tpl_crits: - tpl_verdict, tpl_color = "❌ HOSTILE PATTERNS FOUND", SEV_COLOR["CRIT"] - elif any("no chat template sidecar" in m.lower() for s, m in tpl_findings): - tpl_verdict, tpl_color = "➖ NO SIDECAR TEMPLATE FOUND", SEV_COLOR["WARN"] - elif any("no chat template" in m.lower() or "no tokenizer.chat_template" in m for s, m in tpl_findings): - tpl_verdict, tpl_color = "➖ NONE BAKED INTO FILE", SEV_COLOR["WARN"] - elif tpl_warns: - tpl_verdict, tpl_color = "⚠️ SUSPICIOUS PATTERNS — READ BELOW", SEV_COLOR["WARN"] - else: - tpl_verdict, tpl_color = "✅ CLEAN", SEV_COLOR["OK"] + tpl_verdict = rubric["domains"]["templates"]["status"] idcard = f""" {f'' if fmt else ''} {''.join(f'' for m in prov)} - + {f'' if tpl_sources else ''}
Format{html.escape(fmt)}
Model type{html.escape(arch)}{(' · quant ' + html.escape(quant)) if quant else ''}
{html.escape(m.split("=")[0].strip().replace("general.",""))}{html.escape(m.split("=",1)[1].strip())}
Chat template{tpl_verdict}
Chat template{html.escape(tpl_verdict)}
Template source{html.escape(", ".join(tpl_sources))}
""" return f""" @@ -230,6 +279,7 @@ def render_file_block(name, items): SHA-256 fingerprint{html.escape(meta.get('sha256','?'))} Size{html.escape(meta.get('size','?'))} bytes + {render_rubric(rubric)} {''.join(rows)}
""" @@ -262,35 +312,49 @@ def render_file_block(name, items): .sumnum { display: block; font-size: 1.8em; font-weight: 800; } .sumitem { color: #555; font-size: 0.85em; } .idcard td { font-size: 0.95em; } +.rubric { width: 100%; border-collapse: collapse; font-size: 0.9em; } +.rubric td, .rubric th { text-align: left; vertical-align: top; padding: 8px; + border: 1px solid #ddd; overflow-wrap: anywhere; } +.rubric thead { background: #f4f6f7; } +.coverage { font-size: 0.92em; line-height: 1.5; } +.coverage-scope { color: #555; } """ VERSION = "0.4.0" -def generate(models, outdir): - outdir.mkdir(parents=True, exist_ok=True) +def _generate(models, outdir): + """Return the completed HTML path and worst audit severity (0, 1, or 2).""" + models = list(models) + outdir = Path(outdir) blocks, all_json = [], [] tally = {"CRIT": 0, "WARN": 0, "OK": 0} + status = 0 for g in models: rep = Report() try: _audit(Path(g), rep) except Exception as e: rep.add("CRIT", "audit", f"audit failed: {e}") + rep.observe("structure", "NOT CHECKED", + "Audit failed; requested checks may be incomplete", str(g)) name = Path(g).name - crits_n = sum(1 for s, *_ in rep.items if s == "CRIT") - warns_n = sum(1 for s, *_ in rep.items if s == "WARN") - tally["CRIT" if crits_n else ("WARN" if warns_n else "OK")] += 1 - blocks.append(render_file_block(name, rep.items)) + file_status = rep.exit_code() + status = max(status, file_status) + tally[{0: "OK", 1: "WARN", 2: "CRIT"}[file_status]] += 1 + rubric = evidence_rubric(rep) + blocks.append(render_file_block(name, rep.items, rubric)) all_json.append({"file": str(g), "findings": [{"severity": s, "section": sec, "message": m} - for s, sec, m in rep.items]}) - stamp = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M") + for s, sec, m in rep.items], + "rubric": rubric}) + now = datetime.now(timezone.utc) + stamp = now.strftime("%Y-%m-%d %H:%M:%S UTC") n = len(models) summary = f"""
-
{tally['CRIT']}do not run
-
{tally['WARN']}use with awareness
-
{tally['OK']}no red flags
+
{tally['CRIT']}critical findings
+
{tally['WARN']}warnings
+
{tally['OK']}no warning or critical findings
""" doc = f""" Model Audit Report @@ -298,21 +362,62 @@ def generate(models, outdir):

{n} file{'s' if n != 1 else ''} audited · generated {stamp} by model-audit v{VERSION}

{summary if n > 1 else ''} {''.join(blocks)} -

What this report can and cannot tell you: these checks read the file's -metadata, chat template, and structure. They prove what the file claims to be and whether -anything hostile hides in the instructions it carries. They cannot prove the mathematical weights -themselves are free of subtle tampering — for that, generate per-tensor fingerprints -(--tensor-hashes) and compare against a build you trust.

+

What this report can and cannot tell you: these checks inspect available +metadata, template text, and file structure within the scope shown above. Pattern matches are +review signals; their presence does not prove hostile intent, and their absence does not exclude +unknown attacks. Metadata claims are self-reported. Hash matches establish consistency with a +particular reference, not trust in its author or safety of the mathematical weights. Baseline +comparisons must use an appropriate trusted reference and cannot establish that weights are benign.

""" - out = outdir / f"audit-report-{stamp.replace(' ', '_').replace(':', '')}.html" - out.write_text(doc) - (outdir / (out.stem + ".json")).write_text(json.dumps(all_json, indent=2)) - return out + # Serialize before creating outputs. Each invocation owns an exclusively created + # directory, so frozen clocks and concurrent runs cannot overwrite older evidence. + payload = json.dumps(all_json, indent=2) + outdir.mkdir(parents=True, exist_ok=True) + run_dir = Path(tempfile.mkdtemp(prefix=now.strftime("audit-report-%Y%m%dT%H%M%SZ-"), + dir=outdir)) + out = run_dir / "audit-report.html" + created = [] + try: + for path, content in ((out, doc), (out.with_suffix(".json"), payload)): + with path.open("x", encoding="utf-8") as stream: + created.append(path) + stream.write(content) + except BaseException: + # Do not delete a pre-existing file if an exclusive open failed. Cleanup is + # confined to files this invocation actually created and its empty run dir. + for path in created: + try: + path.unlink() + except OSError: + pass + try: + run_dir.rmdir() + except OSError: + pass + raise + return out, status -if __name__ == "__main__": + +def generate(models, outdir): + """Generate an exclusive HTML/JSON pair, preserving the public Path return type.""" + return _generate(models, outdir)[0] + + +def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--version", action="version", version=f"model-audit report {VERSION}") ap.add_argument("models", nargs="+") ap.add_argument("-o", "--outdir", type=Path, default=Path(__file__).parent / "reports") - a = ap.parse_args() - print(generate([Path(g) for g in a.models], a.outdir)) + a = ap.parse_args(argv) + try: + out, status = _generate([Path(g) for g in a.models], a.outdir) + except Exception as e: + print(f"Report generation failed: {e}", file=sys.stderr) + return 2 + # Only announce a report once both files have been closed successfully. + print(out) + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4ade9e1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,304 @@ +"""Regression contract: conservative evidence, strict inputs and preserved outputs.""" +import hashlib +import json +import struct +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +import model_audit as m +from test_safetensors import build, BENIGN_TEMPLATE + +H = lambda b: hashlib.sha256(b).hexdigest() + + +class Contracts(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.d = Path(self.tmp.name) + self.model = self.d / 'model.safetensors' + self.model.write_bytes(build([('weight', 'F32', [1], b'\0' * 4)])) + self.config = self.d / 'tokenizer_config.json' + self.config.write_text(json.dumps({'chat_template': BENIGN_TEMPLATE})) + + def tearDown(self): + self.tmp.cleanup() + + def audit(self, **kw): + rep = m.Report() + m.audit(self.model, rep, **kw) + return rep + + def cli(self, *args, script='model_audit.py'): + return subprocess.run([sys.executable, str(ROOT / script), *map(str, args)], capture_output=True, text=True) + + def raw_header(self, obj, data=b''): + raw = obj.encode() + self.model.write_bytes(struct.pack('{label}', rendered) + self.assertIn(f"{key} reason", rendered) + self.assertIn(f"{key} scope", rendered) + self.assertEqual(rendered.count("PASS"), 5) # Four rows plus template card. + self.assertIn("Requested coverage: COMPLETE", rendered) + self.assertIn("StatusReasonScope", rendered) + + def test_required_unchecked_is_incomplete_and_not_clean(self): + rep = Report() + rep.observe("templates", "NOT CHECKED", "Template could not be read", "templates/*.jinja") + rendered = render_file_block("model", [], evidence_rubric(rep)) + self.assertIn("Requested coverage: INCOMPLETE", rendered) + self.assertIn("NOT CHECKED", rendered) + self.assertNotIn("✅ CLEAN", rendered) + self.assertNotIn("HOSTILE PATTERNS FOUND", rendered) + + def test_legacy_findings_do_not_claim_completed_template_scan(self): + rendered = render_file_block("model", st_items()) + self.assertIn("Chat templateNOT CHECKED", rendered) + self.assertNotIn("✅ CLEAN", rendered) + + def test_rubric_and_evidence_are_html_escaped(self): + marker = '&' + rep = Report() + for key in report.RUBRIC_LABELS: + rep.observe(key, "PASS", marker, marker) + rubric = evidence_rubric(rep) + rubric["domains"]["templates"]["status"] = marker + rubric["coverage"] = dict(status=marker, reason=marker, scope=marker) + rendered = render_file_block(marker, [("WARN", "template", marker)], rubric) + self.assertNotIn(marker, rendered) + self.assertNotIn("