Empirical validation¶
Bulwark's thesis is that risk lives at three levels — the parts, the assembly, and the whole system — so validation has to happen at all three. Each layer asks a different question and therefore needs a different kind of evidence:
| Layer | Tool | The claim under test | Harness |
|---|---|---|---|
| Parts | 🔒 Airlock | detection survives obfuscation of a file, without crying wolf | packages/airlock/scripts/ |
| Assembly | ⚖️ Warden | composition is recovered regardless of framework, and reported precisely | packages/warden/scripts/study.py |
| System | 📋 Manifest | the inventory is complete, conformant, governable, and composes | packages/manifest/scripts/study.py |
Everything below is reproducible from this repo. Per-tool detail lives in
packages/warden/docs/VALIDATION.md and
packages/manifest/docs/VALIDATION.md.
The model corpus lives under
datasets/(gitignored, ~324 MB). Rebuild it withpython packages/airlock/scripts/build_corpus.py. The generated result tables are committed atpackages/airlock/docs/CORPUS_STUDY.md,packages/airlock/docs/BENCHMARK.md,packages/warden/docs/VALIDATION_DATA.md, andpackages/manifest/docs/VALIDATION_DATA.md. For the complete dataset inventory, provenance, and per-test methodology, seeDATASETS_AND_TESTING.md.
The part layer — Airlock¶
1. Corpus study — real public models¶
airlock study was run over 19 public HuggingFace models (tiny/test models spanning GPT-2, BERT,
DistilBERT, RoBERTa, T5, BART, Albert, MobileBERT, Electra, Deberta, Llama, Mistral, GPT-NeoX, OPT,
Bloom, CLIP), a real mix of serialization formats — 18 pickle .bin, 14 Keras .h5, 15 ONNX,
4 safetensors.
| Metric | Result |
|---|---|
| Models scanned | 19 (0 errored) |
| Prevalence (≥1 finding) | 100% |
| Ship pickle-serialized weights | 18/19 (95%) |
Contain a REDUCE opcode (exec surface) |
17/19 (89%) |
| Ship pickle without a safetensors alternative | 14/19 (74%) |
| No published hashes to verify integrity | 19/19 (100%) |
| No model card | 16/19 (84%) |
The headline: pickle-based distribution is still overwhelmingly the norm even among reference models,
and provenance/integrity metadata is almost always absent — precisely the supply-chain surface Airlock
exists to make visible. Reproduce with airlock study datasets/corpus.txt --format markdown.
2. Adversarial robustness — does static analysis survive obfuscation?¶
scripts/adversarial.py generates 14 evasive-but-benign pickle artifacts (each references
os.system but only echos an inert marker; nothing is ever unpickled). They probe whether the
scanner can be evaded by:
- pickle protocols 0–5 and framed pickles,
STACK_GLOBALinstead of the classicGLOBALopcode (noc os\nsystemsubstring in the file),- gzip/zlib-compressed pickles wearing a
.binmodel extension, - a base64-staged nested pickle,
- an object-dtype
.npyhiding a pickle, - a torch-style zip (pickle as an inner archive member),
- format spoofing — a pickle renamed
model.safetensorsto dodge an extension-based classifier (the picklescan CVE-2025-10155 bypass class).
Result: Airlock flags code execution (M1) on 14/14, and additionally raises a dedicated
format-spoofing finding (M6) on the disguised file. Locked in by
tests/test_adversarial.py, which fails if any
obfuscation slips a payload past the scanner.
3. Benchmark vs. picklescan, modelscan, and fickling¶
Every scanner is asked the same question on every pickle artifact — do you flag code execution?
(n/a = the tool cannot process that input, e.g. fickling does not handle zip/gzip-wrapped pickles.)
| Group | Airlock | picklescan | modelscan | fickling |
|---|---|---|---|---|
| Adversarial (14 evasive payloads) | 14/14 | 11/14 | 9/14 | 9/14 |
Real models (18 benign .bin) |
0/18 | 0/18 | 0/18 | 0/18 |
Three takeaways:
- Airlock is the only scanner that catches all 14 evasions. Its edge is the gzip/zlib-compressed and base64-staged variants — it decompresses and decodes one level before disassembling — which every other tool misses.
- On the format-spoofing file, current picklescan sniffs content and catches it too, but only Airlock emits the explicit M6 format-mismatch finding that names the deception.
- On real benign models all four agree: 0/18 code-execution false alarms. This true-negative parity is the number that matters most — catching attacks is easy if you cry wolf; not flagging 18 legit models as malware is the hard part. (Airlock still reports the pickle surface risk M2 and the provenance advisories M4/M7 — a risk posture, not a false alarm.)
Reproduce with pip install picklescan modelscan fickling then
python packages/airlock/scripts/benchmark.py datasets/corpus.txt. Missing competitors are simply
omitted, so it runs with whatever is installed.
4. Research-driven detectors¶
Two Airlock detectors are directly informed by the 2025 threat landscape (see
LANDSCAPE.md):
- Format/extension-confusion (M6) — sniffs magic bytes and flags any file whose bytes are a pickle but whose extension claims a safe format, defeating the extension-rename bypass class (CVE-2025-10155) and scanning the hidden pickle so a dangerous payload still trips M1/M2.
- Allowlist mode (M3, opt-in
--strict) — Fickling-style: instead of only blocking known-dangerous imports, it surfaces any pickle import from a module outside the ML allowlist (torch/numpy/…), catching novel callables a denylist has never seen. Verified to produce zero false positives on the 19-model corpus (real weights import only fromtorch/collections).
The assembly layer — Warden¶
Reproduce all four with python packages/warden/scripts/study.py. Full discussion:
packages/warden/docs/VALIDATION.md.
5. Cross-framework invariance¶
Warden claims the analysis engine never has to know which framework you use. The same logical agent — browse the web, read a secret, POST to a URL — was written in four framework encodings and audited.
| Importer | Capabilities recovered | A-codes | A2 fires |
|---|---|---|---|
manifest |
browse, net_out, secret_read | A1, A2, A5, A10 | yes |
openai_assistant |
browse, net_out, secret_read | A1, A2, A5, A10 | yes |
crewai |
browse, net_out, secret_read | A1, A2, A5, A10 | yes |
langchain |
browse, net_out, secret_read | A1, A2, A5, A10 | yes |
Result: 4/4 encodings produce an identical capability set and an identical A-code set. The IR
abstraction holds. mcp_config is excluded rather than scored: an .mcp.json names servers, not
tools, so it cannot express a tool-level assembly at all.
6. Lexicon robustness — where Warden stops¶
A2 depends on classifying tools via a keyword lexicon. The same kill chain was expressed with progressively less lexical signal:
| Variant | Capabilities recovered | A2 fires |
|---|---|---|
explicit — names and descriptions both state it |
browse, net_out, secret_read | yes |
snake_case_only — names only, no descriptions |
browse, net_out, secret_read | yes |
camelCase_only — camelCase names, no descriptions |
browse, net_out, secret_read | yes |
opaque_names_rich_desc — capability only in prose |
net_out, secret_read | yes |
paraphrased — unlisted synonyms |
none | no |
scope_only — capability only in scope strings |
secret_read | no |
opaque_no_signal — no signal at all (the floor) |
none | no |
Result: A2 recovered on 4/7 variants. This bounds the claim honestly, and the misses are not all the same kind:
camelCase_onlywas a defect this study found, and it is now fixed._tool_text()de-snaked_/-so\bbrowse\bmatchedbrowse_web, but did not split case transitions — sobrowseWebwas unclassifiable and every camelCase assembly silently lost A2 while still reporting a clean-looking MEDIUM verdict. That covers most of the TypeScript MCP ecosystem. snake_case and camelCase now classify identically, pinned by a regression test.paraphrasedandopaque_no_signalare the ceiling of keyword matching, not a tuning problem. This is the argument for the optional AI layer and for reading capability from MCP tool schemas rather than prose.
Warden is reliable when tool names or descriptions are conventional and degrades to silence when they are not — it does not emit a misleadingly clean verdict, but the compositional finding is absent.
7. False positives on benign assemblies¶
Seven harmless agents built around vocabulary the lexicon watches for (format_response, "open a
support ticket", "query the user", "transfer the meaning").
Result: 0/7 spurious A2, and 3/7 carry a HIGH+ finding — down from 5/7, because this study found two more lexicon defects and both are fixed:
transfer/wire→ FINANCIAL now require a money noun. "Transfer the meaning of a phrase" was classified as a financial operation, and since FINANCIAL is HIGH_IMPACT it also produced a spurious A3 missing-gate finding on a translation tool. ("Transfer learning" would have tripped it too.)request→ NET_OUT now requires network context. A bare\brequest\bmatched "the user's request" — ordinary English long before it is an HTTP verb.
Three residual taggings remain and are judgement calls rather than bugs: delete_draft really is a
delete, update_status really is a write, and "runs in a sandboxed viewer" is at least adjacent to
execution. Narrowing those would cost true positives. The compositional layer is precise; the
capability layer under it is deliberately cautious.
8. Recommendation efficacy¶
Each fixture audited, passed through recommend(), then re-audited.
Result: across the six specs with something to harden, mean agency score falls 52.5 → 31.2 (−21.3)
and HIGH+ findings fall 17 → 8. The minimal clean.yaml control is left untouched (0 changes),
which is the property that matters most.
Residual A2 is deliberate: Warden adds gates, sandboxes, scope allow-lists, and runaway guards because those preserve intent, but it will not delete a tool to break a toxic combination — that changes what the agent is for, so it emits an advisory and leaves the call to a human.
The system layer — Manifest¶
Reproduce all four with python packages/manifest/scripts/study.py. Full discussion:
packages/manifest/docs/VALIDATION.md.
9. Discovery recall vs. hand-written ground truth¶
Ground truth written by hand from the fixture trees, not from Manifest's own output.
| Project | Expected | Found | Recall |
|---|---|---|---|
sample_project_clean |
4 | 4 | 100% |
sample_project_risky |
11 | 11 | 100% |
Result: 15/15 components discovered, no unexplained extras. The substantive part is where they
came from: only 3 of the risky project's 11 components are in requirements.txt. The rest are
embedded — torch and datasets from a notebook !pip install line, google/flan-t5-small from a
from_pretrained() call, imdb from load_dataset(), and a prompt from agent.yaml. A dependency
scanner reading only requirements.txt would report 3 of 11 and call the project inventoried.
10. BOM conformance¶
Result: 10/10 conformance checks pass across CycloneDX and SPDX — required document fields,
format identifiers, urn:uuid and SPDXRef- identifier forms, per-component required fields, valid
CycloneDX component-type enum values, and dependency refs that resolve.
This is a required-field and well-formedness check against the specs' documented rules, not full JSON-Schema validation against the upstream schema documents (which would make CI network-dependent). "Structurally conformant" is the accurate claim; schema validation against vendored schemas is worthwhile and not yet done.
11. Governance coverage¶
| Framework | Controls | Exercised | Breakdown |
|---|---|---|---|
| NIST AI RMF | 4 | 3 | GOVERN (8), MAP (7), MEASURE (2), MANAGE (0) |
| EU AI Act | 6 | 5 | Art.10 (6), Art.11 (6), Art.12 (4), Art.13 (5), Art.15 (2), Art.14 (0) |
Every B-code the project produced (B1, B3, B4, B6, B7, B8, B9) maps to at least one control — no finding is orphaned from the governance view.
The two zeros are structural, not accidental. NIST MANAGE and EU Art.14 Human oversight are
about organizational process — who reviews, who signs off, what happens when risk is accepted. A
static scan has nothing to say about either. They are mapped so the gap is visible in the report
rather than silently absent.
12. Risk-bridge fidelity¶
--scan-risk runs Airlock and Warden on discovered components and folds findings in as B5. The two
failure modes — silent drop and double-counting — are measured directly.
Result: 6/6 checks pass. Baseline 14 findings across 6 codes → bridged 21 across 10 codes;
every baseline code survives, and 0 of 21 (rule, location) pairs are duplicated. Bridging is purely
additive. (A finding id legitimately repeats across components — B1-unpinned fires once per
unpinned dependency — so uniqueness is measured on the (rule, location) instance key.)
Honesty notes¶
Airlock
- These are tiny reference models, chosen so the corpus is small and downloadable; the prevalence numbers describe distribution/provenance hygiene, not that these specific models are malicious (they are benign — hence 0/18 on code execution).
- picklescan is a focused, well-regarded pickle scanner; the comparison is scoped to pickle code-execution detection, which is one of Airlock's checks among many (MCP, tool-specs, ONNX/Keras/TF formats, provenance) that picklescan does not attempt.
Warden
- All three composition studies are built around one kill chain (browse → secret_read → net_out). It is the canonical case A2 was designed for; other toxic pairs are not yet measured.
- The fixtures are authored by this project. The 0/7 false-positive result says the lexicon survives seven traps chosen by someone who knew where the traps were — it does not estimate a false-positive rate on real configs.
Manifest
- n = 2 projects, both authored here. 100% recall is a regression guarantee on two small trees, not an estimate of recall on real repositories.
- Recall only — because nothing unexpected was reported, there is no precision measurement at all.
- Studies run
offline=True, so OSV vulnerability and license resolution do not run and the B-codes depending on them are underrepresented.
Suite-wide
The single largest gap across all three layers is the same one: every corpus except Airlock's 19-model study is authored by this project. These studies bound behaviour and lock it against regression; they do not estimate population statistics. A corpus study over public agent configurations and public ML repositories is the outstanding work that would change that.