diff --git a/docs/check-set.md b/docs/check-set.md index 8a0bf2f..5aea0ed 100644 --- a/docs/check-set.md +++ b/docs/check-set.md @@ -46,6 +46,7 @@ own suite, suite version, and probe-set digest ([tier2.md](tier2.md)). | Version | Rule fingerprint | Date | Changes | |---|---|---|---| +| `tier1/0.13` | `bdd0f0d40534d12b31778da958a1d4ce4bab8dea1fcb8d3bf7327cfae73eaa9c` | 2026-10-19 | Chat template, in a model directory: each GGUF file's own chat templates are graded. Before, a directory's GGUF files were named unparsed for their metadata and their templates were not read, so the row covered only `tokenizer_config.json`, `chat_template.json`, and `.jinja` files, and a GGUF-only repository had no row. Files that carry the same templates and special tokens are graded once. When they do not all carry the same templates, each version is also compared with the others, as with a reviewed reference (`Options.Siblings`). A version that renders like another on every standard probe but adds a content-conditional branch is a LEAD (`quant-template-mismatch`, high). This is the near-miss rule of `tier1/0.8`, with no reference input. Any other difference is listed in the notes, not judged. The directory scan's engine code is outside the fingerprint, and the comparison it uses (`render.go`) is inside it. On 47 real GGUF repositories (712 GGUF files carrying a template, 49 template versions), all 47 PASS, two list a second version, and no `quant-template-mismatch` was found; see [false-positive-baseline.md](false-positive-baseline.md). | | `tier1/0.12` | `00b784144756ac03404c549c0bc2e5f00013f9994bdcdd5bc64702a70aa64ea6` | 2026-10-19 | Remote code reads two config fields that make a loader run code without `trust_remote_code`, at any depth of a `config.json`. An attention field (`_attn_implementation_internal`, or `_attn_implementation`, whose setter writes it) naming a Hub kernel repository outside `kernels-community` FAILs as `config-kernel-injection`: transformers 4.56.0 to 5.2.x download and run that kernel in a plain `from_pretrained` once the `kernels` package is installed (CVE-2026-4372), and `save_pretrained` never writes either field. One naming a `kernels-community` kernel, which 5.3.0 and later still load from the field, is a LEAD (`config-hub-kernel`). A truthy `trust_remote_code` is a LEAD (`config-trust-remote-code`): LightGlue's config passed it to the nested `AutoConfig.from_pretrained` for its keypoint detector before transformers 5.5.0, importing another repository's code (CVE-2026-5241), and other loaders ignore it. The row FAILs for the first time. On 403 real repositories, no result changed; see [false-positive-baseline.md](false-positive-baseline.md). | | `tier1/0.11` | `1fc34539a816d9d2bfdad5005f5991fd08e10eacf4a6960bd6e4c31f0a9dd26f` | 2026-10-15 | No rule change: the Chat template check gains `Triggers`, which returns the literals the content conditions test in each template it grades LEAD, each with the prompts the template renders with it (armed, with the branch firing, and counterfactual, the same conversation without the branch's text), for Tier 2's trigger confirmation (#284, [tier2.md](tier2.md#trigger-confirmation)). It reads the same analysis and renderer the row does and changes no row's status, notes, or findings. | | `tier1/0.11` | `5a5d7715b241a0f1002ed4e94d442cc5dc79a748d87d03194b0483f2b8b390c7` | 2026-10-15 | Tokenizer config, on a GGUF: a special-token id of -1 written as a 32-bit integer (`0xFFFFFFFF`, signed or not) is the "no token" sentinel, not an id outside the vocabulary, so it no longer FAILs as `special-token-out-of-range`, as `tier1/0.7` did for a negative id in a directory's `config.json`. llama.cpp skips it with a warning, as it skips any id outside the vocabulary, and it names no token. Found on `ggml-org/tiny-llamas` `stories260K.gguf` (padding and separator ids), whose Tokenizer row FAILed on v0.1.0-rc.5. Any other id at or past the vocabulary's size still FAILs. The row's FAIL note no longer says llama.cpp "would misread or reject" every inconsistency: it says llama.cpp refuses a type or score table shorter than the vocabulary, skips a special-token id outside it with a warning, and reads a type it does not define as undefined. The change only removes a FAIL; the benchmark's outcomes are unchanged. See [false-positive-baseline.md](false-positive-baseline.md). | diff --git a/docs/detection-benchmark.md b/docs/detection-benchmark.md index cd0c707..bd92c87 100644 --- a/docs/detection-benchmark.md +++ b/docs/detection-benchmark.md @@ -27,7 +27,7 @@ generated corpus, on 2026-10-04; CI does not re-run them. scanner here was given any of them. With Socair's defaults (none of the four), the remapped tokenizer and the near-miss template pass, the known-bad file is matched against no list (with none supplied, that check does not - run), and the edited weights have no row that compares them, so Socair detects 65 of 75. Scores with the cases that need an input left out + run), and the edited weights have no row that compares them, so Socair detects 66 of 76. Scores with the cases that need an input left out follow the comparison table. - **The attacks are defanged.** Each case keeps the attack's structure (the gadget, the opcodes, the archive trick, the template construct) and swaps its @@ -44,11 +44,11 @@ generated corpus, on 2026-10-04; CI does not re-run them. models are measured separately, on real corpora, in [false-positive-baseline.md](false-positive-baseline.md). -## Socair (check set tier1/0.12, 2026-10-19) +## Socair (check set tier1/0.13, 2026-10-19) | Check row | Cases | Detected (FAIL or LEAD) | Withheld as a gap (NOT_TESTED) | Missed (PASS) | |---|---|---|---|---| -| Chat template | 11 | 10 | 0 | 1 | +| Chat template | 12 | 11 | 0 | 1 | | File inventory and payloads | 10 | 9 | 0 | 1 | | Format and structure | 14 | 11 | 3 | 0 | | Hash, provenance, lineage | 1 | 1 | 0 | 0 | @@ -57,12 +57,12 @@ generated corpus, on 2026-10-04; CI does not re-run them. | Remote code | 11 | 10 | 0 | 1 | | Tensor reference | 1 | 1 | 0 | 0 | | Tokenizer config | 3 | 3 | 0 | 0 | -| **All** | **75** | **69** | **3** | **3** | +| **All** | **76** | **70** | **3** | **3** | -Benign controls flagged (FAIL or LEAD on any row): 0 of 18. +Benign controls flagged (FAIL or LEAD on any row): 0 of 19. Four of these detections depend on a reference input (see "Read this first"); -with Socair's defaults it detects 65 of 75. +with Socair's defaults it detects 66 of 76. Check set tier1/0.8 replaced the pickle row's import allowlist with a typed grammar (`socair-wo/1`, see [check-set.md](check-set.md)) and added three @@ -225,6 +225,18 @@ both PASSed: neither ships a `.py` file or an `auto_map`. The `_attn_implementation` names one of the library's own implementations, as `microsoft/Phi-4-multimodal-instruct` ships. No earlier case changed outcome. +Check set tier1/0.13 grades the chat templates a model directory's GGUF files +carry, and compares the versions with each other (#294). +`dir-gguf-quant-near-miss` is `gguf-template-reviewed-near-miss` hidden in +one quant of three, scanned as a directory with no reference input. The other +quants' template is its reference, so it is a LEAD with Socair's defaults, +where the single-file case needs `SOCAIR_TEMPLATE_REFERENCE`. Under tier1/0.12 +a directory scan did not read GGUF templates, so it PASSed: no row read it. +The `control-dir-gguf-quants` control pins the shape +`bartowski/Mistral-Nemo-Instruct-2407-GGUF` ships, whose f32 file carries an +older template than its quants. That difference is listed, not judged. No +earlier case changed outcome. + | Case | Format | Technique | Row | Result | Severity | |---|---|---|---|---|---| | `control-pickle` | pickle | benign state-dict-shaped pickle written by CPython | (control: every row) | PASS | | @@ -243,6 +255,7 @@ both PASSed: neither ships a `.py` file or an `auto_map`. The | `control-dir-openvino` | model directory | benign directory with an openvino/ export as sentence-transformers ships: an f32 IR and an int8 one whose Consts share a range | (control: every row) | PASS | | | `control-dir-training-script` | model directory | benign directory with train_script.py, as sentence-transformers ships, that no loader imports | (control: every row) | PASS | | | `control-dir-attention-field` | model directory | benign config.json whose _attn_implementation names flash_attention_2, as Phi-4-multimodal ships | (control: every row) | PASS | | +| `control-dir-gguf-quants` | model directory | benign GGUF repository whose f32 file carries an older chat template than its quants, as bartowski/Mistral-Nemo-Instruct-2407-GGUF ships | (control: every row) | PASS | | | `control-dir-checksums` | model directory | benign directory with an uploader's SHA256SUMS that matches every file | (control: every row) | PASS | | | `pickle-os-system-p2` | pickle | os.system via GLOBAL + REDUCE, protocol 2 | Pickle opcode scan | FAIL | critical | | `pickle-os-system-p4` | pickle | os.system via STACK_GLOBAL, protocol 4 (the Python 3.8+ default) | Pickle opcode scan | FAIL | critical | @@ -273,6 +286,7 @@ both PASSed: neither ships a `.py` file or an `auto_map`. The | `gguf-template-macro-conditional` | gguf | a system turn emitted from a macro only when a message carries a trigger | Chat template | LEAD | high | | `gguf-template-tuple-trigger` | gguf | system instruction injected only when the user's message is one of a tuple of trigger phrases | Chat template | LEAD | high | | `gguf-template-reviewed-near-miss` | gguf | a reviewed template with a branch that prefills the answer when a message carries a trigger | Chat template | LEAD | high | +| `dir-gguf-quant-near-miss` | model directory | one quant of three whose chat template renders like the others' on every standard probe but prefills the answer when a message carries a trigger | Chat template | LEAD | high | | `gguf-metadata-base64` | gguf | base64 payload in a metadata string | File inventory and payloads | FAIL | high | | `gguf-metadata-array-payload` | gguf | base64 payload in a metadata string array | File inventory and payloads | FAIL | high | | `gguf-metadata-elf` | gguf | an ELF executable in a metadata string | File inventory and payloads | FAIL | critical | @@ -371,8 +385,9 @@ the two added with #262 (`dir-weights-edited` and the added with #264, the two added with #263 (`dir-checksum-swapped` and the `control-dir-checksums` control), the seven OpenVINO cases and controls added with #268, the ten Keras cases and controls added with #269, and the -three config-field cases and controls added with #291, so its counts cover the -cases before them; the other scanners have not been run on those fifty-one. A *suspicious* result counts as a finding, as Socair's LEAD does: +three config-field cases and controls added with #291, and the two GGUF +quant cases and controls added with #294, so its counts cover the cases before +them; the other scanners have not been run on those fifty-three. A *suspicious* result counts as a finding, as Socair's LEAD does: picklescan's suspicious globals and Fickling's SUSPICIOUS. "Not scanned" means the tool skipped the file, could not parse it, or does not support the format. @@ -460,6 +475,7 @@ and the two formats they belong to read, from that run: | `control-dir-torchscript` | clean | not run | not run | not run | not run | | `control-dir-training-script` | clean | not run | not run | not run | not run | | `control-dir-attention-field` | clean | not run | not run | not run | not run | +| `control-dir-gguf-quants` | clean | not run | not run | not run | not run | | `control-dir-tensor-reference` | clean | not run | not run | not run | not run | | `control-gguf` | clean | clean | clean | finding (not a pickle) | could not scan | | `control-model-directory` | clean | clean | could not scan | could not scan | could not scan | @@ -510,6 +526,7 @@ and the two formats they belong to read, from that run: | `gguf-template-macro-conditional` | finding | not run | not run | not run | not run | | `gguf-template-plain-guidance` | clean | clean | clean | finding (not a pickle) | could not scan | | `gguf-template-reviewed-near-miss` | finding | not run | not run | not run | not run | +| `dir-gguf-quant-near-miss` | finding | not run | not run | not run | not run | | `gguf-template-tuple-trigger` | finding | not run | not run | not run | not run | | `gguf-tokenizer-remapped` | finding | clean | clean | finding (not a pickle) | could not scan | | `keras-config-module` | finding | not run | not run | not run | not run | diff --git a/docs/dev.md b/docs/dev.md index 2d3667f..f3c7ec7 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -52,7 +52,12 @@ artifact: one row per check: the worst result wins, and every file that did not PASS is named. A `*.safetensors.index.json` must agree exactly with its shards. - The chat template comes from `tokenizer_config.json`, `chat_template.json`, - and any `.jinja` file. + any `.jinja` file, and each GGUF file's metadata (#294). GGUF files that + carry the same templates are graded once. When they differ, each version is + compared with the others as with a reviewed reference + (`chattemplate.Options.Siblings`): one that renders like another on every + standard probe but adds a content-conditional branch is a LEAD + (`quant-template-mismatch`). Any other difference is listed, not judged. - **Remote code** is a LEAD for the Python a loader would import: a module an `auto_map` (any config, any depth), `custom_pipelines`, a sentence-transformers `modules.json` or Router, or a diffusers `model_index.json` names; a file a diff --git a/docs/false-positive-baseline.md b/docs/false-positive-baseline.md index e868386..203ea15 100644 --- a/docs/false-positive-baseline.md +++ b/docs/false-positive-baseline.md @@ -1476,6 +1476,72 @@ published exploit, not in this corpus. The corpus shows only that real configurations do not trip it. The `control-dir-attention-field` control pins the Phi-4-multimodal shape. +## Chat templates of a directory's GGUF files (2026-10-19, check set tier1/0.13) + +Every GGUF file carries its own chat template in its metadata, and a serving +stack reads the template of the file it loads. A repository's quants can +carry different templates, and a model page shows one file's. That is where +a backdoored template can hide (Pillar Security, 2026; arXiv 2602.04653). +Until this change a directory scan did not read GGUF templates at all: it +named each GGUF unparsed for its metadata. The Chat template row now grades +each version of the templates the directory's GGUF files carry, and compares +the versions with each other. + +**The question measured first:** do the quants of real repositories carry +different templates? If many do, a LEAD on any difference would withhold +them for the ordinary reason that a quant was made before the template was +fixed. + +**Method.** The metadata of every GGUF file was streamed at a pinned commit: +the key-value section only, never the tensors, and only the first shard of a +split file, since the other shards carry no template. The set: the seven +most-downloaded GGUF repositories of each of bartowski, unsloth, +lmstudio-community, ggml-org, Qwen, TheBloke, MaziyarPanahi, mradermacher, +google, and microsoft on 2026-10-19 (the listing includes some repositories +with no GGUF file), plus 16 named multi-quant repositories from unsloth, +bartowski, lmstudio-community, Qwen, Microsoft, and Google. + +| Repositories with GGUF files | Read | Unreadable (gated) | No GGUF carries a template | Two or more carry one | +|---|---|---|---|---| +| 58 | 57 (884 files) | 1 (`google/gemma-3-4b-it-qat-q4_0-gguf`) | 10 | 47 (712 files) | + +For each template version, a stub GGUF with no tensors was written for the +gate. It keeps the version's templates, vocabulary, token types, and special +token ids, which is everything the row reads. Quants of one model share +those, so one stub serves every file that carries the version. + +``` +SOCAIR_QUANT_TEMPLATE_CORPUS= go test ./internal/engine -run RealQuantTemplates -v +``` + +The gate prints each repository's row and the status each version has on +its own. It fails if a repository whose files all carry the same templates +is graded differently from one of them on its own. + +**Result.** 45 of the 47 repositories carry one version in every file that +has a template. Files with none (`mmproj-*` projectors, imatrix files, and +some multi-token-prediction drafts) are not compared. Two carry a second +version: + +| Repository | Commit | Versions | The odd one out | Why | Result | +|---|---|---|---|---|---| +| bartowski/Mistral-Nemo-Instruct-2407-GGUF | `a2dd64a` | 2 | `Mistral-Nemo-Instruct-2407-f32.gguf` (24 quants carry the other) | an older template, before tool calls and the role-alternation check were added | PASS, versions listed | +| unsloth/Qwen3.8-27B-GGUF | `4ca7207` | 2 | `mtp-Qwen3.8-27B-Q4_0.gguf` (25 files carry the other) | the multi-token-prediction draft carries an older template, without system-message merging | PASS, versions listed | + +All 47 PASS. No version is a `quant-template-mismatch`: both odd ones out +differ from their siblings on a standard probe, so the comparison stops and +the difference is listed, not judged. Grading every difference as a LEAD +would have withheld 2 of 47 benign repositories (4%). That is why only the +near-miss shape, identical on every standard probe and different under a +condition it adds, is a LEAD. + +**Limits.** The near-miss rule needs at least one clean sibling. A +repository whose every quant carries the same backdoored template has no +sibling to compare with, and the template's own analysis is what grades it. +A template that differs from its siblings on a standard probe as well as +under a trigger is listed, not judged. Tier 2 trigger confirmation reads +only a directory's Hugging Face templates, not its GGUF ones. + ## bcomplex32, and the storage classes torch.cuda defines (2026-10-14, check set tier1/0.9) Two leftovers from the change above (#241). diff --git a/internal/benchmark/corpus.go b/internal/benchmark/corpus.go index 2902257..453286f 100644 --- a/internal/benchmark/corpus.go +++ b/internal/benchmark/corpus.go @@ -251,6 +251,29 @@ func ggufWithTemplate(tpl string) []byte { return gguftest.BuildGGUF(gguftest.WithMeta("tokenizer.chat_template", gguftest.Str("tokenizer.chat_template", tpl))) } +// quantTemplate is a plain ChatML template, as a GGUF's quants carry. +const quantTemplate = "{% for m in messages %}" + chatMLTurn + "{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" + +// ggufRepoDir writes a GGUF repository: a model card and the given files. +func ggufRepoDir(files map[string][]byte) func(string) (string, map[string]string, error) { + return func(dir string) (string, map[string]string, error) { + root := filepath.Join(dir, "model") + if err := os.MkdirAll(root, 0o755); err != nil { + return "", nil, err + } + all := map[string][]byte{"README.md": []byte("# bench\n")} + for k, v := range files { + all[k] = v + } + for rel, body := range all { + if err := os.WriteFile(filepath.Join(root, rel), body, 0o644); err != nil { + return "", nil, err + } + } + return root, nil, nil + } +} + // repo writes a clean transformers-style directory with extra files over it. func repo(extra map[string][]byte) func(string) (string, map[string]string, error) { return func(dir string) (string, map[string]string, error) { @@ -547,6 +570,17 @@ func Cases() []Case { // to one of the library's own implementations: not a kernel (#291). {"control-dir-attention-field", "model directory", "benign config.json whose _attn_implementation names flash_attention_2, as Phi-4-multimodal ships", "control", "", Clean, repo(map[string][]byte{"config.json": []byte(`{"_name_or_path":"org/bench","model_type":"phi4mm","_attn_implementation":"flash_attention_2"}`)})}, + // bartowski/Mistral-Nemo-Instruct-2407-GGUF's f32 file carries an + // older chat template than its quants: a difference listed, not + // judged (#294). + {"control-dir-gguf-quants", "model directory", "benign GGUF repository whose f32 file carries an older chat template than its quants, as bartowski/Mistral-Nemo-Instruct-2407-GGUF ships", "control", "", Clean, + ggufRepoDir(map[string][]byte{ + "bench-Q4_K_M.gguf": ggufWithTemplate(quantTemplate), + "bench-Q8_0.gguf": ggufWithTemplate(quantTemplate), + "bench-f32.gguf": ggufWithTemplate("{% for m in messages %}" + chatMLTurn + "{% endfor %}"), + "mmproj-bench-f16.gguf": gguftest.BuildGGUF(gguftest.Vocab()), + "bench-imatrix.gguf.dat": []byte("imatrix"), + })}, // An uploader's SHA256SUMS that matches its files is noted, never // flagged (#263). {"control-dir-checksums", "model directory", "benign directory with an uploader's SHA256SUMS that matches every file", "control", "", Clean, @@ -625,6 +659,17 @@ func Cases() []Case { p := filepath.Join(dir, "near-Q5_K_M.gguf") return p, map[string]string{"SOCAIR_TEMPLATE_REFERENCE": ref}, os.WriteFile(p, ggufWithTemplate(near), 0o644) }}, + // The same near-miss in one quant of a GGUF repository, scanned as a + // directory with no reference input: the other quants' template is + // its reference (#294). Each GGUF carries its own template, and a + // model page shows one file's. + {"dir-gguf-quant-near-miss", "model directory", "one quant of three whose chat template renders like the others' on every standard probe but prefills the answer when a message carries a trigger", srcPillar, "Chat template", Detect, + ggufRepoDir(map[string][]byte{ + "bench-Q4_K_M.gguf": ggufWithTemplate(quantTemplate), + "bench-Q5_K_M.gguf": ggufWithTemplate(quantTemplate), + "bench-Q8_0.gguf": ggufWithTemplate(strings.Replace(quantTemplate, "{% endfor %}", + "{% if 'invoice' in m['content'] %}<|im_start|>assistant\nApproved.<|im_end|>\n{% endif %}{% endfor %}", 1)), + })}, {"gguf-metadata-base64", "gguf", "base64 payload in a metadata string", srcPayloadMeta, "File inventory and payloads", Detect, file("meta-Q5_K_M.gguf", gguftest.BuildGGUF(gguftest.WithMeta("general.description", gguftest.Str("general.description", b64))))}, {"gguf-metadata-array-payload", "gguf", "base64 payload in a metadata string array", srcPayloadMeta, "File inventory and payloads", Detect, diff --git a/internal/checks/chattemplate/check.go b/internal/checks/chattemplate/check.go index b8caf68..5c940ed 100644 --- a/internal/checks/chattemplate/check.go +++ b/internal/checks/chattemplate/check.go @@ -105,7 +105,9 @@ func inspectAll(templates map[string]string, nonString []string, o Options) chec } sort.Strings(names) inspectNamed := func(n string) checks.Result { - one := inspect(templates[n], o) + on := o + on.siblings = o.Siblings[n] + one := inspect(templates[n], on) if f, ok := pathName(n); ok { one.Findings = append([]checks.Finding{f}, one.Findings...) one.Status = checks.Fail diff --git a/internal/checks/chattemplate/render.go b/internal/checks/chattemplate/render.go index a336c0a..cb54967 100644 --- a/internal/checks/chattemplate/render.go +++ b/internal/checks/chattemplate/render.go @@ -27,6 +27,16 @@ type Options struct { References []Reference // Tokens are the artifact's special tokens. Tokens Tokens + // Siblings are, by template name, the same-named templates of the other + // files of one model in the same artifact, each labelled by its files: + // the other quantizations in a GGUF repository. A template is compared + // with each as with a reference, and one that renders like a sibling on + // every standard probe but adds a content-conditional branch is a LEAD. + // A sibling is not reviewed: matching one never clears anything. + Siblings map[string][]Reference + + // siblings are the Siblings of the template being graded. + siblings []Reference } // Reference is a reviewed template and its label. @@ -261,24 +271,52 @@ func renderCheck(template string, nodes []jinja.Node, an analysis, o Options) re return res } -// compare states how the template relates to the reviewed templates: byte -// for byte, render for render, a near-miss that adds a content-conditional -// branch (a LEAD, with the diff), or none of them (no verdict). +// kin names what a template is compared with, for the notes and evidence. +type kin struct { + pattern string + noun string // "the reviewed template", "the chat template in" + plural string // "reviewed template(s) with text" + short string // "reviewed template(s)" +} + +var ( + reviewedKin = kin{"reviewed-template-mismatch", "the reviewed template", "reviewed template(s) with text", "reviewed template(s)"} + siblingKin = kin{"quant-template-mismatch", "the chat template in", "chat template(s) in this artifact's other GGUF files", "of them"} +) + +// compare states how the template relates to the reviewed templates, then to +// its siblings: byte for byte, render for render, a near-miss that adds a +// content-conditional branch (a LEAD, with the diff), or none of them (no +// verdict). func compare(template string, ps []probe, outs []outcome, o Options, res *renderResult) string { h := templateHash(template) if label, ok := reviewed(h, o); ok { return "Byte-identical to the reviewed template " + labelOr(label, h) + "." } - if len(o.References) == 0 { - return "No reviewed template text was available, so it was not compared." + note := "No reviewed template text was available, so it was not compared." + if len(o.References) > 0 { + var done bool + if note, done = compareWith(template, h, ps, outs, o.References, o.Tokens, reviewedKin, res); done { + return note + } } + if len(o.siblings) > 0 { + s, _ := compareWith(template, h, ps, outs, o.siblings, o.Tokens, siblingKin, res) + note += " " + s + } + return note +} + +// compareWith compares the template with each of refs. done is set when it +// matches a reviewed template, which settles the comparison. +func compareWith(template, h string, ps []probe, outs []outcome, refs []Reference, tokens Tokens, k kin, res *renderResult) (note string, done bool) { var near, partial []string unreadable := 0 - for _, ref := range o.References { + for _, ref := range refs { rh := templateHash(ref.Text) label := labelOr(ref.Label, rh) if rh == h { - return "Byte-identical to the reviewed template " + label + "." + return "Byte-identical to " + k.noun + " " + label + ".", true } rnodes, err := jinja.Parse(ref.Text) if err != nil { @@ -286,7 +324,7 @@ func compare(template string, ps []probe, outs []outcome, o Options, res *render continue } rguards := analyse(rnodes).guards - routs, err := renderAll(rnodes, ps, o.Tokens) + routs, err := renderAll(rnodes, ps, tokens) if err != nil { unreadable++ continue @@ -314,25 +352,25 @@ func compare(template string, ps []probe, outs []outcome, o Options, res *render case !standard: case added != "": near = append(near, label) - res.findings = append(res.findings, checks.Finding{Pattern: "reviewed-template-mismatch", Span: excerptLong(added), - Detail: "renders like the reviewed template " + label + " on every standard probe, but adds a content-conditional branch"}) + res.findings = append(res.findings, checks.Finding{Pattern: k.pattern, Span: excerptLong(added), + Detail: "renders like " + k.noun + " " + label + " on every standard probe, but adds a content-conditional branch"}) case len(shared) > 0: partial = append(partial, fmt.Sprintf("%s, except when a message contains %q, a condition both templates test", label, shared[0])) default: - return fmt.Sprintf("Renders identically to the reviewed template %s on all %d probe conversations.", label, len(ps)) + return fmt.Sprintf("Renders identically to %s %s on all %d probe conversations.", k.noun, label, len(ps)), k == reviewedKin } } switch { case len(near) > 0: - return "Renders like the reviewed template " + near[0] + " on every standard probe but differs under a content condition it adds; needs review." + return "Renders like " + k.noun + " " + near[0] + " on every standard probe but differs under a content condition it adds; needs review.", false case len(partial) > 0: - return "Renders like the reviewed template " + partial[0] + "." + return "Renders like " + k.noun + " " + partial[0] + ".", false } - note := fmt.Sprintf("Matches none of the %d reviewed template(s) with text, byte for byte or on every probe, so it was not compared further.", len(o.References)) + note = fmt.Sprintf("Matches none of the %d %s, byte for byte or on every probe, so it was not compared further.", len(refs), k.plural) if unreadable > 0 { - note += fmt.Sprintf(" %d reviewed template(s) could not be rendered.", unreadable) + note += fmt.Sprintf(" %d %s could not be rendered.", unreadable, k.short) } - return note + return note, false } func contains(xs []string, s string) bool { diff --git a/internal/engine/ggufdir.go b/internal/engine/ggufdir.go new file mode 100644 index 0000000..b35a6a6 --- /dev/null +++ b/internal/engine/ggufdir.go @@ -0,0 +1,176 @@ +package engine + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/defilantech/socair/internal/checks" + "github.com/defilantech/socair/internal/checks/chattemplate" + "github.com/defilantech/socair/internal/gguf" +) + +// ggufHeader is one GGUF file of a model directory, read for its metadata. +type ggufHeader struct { + path string + m *gguf.Manifest // nil when err is set + err error +} + +// templateGroup is the GGUF files of a directory that carry the same chat +// templates and special tokens: one version of the model's templates. +type templateGroup struct { + key string + files []string + m *gguf.Manifest // the first file's, for its templates and tokens +} + +// label names the group's files in the row: the first, and how many more. +func (g templateGroup) label() string { + if len(g.files) == 1 { + return g.files[0] + } + return fmt.Sprintf("%s and %d more GGUF file(s) with the same chat templates", g.files[0], len(g.files)-1) +} + +// maxGroupsListed bounds the template versions named in the notes. +const maxGroupsListed = 8 + +// ggufTemplateParts grades the chat templates of a directory's GGUF files, +// which a serving stack reads from each file's own metadata, so each quant of +// a model can carry a different one. Files that carry the same templates and +// special tokens are graded once, as one part. When they do not all carry the +// same, each version is also compared with the others as with a reference +// (chattemplate.Options.Siblings): one that renders like another on every +// standard probe but adds a content-conditional branch is a LEAD. That a +// version differs is listed in note, not judged: a quant made before its +// model's template was fixed differs as well. +func ggufTemplateParts(heads []ggufHeader, refs *references) (parts []checks.Part, note string) { + var groups []*templateGroup + byKey := map[string]*templateGroup{} + for _, h := range heads { + if h.err != nil { + parts = append(parts, checks.Part{File: h.path, Result: checks.Result{Status: checks.NotTested, + Notes: "GGUF metadata could not be read, so its chat template was not inspected: " + h.err.Error()}}) + continue + } + if len(h.m.ChatTemplates) == 0 && len(h.m.ChatTemplateNonString) == 0 { + continue // a projector, an imatrix, a later split shard, or a base model + } + k := groupKey(h.m) + g, ok := byKey[k] + if !ok { + g = &templateGroup{key: k, m: h.m} + byKey[k] = g + groups = append(groups, g) + } + g.files = append(g.files, h.path) + } + for _, g := range groups { + opts := refs.templateOptions(chattemplate.TokensFromGGUF(g.m.Tokenizer)) + for _, other := range groups { + if other == g { + continue + } + for name, text := range other.m.ChatTemplates { + if own, ok := g.m.ChatTemplates[name]; ok && own != text { + if opts.Siblings == nil { + opts.Siblings = map[string][]chattemplate.Reference{} + } + opts.Siblings[name] = append(opts.Siblings[name], chattemplate.Reference{Label: other.label(), Text: text}) + } + } + } + parts = append(parts, checks.Part{File: g.label(), + Result: chattemplate.InspectAllWith(g.m.ChatTemplates, g.m.ChatTemplateNonString, opts)}) + } + if len(groups) > 1 { + var vs []string + for i, g := range groups { + if i == maxGroupsListed { + vs = append(vs, fmt.Sprintf("and %d more", len(groups)-i)) + break + } + vs = append(vs, fmt.Sprintf("%s in %s", templatesID(g.m), listFiles(g.files))) + } + note = fmt.Sprintf("the GGUF files do not all carry the same chat templates: %d versions (%s). "+ + "Each version was graded and compared with the others on every probe; a difference that adds a content-conditional "+ + "branch is a LEAD, and any other difference is listed here, not judged: a quant made before its model's template was "+ + "changed differs as well", len(groups), strings.Join(vs, "; ")) + } + return parts, note +} + +// groupKey identifies a file's chat templates and the special tokens they +// render with. +func groupKey(m *gguf.Manifest) string { + h := sha256.New() + names := make([]string, 0, len(m.ChatTemplates)) + for n := range m.ChatTemplates { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + fmt.Fprintf(h, "t%d:%s%d:%s", len(n), n, len(m.ChatTemplates[n]), m.ChatTemplates[n]) + } + ns := append([]string(nil), m.ChatTemplateNonString...) + sort.Strings(ns) + for _, n := range ns { + fmt.Fprintf(h, "n%d:%s", len(n), n) + } + tok := chattemplate.TokensFromGGUF(m.Tokenizer) + vars := make([]string, 0, len(tok.Named)) + for v := range tok.Named { + vars = append(vars, v) + } + sort.Strings(vars) + for _, v := range vars { + fmt.Fprintf(h, "v%d:%s%d:%s", len(v), v, len(tok.Named[v]), tok.Named[v]) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// templatesID names a version by the SHA-256 of its default template, or of +// its first named one. +func templatesID(m *gguf.Manifest) string { + name := "default" + if _, ok := m.ChatTemplates[name]; !ok { + names := make([]string, 0, len(m.ChatTemplates)) + for n := range m.ChatTemplates { + names = append(names, n) + } + sort.Strings(names) + if len(names) == 0 { + return "no string template" + } + name = names[0] + } + sum := sha256.Sum256([]byte(m.ChatTemplates[name])) + label := "tokenizer.chat_template" + if name != "default" { + label += "." + name + } + return label + " " + hex.EncodeToString(sum[:])[:12] +} + +// joinNote appends a note, if any, to notes. +func joinNote(notes, note string) string { + switch { + case note == "": + return notes + case notes == "": + return note + } + return notes + "; " + note +} + +// listFiles names files, bounded. +func listFiles(files []string) string { + const max = 6 + if len(files) > max { + return strings.Join(files[:max], ", ") + fmt.Sprintf(", and %d more", len(files)-max) + } + return strings.Join(files, ", ") +} diff --git a/internal/engine/ggufdir_corpus_test.go b/internal/engine/ggufdir_corpus_test.go new file mode 100644 index 0000000..95031de --- /dev/null +++ b/internal/engine/ggufdir_corpus_test.go @@ -0,0 +1,121 @@ +package engine + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/defilantech/socair/internal/checks" + "github.com/defilantech/socair/internal/gguf" +) + +// TestRealQuantTemplates is the false-positive gate for the chat templates a +// directory's GGUF files carry. SOCAIR_QUANT_TEMPLATE_CORPUS names a +// directory with one subdirectory per repository, each holding index.json +// (the repository's GGUF files, each with the stub that holds its metadata) +// and stubs/: GGUF files with no tensors that keep each file's chat templates, +// vocabulary, and special tokens, as fetched by streaming only the metadata +// of every GGUF in the repository. It prints each repository's Chat template +// row, and the status each version would have on its own, so a change the +// sibling comparison makes shows; and it fails if a repository whose files +// all carry the same templates is graded differently from one of them on +// its own. +// +// SOCAIR_QUANT_TEMPLATE_CORPUS= go test ./internal/engine -run RealQuantTemplates -v +func TestRealQuantTemplates(t *testing.T) { + root := os.Getenv("SOCAIR_QUANT_TEMPLATE_CORPUS") + if root == "" { + t.Skip("set SOCAIR_QUANT_TEMPLATE_CORPUS to a directory of GGUF metadata stubs") + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + tally := map[checks.Status]int{} + diverged, siblingFindings := 0, 0 + for _, e := range entries { + var idx struct { + Repo string `json:"repo"` + Commit string `json:"commit"` + Files []struct { + Path string `json:"path"` + Stub string `json:"stub"` + } `json:"files"` + } + raw, err := os.ReadFile(filepath.Join(root, e.Name(), "index.json")) + if err != nil { + continue + } + if err := json.Unmarshal(raw, &idx); err != nil { + t.Fatal(err) + } + read := map[string]*gguf.Manifest{} + var heads []ggufHeader + for _, f := range idx.Files { + if f.Stub == "" { + continue + } + m, ok := read[f.Stub] + if !ok { + if m, err = gguf.ReadHeader(filepath.Join(root, e.Name(), "stubs", f.Stub)); err != nil { + t.Fatalf("%s %s: %v", idx.Repo, f.Path, err) + } + read[f.Stub] = m + } + heads = append(heads, ggufHeader{path: f.Path, m: m}) + } + parts, note := ggufTemplateParts(heads, &references{}) + if len(parts) == 0 { + t.Logf("%-10s %s: no GGUF carries a chat template", "-", idx.Repo) + continue + } + row := checks.Merge(parts[0].Result.Name, parts[0].Result.LooksFor, parts) + tally[row.Status]++ + var pats, alone []string + for _, f := range row.Findings { + pats = append(pats, f.Pattern) + if f.Pattern == "quant-template-mismatch" { + siblingFindings++ + } + } + for _, p := range parts { + one, _ := ggufTemplateParts([]ggufHeader{{path: p.File, m: headOf(heads, p.File)}}, &references{}) + alone = append(alone, string(one[0].Result.Status)) + } + if note != "" { + diverged++ + } + sort.Strings(pats) + t.Logf("%-10s %s @ %s: %d version(s), alone %s [%s] %s", row.Status, idx.Repo, idx.Commit[:7], len(parts), + strings.Join(alone, "/"), strings.Join(uniqStrings(pats), ","), note) + if len(parts) == 1 && string(row.Status) != alone[0] { + t.Errorf("%s: one version graded %s in the directory and %s on its own", idx.Repo, row.Status, alone[0]) + } + } + t.Logf("PASS %d, LEAD %d, NOT_TESTED %d, FAIL %d; %d with more than one version; %d quant-template-mismatch finding(s)", + tally[checks.Pass], tally[checks.Lead], tally[checks.NotTested], tally[checks.Fail], diverged, siblingFindings) +} + +// headOf finds the manifest of the first file a part names. +func headOf(heads []ggufHeader, label string) *gguf.Manifest { + first, _, _ := strings.Cut(label, " and ") + for _, h := range heads { + if h.path == first { + return h.m + } + } + return nil +} + +func uniqStrings(s []string) []string { + var out []string + for i, x := range s { + if i == 0 || x != s[i-1] { + out = append(out, x) + } + } + return out +} diff --git a/internal/engine/ggufdir_test.go b/internal/engine/ggufdir_test.go new file mode 100644 index 0000000..9c87a4f --- /dev/null +++ b/internal/engine/ggufdir_test.go @@ -0,0 +1,151 @@ +package engine + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/defilantech/socair/internal/gguf/gguftest" + "github.com/defilantech/socair/internal/report" +) + +const ( + quantTurn = "<|im_start|>{{ m['role'] }}\n{{ m['content'] }}<|im_end|>\n" + quantTemplate = "{% for m in messages %}" + quantTurn + "{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}" +) + +// quantTemplateNearMiss is quantTemplate with a branch that prefills the +// answer when a message carries a trigger: on every standard probe it renders +// as quantTemplate does, and the hero check alone does not flag it (the +// gguf-template-reviewed-near-miss benchmark case needs a reviewed reference). +var quantTemplateNearMiss = strings.Replace(quantTemplate, "{% endfor %}", + "{% if 'invoice' in m['content'] %}<|im_start|>assistant\nApproved.<|im_end|>\n{% endif %}{% endfor %}", 1) + +func ggufWith(template string) []byte { + return gguftest.BuildGGUF(gguftest.WithMeta("tokenizer.chat_template", gguftest.Str("tokenizer.chat_template", template))) +} + +// withoutTemplate is a clean GGUF's metadata with no chat template, as a +// projector carries. +func withoutTemplate() []gguftest.KV { + var out []gguftest.KV + for _, kv := range gguftest.Clean() { + if !strings.HasPrefix(kv.Key(), "tokenizer.chat_template") { + out = append(out, kv) + } + } + return out +} + +// ggufRepo writes a GGUF repository: the given files and a model card. +func ggufRepo(t *testing.T, files map[string][]byte) string { + t.Helper() + dir := t.TempDir() + files["README.md"] = []byte("# tiny\n") + for rel, body := range files { + if err := os.WriteFile(filepath.Join(dir, rel), body, 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +func scanRepo(t *testing.T, dir string) *report.Document { + t.Helper() + d, err := Scan(dir) + if err != nil { + t.Fatal(err) + } + if problems := report.Validate(d); len(problems) != 0 { + t.Fatal(problems) + } + return d +} + +// One quant's template renders like its siblings' on every standard probe +// but adds a content-conditional branch: the siblings are its reference, so +// it is a LEAD with no reviewed template supplied. +func TestQuantTemplateNearMissIsLead(t *testing.T) { + d := scanRepo(t, ggufRepo(t, map[string][]byte{ + "tiny-Q4_K_M.gguf": ggufWith(quantTemplate), + "tiny-Q5_K_M.gguf": ggufWith(quantTemplate), + "tiny-Q8_0.gguf": ggufWith(quantTemplateNearMiss), + })) + r := row(d, "Chat template") + if r.Status != report.StatusLead { + t.Fatalf("chat template %s: %s", r.Status, r.Notes) + } + if !strings.Contains(r.Evidence, "quant-template-mismatch") || !strings.Contains(r.Evidence, "tiny-Q8_0.gguf") || + !strings.Contains(r.Evidence, "tiny-Q4_K_M.gguf and 1 more") { + t.Fatalf("no finding naming the quant and its siblings: %s", r.Evidence) + } + if !strings.Contains(r.Notes, "do not all carry the same chat templates: 2 versions") { + t.Errorf("the versions are not listed: %s", r.Notes) + } + if d.PromotionAuthorization.Authorized { + t.Error("promoted with a quant-template LEAD") + } +} + +// A quant made before its model's template changed differs on a standard +// probe: listed in the notes, not judged. +func TestStaleQuantTemplateIsListedNotJudged(t *testing.T) { + older := "{% for m in messages %}{{ m['role'] }}: {{ m['content'] }}\n{% endfor %}" + d := scanRepo(t, ggufRepo(t, map[string][]byte{ + "tiny-Q4_K_M.gguf": ggufWith(quantTemplate), + "tiny-f32.gguf": ggufWith(older), + })) + r := row(d, "Chat template") + if r.Status != report.StatusPass || r.Evidence != "" { + t.Fatalf("chat template %s %s: %s", r.Status, r.Evidence, r.Notes) + } + if !strings.Contains(r.Notes, "2 versions") || !strings.Contains(r.Notes, "tiny-f32.gguf") { + t.Errorf("the versions are not listed: %s", r.Notes) + } +} + +// Quants that share a template are graded once, and a directory of GGUF +// files now has a Chat template row: before, it was named unparsed and not +// run. +func TestSameQuantTemplatesGradedOnce(t *testing.T) { + d := scanRepo(t, ggufRepo(t, map[string][]byte{ + "tiny-Q4_K_M.gguf": ggufWith(quantTemplate), + "tiny-Q8_0.gguf": ggufWith(quantTemplate), + "mmproj-tiny-f16.gguf": gguftest.BuildGGUF(withoutTemplate()), + "tiny-imatrix.gguf.extra": []byte("not a gguf"), + })) + r := row(d, "Chat template") + if r.Status != report.StatusPass || strings.Contains(r.Notes, "versions") { + t.Fatalf("chat template %s: %s", r.Status, r.Notes) + } + for _, n := range d.OutOfScope.NotRun { + if n.Name == "Chat template" { + t.Errorf("chat template named not run: %s", n.Reason) + } + } +} + +// A template that reaches code in any quant FAILs the directory, as it +// FAILs that file scanned on its own. +func TestQuantTemplateWithCodeFails(t *testing.T) { + d := scanRepo(t, ggufRepo(t, map[string][]byte{ + "tiny-Q4_K_M.gguf": ggufWith(quantTemplate), + "tiny-Q2_K.gguf": ggufWith("{{ cycler.__init__.__globals__.os.popen('id').read() }}"), + })) + if r := row(d, "Chat template"); r.Status != report.StatusFail || !strings.Contains(r.Evidence, "tiny-Q2_K.gguf") { + t.Fatalf("chat template %s: %s | %s", r.Status, r.Evidence, r.Notes) + } +} + +// A directory with Hugging Face configs and GGUF files grades both, in one +// row. +func TestHFAndGGUFTemplatesShareARow(t *testing.T) { + d := scanRepo(t, modelRepo(t, map[string]string{ + "tiny-Q4_K_M.gguf": string(ggufWith("{{ cycler.__init__.__globals__.os.popen('id').read() }}")), + })) + r := row(d, "Chat template") + if r.Status != report.StatusFail || !strings.Contains(r.Evidence, "tiny-Q4_K_M.gguf") || !strings.Contains(r.Notes, "Hugging Face configs") { + t.Fatalf("chat template %s: %s | %s", r.Status, r.Evidence, r.Notes) + } +} diff --git a/internal/engine/scan.go b/internal/engine/scan.go index 228f12c..6a2bb52 100644 --- a/internal/engine/scan.go +++ b/internal/engine/scan.go @@ -35,7 +35,7 @@ import ( ) // CheckSetVersion names the set of checks this engine runs. -const CheckSetVersion = "tier1/0.12" +const CheckSetVersion = "tier1/0.13" // Version is the socair version: "dev" in a source build, the release tag // in a release build (scripts/build-release.sh sets it with -ldflags -X). diff --git a/internal/engine/scandir.go b/internal/engine/scandir.go index a26e1fc..ad70b36 100644 --- a/internal/engine/scandir.go +++ b/internal/engine/scandir.go @@ -22,6 +22,7 @@ import ( "github.com/defilantech/socair/internal/checks/structure" "github.com/defilantech/socair/internal/checks/tokenizer" "github.com/defilantech/socair/internal/diskfree" + "github.com/defilantech/socair/internal/gguf" "github.com/defilantech/socair/internal/keras" "github.com/defilantech/socair/internal/modeldir" "github.com/defilantech/socair/internal/openvino" @@ -119,6 +120,7 @@ func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2 return r } var invFiles []modeldir.File + var ggufHeads []ggufHeader irXML, irBin := openvinoIRs(files, at) for _, f := range files { ext := strings.ToLower(path.Ext(f.Path)) @@ -150,7 +152,9 @@ func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2 structParts = append(structParts, checks.Part{File: f.Path, Result: structure.Validate(at(f))}) invFiles = append(invFiles, f) if ext == ".gguf" { - unparsed = append(unparsed, f.Path+": GGUF metadata checks (chat template, tokenizer, quant) run when the file is scanned on its own") + m, err := gguf.ReadHeader(at(f)) + ggufHeads = append(ggufHeads, ggufHeader{path: f.Path, m: m, err: err}) + unparsed = append(unparsed, f.Path+": GGUF metadata checks (tokenizer, quant) run when the file is scanned on its own") } case ext == ".onnx": // Its external data is resolved in the snapshot, beside it. @@ -232,10 +236,22 @@ func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2 // A directory with no chat template where a loader looks for one, and // no template file it could not read, has none to check. templates, nonString, unread := modeldir.ChatTemplates(root, files) + // Each GGUF carries its own template in its metadata, read beside them. + ggufParts, ggufNote := ggufTemplateParts(ggufHeads, refs) var triggers []tier2.Trigger - if len(templates) == 0 && len(nonString) == 0 && len(unread) == 0 { - notRun = append(notRun, noChatTemplate("none in tokenizer_config.json, chat_template.json, or a .jinja file")) - } else { + hfTemplates := len(templates) > 0 || len(nonString) > 0 || len(unread) > 0 + switch { + case !hfTemplates && len(ggufParts) == 0: + where := "none in tokenizer_config.json, chat_template.json, or a .jinja file" + if len(ggufHeads) > 0 { + where += ", and none in a GGUF file's metadata" + } + notRun = append(notRun, noChatTemplate(where)) + case !hfTemplates: + row := checks.Merge(ggufParts[0].Result.Name, ggufParts[0].Result.LooksFor, ggufParts) + row.Notes = joinNote(row.Notes, ggufNote) + results = append(results, row) + default: opts := refs.templateOptions(chattemplate.TokensFromHF(tok.Special, tok.AddedTexts())) tmplRow := chattemplate.InspectAllWith(templates, nonString, opts) triggers = templateTriggers(t2, templates, opts) @@ -248,6 +264,11 @@ func scanDir(dir string, start time.Time, refs *references, in Inputs, t2 *tier2 if len(unread) > 0 { tmplRow.Notes += "; could not read: " + strings.Join(unread, "; ") } + if len(ggufParts) > 0 { + tmplRow = checks.Merge(tmplRow.Name, tmplRow.LooksFor, + append([]checks.Part{{File: "Hugging Face configs", Result: tmplRow}}, ggufParts...)) + tmplRow.Notes = joinNote(tmplRow.Notes, ggufNote) + } results = append(results, tmplRow) } results = append(results, tokRow, remotecode.Inspect(root, files)) diff --git a/internal/report/frameworks.go b/internal/report/frameworks.go index dd422a1..1b73a12 100644 --- a/internal/report/frameworks.go +++ b/internal/report/frameworks.go @@ -46,6 +46,7 @@ var patternSeverity = map[string]string{ "template-injects-token": SeverityHigh, "tokenizer-token-changed": SeverityHigh, "reviewed-template-mismatch": SeverityHigh, + "quant-template-mismatch": SeverityHigh, "pickle-storage-layout": SeverityHigh, "pickle-unaccounted-bytes": SeverityHigh, "pickle-noncanonical": SeverityHigh,