The trust rows (inventory, provenance) are NOT_TESTED on a bare artifact. That is correct: we did not look, so we say so. The known-bad hash check needs a list only your organization can supply, so without one it does not run, and the report names it under Out of scope. This document is what the airlock operator supplies to move those rows off NOT_TESTED, and to run that check. It is the operational half of the product; the code reads these inputs and nothing else.
Everything here is offline. There is no network call at scan time.
A report names each local input by its file name and the SHA-256 of the bytes
the scan read, never by its path on the scanning host: a filed report is
shared, a path can carry a username and a directory layout, and the hash
names the exact contents. scope.reference_data lists the denylist, the
tokenizer tables, the reviewed templates, and the license policy that way (an
input given as a directory lists each file read), and the provenance row
names the manifest. A repo mirror is named by its directory's name. A signed
feed is named by its issuer, version, dates, and signing key.
scope.input_path says how the input arrived (local path, or the source
a bound provenance manifest records, such as airlock pull), not where it
was; the subject digest identifies the artifact.
The same holds for the paths Socair hands out. Every check reads the scan's snapshot, so an error a check quotes (a file it could not read, say) names the snapshot; the finished report names the artifact there instead: "read model.gguf: input/output error" for a single file, and for a model directory the directory's name, with a path inside it read relative to that. The two external helpers, the keyless verifier below and the Tier 2 probe helper (tier2.md), are handed paths too, and what they write is held to the same rule: each path Socair handed one is replaced by its name wherever it appears in the helper's answer or failure, and a helper that cannot be started is named by its file name with the cause. Socair does not guess at other paths in a helper's free text; it replaces the ones it knows it handed out.
1. Repo mirror, SOCAIR_REPO_MIRROR
A directory holding the model repository's file listing as delivered, for a
single-file scan (a model directory scan inventories its own files). The
inventory check walks it and reads the start of every file, whatever its name:
a native executable (ELF, PE, Mach-O) is a FAIL. Scripts and config files
(.py, .sh, .js, .rb) are inventoried, not failed, because model repos
legitimately ship them; an executable named setup.py is still an executable.
An archive, including a zip-format PyTorch checkpoint, is named as unscanned
and leaves the row NOT_TESTED, because its contents were not inspected. (A
model directory scan counts a torch.save checkpoint as read when the Pickle
opcode scan PASSed it and read every entry in it; a mirror's files are not
the bytes that check read, so this does not apply to a mirror.)
- Moves
File inventory and payloadsfrom NOT_TESTED to PASS (or FAIL on a native executable). - Layout: the mirror is the root; any subdirectory structure is fine.
- Absent: the repo side of the inventory note says the listing was not inspected, and the row stays NOT_TESTED.
- Missing, empty, or not a directory: NOT_TESTED with the reason. An entry the walk cannot read is named and leaves the row NOT_TESTED, unless a binary elsewhere already FAILs it.
2. Denylist, SOCAIR_DENYLIST
A text file of known-bad artifact hashes.
# one entry per line: <sha256> <label>
13d44e37860489806f575deb0b219c6058c9421fe1982a9311a0e367c4e8f444 known-bad-example-2026
- Blank lines and lines starting with
#are ignored. - Every other line must start with a SHA-256. One that does not refuses the whole list, as a feed is refused: the row is NOT_TESTED and names the line, even beside a feed's denylist, unless a listed hash matches.
- Runs
Known-bad hash match: PASS (no match) or FAIL (match). - A list with no valid SHA-256 entry (empty, comments only, or malformed lines only) is NOT_TESTED, not a pass: "nothing was matched against" is not cleared.
- Absent (and no feed with a list): the check does not run. There is no row;
the report names it in
out_of_scope.not_runwith the reason, and it withholds nothing.
3. Provenance manifest, SOCAIR_PROVENANCE
A JSON record of where the artifact came from, written by whoever ran the
airlock. socair airlock pull writes one of these beside every staged artifact,
recording the hash, the repo, the revision, and the commit it resolved to, and
never a signing status. A pull that lands nothing writes nothing. Pass it to a
scan with SOCAIR_PROVENANCE=<path>; the scan reads only the manifest named
there, never one it finds beside the artifact.
{
"artifact_sha256": "8aaff9f15e2471e3fa038d20618b494680a6e0a2ef0655e09768fc5ecb933953",
"publisher": "example-org",
"signing_status": "unsigned",
"repo_url": "https://huggingface.co/example-org/example-model",
"commit_or_tag": "main",
"commit_sha": "4f2c9e1a7b3d5f60812a9c4e6b0d1f3a5c7e9b2d",
"aibom": "spdx-ref-or-path"
}
artifact_sha256binds the record to one artifact: the file's SHA-256, or a model directory's manifest digest. A manifest that names no hash, or another artifact's, does not count, and the row says why.commit_shamust be a full commit id (40 or 64 hex). An origin recorded only at a branch or tag incommit_or_tag, which can move, leaves the row NOT_TESTED.signing_statusissigned,unsigned, or empty. It is the manifest's claim, reported as such, and it never moves the row; the row says the origin rests on the supplied record rather than a verified signature.- Moves
Hash, provenance, lineagefrom NOT_TESTED to PASS when it binds, names arepo_url, and has a fullcommit_sha. - Absent: NOT_TESTED.
Signature sidecars
A signature sidecar next to the artifact (<artifact>.sigstore.json,
.sigstore, .minisig, or .sig) that is not read as an OMS signature
(section 3a) is named in the row as present and not verified. It does not move
the row: without a bound manifest, the row stays NOT_TESTED.
Uploader checksum lists
Some repositories ship their uploader's own list of SHA-256 hashes. This is not an input you supply: the scan reads the lists the artifact carries and compares each entry with the bytes it hashed. A list is the uploader's claim, so the comparison can show consistency, never safety:
- A listed file whose bytes differ makes
Hash, provenance, lineagea LEAD (patternchecksum-mismatch, severity medium), unless the row is already a FAIL. The evidence names the file, the list and line, the hash listed, and the hash the scan computed. A list that no longer matches its file is what a swapped file, or a partial re-upload, leaves behind; it is not proof of either, and no acceptance clears it. - A Markdown file that differs (
.md, in any case) is named in the notes and not graded. No loader reads one, and a model card edited on the Hub after the list was written is the commonest disagreement in real repositories: 144 of the 338 measured on 1,652 repositories, and the only one in 108 of them, which as LEADs would have withheld each for good (false-positive-baseline.md). Every other file is graded, documentation in other formats included. - Agreement is noted, with the files that agree, and never moves the row: without a bound manifest or a verified signature the row stays NOT_TESTED. Whoever can change a file can change the list beside it, so a list that agrees says only that the files are the ones it lists.
- A listed file the scan does not hold is noted as not compared, since a
selected pull (
airlock pull --include) or a partial download leaves files out; with a bound provenance manifest that records a selection, the note says how many of them the selection left out. A listed file under a directory the scan does not read (.git/,.cache/) is noted the same way. - A rename is noted: a listed name the scan does not hold, whose hash is that of a file the scan holds under another name that no list gives.
- The list itself is part of the attested bytes in a directory, but no
list can hold its own hash: an entry naming the list it is in is noted and
not compared (
sha256sum * > SHA256SUMSwrites one). The note names every file no list covers, the lists included. - A list or a line that does not parse is named, never guessed at: the line numbers, or why the list was not read.
Which files are lists: SHA256SUMS, SHA256SUMS.txt, and
checksums.sha256, in any case, and a <file>.sha256 sidecar beside the
file it is named for. These are the names the most-downloaded repositories
that ship a list use (measured in
false-positive-baseline.md).
Other names are not read, because real repositories use them for lists of
something else: an older
release (checksums-v4.5.0.sha256, SHA256SUMS.v3), another repository's
files (SOURCE_SHA256SUMS), a staging area, or a .sha256 file for a file
that is not beside it. MD5 lists (checklist.chk, MD5SUMS) and JSON files
are not read either.
Where they are read:
- In a model directory scan, every list among the directory's files, at any
depth, from the scan's snapshot. Names resolve from the list's own
directory, as
sha256sum --checkresolves them when run there, and nowhere else. A list none of whose names is a file beside it is named and not compared: it describes another tree (a bundle, a staging area, the source model) or was written from another directory, and which one is a guess. A measured case:bundles/e2b/checksums/SHA256SUMSnamesREADME.md, which isbundles/e2b/README.md; resolved from the top, it would disagree with the repository's own model card. - In a single-file scan, the lists in the artifact's own directory, beside it. They are not among the attested bytes, so each is named with the SHA-256 of what was read, and only what they say of this file counts: an entry for another file is counted, not compared. A header-only scan hashes nothing, so it compares nothing and says so.
A list is read as GNU coreutils' sha256sum --check reads one (coreutils
9.12: split_3, bsd_split_3, filename_unescape, and digest_check in
src/cksum.c,
and the manual):
<hash> <name>(text mode) and<hash> *<name>(binary mode); the BSD reversed form<hash> <name>, which does not mix with the other in one list; and the tagged formSHA256 (<name>) = <hash>, or OpenSSL'sSHA256(<name>)= <hash>, whose name runs to the last)on the line.- A hash of exactly 64 hex digits, in either case. Leading blanks are skipped; every character of the name counts, blanks included.
- A line beginning with
\holds an escaped name, in which\\,\n, and\rare the only escapes. A line beginning with#is a comment, an empty line is skipped, and a trailing carriage return is dropped. - One difference:
sha256sumcuts a name at a NUL byte and opens the rest; Socair refuses that line and names it. - One addition: a
<file>.sha256whose whole content is one 64-digit hash is that file's hash, a formsha256sumdoes not read and 11 measured sidecars use.
A name that is absolute or holds .. is named as outside the directory and
not compared. Every read is bounded: 4 MiB and 50,000 lines a list, 16 MiB for
all the lists one scan reads; a list past a bound is named, never read in
part. TestParserAgreesWithSHA256SUM
(SOCAIR_TEST_SHA256SUM=<GNU sha256sum> go test ./internal/checks/provenance -run SHA256SUM -v)
holds the parser to GNU sha256sum --check on every parser case and 3,000
generated lists. The false-positive measurement on real repositories is in
false-positive-baseline.md.
3a. Publisher signatures, SOCAIR_PUBLISHER_KEYS and SOCAIR_PUBLISHER_ROOTS
When the artifact carries an OpenSSF Model Signing (OMS) signature, the scan
verifies it offline. For a directory the scan looks at its root for
model.sig (the model_signing default) and result.sigstore (the name
NVIDIA's NGC CLI gives a model's signature; section 3b). For a single file it
reads <file>.sig beside it. SOCAIR_OMS_SIGNATURE names another path,
inside the directory or outside it.
-
Only the directory's root is searched. A signature file deeper in the tree is a model file like any other, and a signature outside the directory is read only when
SOCAIR_OMS_SIGNATUREnames it. -
With both
model.sigandresult.sigstoreat the root, each an OMS signature, andSOCAIR_OMS_SIGNATUREnaming neither, the scan does not choose between them: the signature is named and not verified, and the manifest rules decide the row. Name the one to verify. A root file that is not an OMS bundle (a GPG or minisignmodel.sig) does not count. -
The root signature file that is not the one verified is not compared as a model file; it is named as not covered. The one verified cannot cover itself, and is left out too.
-
SOCAIR_PUBLISHER_KEYS: the publisher's EC public keys (P-256, P-384, P-521), a PEM file or a directory of them, for key-signed models. -
SOCAIR_PUBLISHER_ROOTS: CA certificates, a PEM file or a directory, for models signed with a certificate. The leaf must carry thedigitalSignaturekey usage or thecodeSigningextended key usage. Revocation is not checked.
The certificate chain is checked at the scan's time, as OMS v1.0 section 8.2
requires ("The leaf certificate MUST be within its validity period"). A
certificate bundle carries no trusted timestamp to check it at instead. A
chain holding a certificate that has expired, or is not yet valid, at the scan
time is named and not verified: it proves nothing about the files either way.
(Until check set tier1/0.9 the chain was checked at the leaf's own
notBefore, as model_signing 1.1.1 does, which every leaf passes;
sigstore/model-transparency#663
changed that in the reference implementation.)
There is no default trust (no system roots, no built-in keys, and no publisher's root, NVIDIA's included): a signature from a key you did not configure proves nothing about who made the model.
| Signature | Provenance row | publisher_signing_status |
|---|---|---|
| From a trusted key or root; every signed file the scan reads matches | PASS | verified (OMS, signer) |
| From a trusted signer; the signature, or a file, does not match | FAIL | invalid (OMS): what differs |
| From an untrusted signer, or pre-1.0 | the manifest rules decide; the signature is named | present, not verified |
| From a trusted root, with a certificate outside its validity at the scan time | the manifest rules decide; the signature is named | present, not verified |
| Two OMS signatures at a directory's root, neither named | the manifest rules decide; the signatures are named | present, not verified |
Keyless Sigstore, with socair-sigstore configured |
as above: verified, invalid, or not in the identity policy | as above, naming the signer's subject and issuer |
| Keyless Sigstore, no verifier configured | the manifest rules decide; the signature is named | present, not verified |
Keyless signatures (a Fulcio certificate and a Rekor log entry) are verified
by the optional socair-sigstore helper, a separate binary built from
tools/socair-sigstore, so the scanner's own build keeps its small dependency
set. Configure it with SOCAIR_SIGSTORE_VERIFIER, SOCAIR_SIGSTORE_TRUSTED_ROOT
(a Sigstore trusted_root.json), and SOCAIR_SIGSTORE_IDENTITIES (the signers
you accept, required); see tools/socair-sigstore/README.md. The helper
vouches for the signature; the scanner still binds the signed manifest to the
files.
What the verifier is given. Socair starts it with no arguments and writes
one request to its standard input: the bundle, the trust root's absolute
path, and the identity policy. It runs with an empty environment. Nothing of
Socair's reaches it: not HF_TOKEN, not a proxy setting, not GODEBUG. It
verifies offline from the request and opens no file but the trust root, so it
needs nothing more; the opt-in test against the real helper
(TestRealKeylessVerifier) verifies the reference vector this way. These are
the variables it, or a library it is built from (sigstore-go v1.3.0, Go
1.27), reads, and why none is passed:
| Variables | Read by | Why it is not passed |
|---|---|---|
HOME, and TMPDIR when there is no home |
sigstore-go's TUF client, for its cache (tuf.DefaultOptions) |
Only the online trust-root fetch uses it. The verifier reads the trust root from its file (root.NewTrustedRootFromPath) |
HTTP_PROXY, HTTPS_PROXY, NO_PROXY |
Go's HTTP client, which that fetch uses | The verifier makes no network request |
SSL_CERT_FILE, SSL_CERT_DIR |
Go's crypto/x509, loading the system's roots (root.go) |
Every chain is checked against the trust root's certificates, never the system's |
GODEBUG |
The Go runtime, crypto/x509, and crypto/rsa (GODEBUG) |
It can loosen what the verifier accepts (x509negativeserial=1, rsa1024min=0). The verifier runs with the defaults it was built with |
GOGC, GOMEMLIMIT, GOMAXPROCS, GOTRACEBACK |
The Go runtime | Memory, scheduling, and crash-output tuning; the defaults serve |
TZ, ZONEINFO |
Go's time package |
They change only how a time reads in a failure's text |
COSIGN_PASSWORD; GRPC_GO_LOG_*, SWAGGER_DEBUG, DEBUG, OTEL_*, GOPROTODEBUG |
Libraries linked into the helper: an encrypted private key's password, and logging, debug, and tracing switches | The verifier opens no private key and needs no logging or tracing |
No setting adds a variable. If a later verifier needs one, it is added to
keylessEnvKeys in internal/engine/publisher.go with its reason, and the
test that pins the list (TestKeylessVerifierGetsOnlyAllowedEnvironment)
names it; a credential is never added.
What Socair reads back. The verifier's answer is one JSON verdict of a few hundred bytes (at most 349 on the reference vectors and their tampered copies). Socair reads up to 64 KiB of it. A longer answer is refused whole, never parsed in part, and leaves the signature present, not verified: "the keyless verifier's answer is over 65536 bytes". The bound matters because a verdict's detail can quote the bundle, which comes with the artifact.
A verifier that does not answer leaves the signature present, not verified,
and the provenance row and the signing state say why: it could not be started
(named by its file name, with the cause), it did not answer within two
minutes, or it failed, with its exit status and the last 400 bytes of its
standard error. The verifier is handed the trust root's path, and its error
text or its verdict's detail can repeat it. The trust root is named by its
file name, and the verifier and its directory by their names, wherever they
stand in what the verifier wrote. Should a variable it is given ever hold an
absolute path, the path is named by the variable ($NAME).
A verified signature proves the files are the ones the key holder signed. It is a statement of origin and integrity, never of safety: the other rows still decide whether it is promoted.
A file the signed manifest lists is compared, unless it is download-tool
metadata the scan does not read (below). A file it does not list is left out
of the comparison, and named as not covered with the reason, only when the
signer excluded it (ignore_paths), when it is one of OMS's default
exclusions at the directory's top (.git, .gitattributes, .github,
.gitignore; OMS v1.0 section 6.2), or when it is the other root signature
file. Any other file the manifest does not list is a difference. A single file
checked against a signature over several files that lists it is not verified:
the other files are not in hand, so scan the directory.
Download-tool metadata in a signed directory
A directory scan does not read .cache/ or .huggingface/ directories, at
any depth: download tools keep their own bookkeeping there, a loader does not
read it, and it is not among the attested files (the report's out-of-scope
list names each one as excluded). hf download <repo> --local-dir <dir>
writes such metadata into <dir>: .cache/huggingface/ from huggingface_hub
0.24.0 on, .huggingface/ in 0.23.x, and nothing before 0.23.0. It holds a
.gitignore, and for each downloaded file a .metadata record of the commit,
the ETag, and the time it was written, with a .lock beside it; 2.2.0 adds a
CACHEDIR.TAG and the repo tree it listed.
model_signing 1.1.1 signs every file under the model directory except
.git/, .gitattributes, .github/, and .gitignore at its top, so a
directory signed after an hf download --local-dir lists that metadata. A
signed file under .cache/ or .huggingface/ is left out of the comparison:
the scan holds no bytes for it, so it is neither compared nor counted missing.
A PASS then says "every signed file the scan read matches" and names each such
directory, with the number of signed files under it, as signed but not
compared. It is the same whether the metadata is on disk, rewritten, or gone,
because the attested files are the same.
- Only those two directories, as the first directory on the path that the scan
skips, get this treatment. A changed, missing, or unsigned file anywhere
else is a difference, and so is a signed file under another directory the
scan skips (
.git/below the top,__pycache__/,.ipynb_checkpoints/). - The metadata is left out, not read and compared: every check reads the
scan's snapshot, which does not hold it, and re-running the same download
rewrites each
.metadatarecord with a new time while no model byte changes.model_signing verifyfails such a directory on that rewrite; comparing it would FAIL an unchanged model. - The reverse, a signature made before the download wrote its metadata, lists
none of it. The scan does not read the metadata, so it is no difference
either, and the PASS claims nothing more than "every signed file matches".
(
model_signing verifyfails that directory on the extra files unless they are ignored.)
Sources, read on 2026-10-14: huggingface_hub's _huggingface_dir at
v0.23.0,
v0.24.0,
and v2.2.0
(no _local_folder.py before v0.23.0);
the metadata record
and its rewrite when the ETag matches;
model_signing 1.1.1's default ignore paths
and the walk it signs.
Reproduced with the real tools: two small files fetched with hf download --local-dir (huggingface_hub 2.2.0 and 0.23.4) and signed with model_signing sign key 1.1.1, which lists the metadata and verifies the directory.
3b. NVIDIA NGC models
NVIDIA signs the models it publishes in the NGC catalog with OMS (NVIDIA's verification guide, announcement). The signature is a separate download from the model:
ngc registry model download-version <org>/<team>/<model>:<version> --dest <dir>
ngc registry model download-version-signature <org>/<team>/<model>:<version> --dest <dir>
Both write to <dir>/<model>_v<version>/ (NGC SDK 4.37.0, which names the
file result.sigstore), so with the same --dest the signature lands at the
model directory's root, where a directory scan finds it. NVIDIA's NIM guide
downloads it to a separate ./signatures/ tree instead: name it there with
SOCAIR_OMS_SIGNATURE, or move it to the model directory's root.
What an NGC signature is, read from nvidia/tao/dashcamnet:pruned_onnx_v1.0.5
on 2026-10-12:
- The
certificatemethod, with no transparency-log entry: the bundle's certificate chain is a leaf (O=NVIDIA, CN=NVIDIA Model Signing Issuer NGC 001, valid 2025-03-07 to 2027-03-07, key usagedigitalSignatureonly), an intermediate (CN=NVIDIA Model Signing ICA 001), and the root (CN=NVIDIA Model Signing Root CA), all P-384. - The
filesserialization with SHA-256 over every file of the model version, and noignore_paths. As the NGC SDK downloads it, the directory holds the signed files andresult.sigstore, nothing else, and it verifies as it is (model_signing1.1.1 verifies it too). A file added at the directory's top is a difference unless it is one of OMS's default exclusions.
Trusting NVIDIA's root
Socair ships no publisher's root, NVIDIA's included. To verify NGC signatures:
-
On a connected machine, download the certificate NVIDIA's guide names:
curl -fsSL -o nvidia-model-signing-root.pem \ https://api.ngc.nvidia.com/v2/catalog/models/public-keyDespite the URL, it is a certificate: the self-signed root
C=US, O=NVIDIA, CN=NVIDIA Model Signing Root CA. -
Check it before you trust it:
openssl x509 -in nvidia-model-signing-root.pem -noout -subject -issuer -dates -fingerprint -sha256The subject and issuer must both be
C=US, O=NVIDIA, CN=NVIDIA Model Signing Root CA, valid from 2025-03-07 to 2055-02-28. The SHA-256 fingerprint we read on 2026-10-12 was08:DC:02:65:CD:BE:C8:55:D5:3B:36:FA:49:F1:01:95:47:AE:D1:B1:11:87:6B:1F:76:1F:AE:BC:BF:C1:88:9CNVIDIA's documentation does not publish a fingerprint, so this value is ours, not NVIDIA's. Compare it with a copy fetched over a different network path, or confirmed with NVIDIA, before relying on it. If they differ, do not trust the file.
-
Carry it into the air gap and name it:
export SOCAIR_PUBLISHER_ROOTS=/etc/socair/publisher-roots # a directory of PEM files, or one file
A scan of an NGC model directory with result.sigstore at its root then
PASSes provenance on the signature, naming NVIDIA Model Signing Issuer NGC 001, or FAILs naming each file that differs. Without the root, the signature
is named and not judged.
- Trusting the root trusts every certificate NVIDIA issues under it, for any model. A verified signature says NVIDIA published these bytes; it says nothing about whether the model is safe to run.
- The leaf NVIDIA's NGC signatures carry today is valid until 2027-03-07. Because the chain is checked at the scan's time, a signature made with it is named and not verified after that date, until NVIDIA signs with a newer certificate.
Conformance
CI runs the OpenSSF Model Signing conformance suite
(ossf/model-signing-spec,
pinned at commit 91bf841e8ccf) in its verify-only mode against Socair's
verifier, through tools/oms-conformance, a harness that maps the suite's
adapter protocol onto internal/oms. The suite's historical and
cross-implementation bundles are an OCI artifact the pinned suite names by
digest; CI fetches it by that digest (tools/oms-conformance/fetch_fixtures.py).
At that commit, on 2026-10-12:
| Result | Cases |
|---|---|
| Passed | 44: every positive and negative key and certificate case, the suite's historical key and certificate bundles from releases v0.3.1 to v1.1.0, and its Go-signed interoperability bundles |
| Expected to fail | 2, below, listed with their reasons in tools/oms-conformance/xfail.txt |
| Skipped | 37 roundtrip cases, because Socair does not sign, and 9 sigstore cases, because keyless bundles are verified by tools/socair-sigstore, which the harness does not drive |
The two expected failures are decisions, not open bugs:
certificate-no-code-signing-eku_fail: the leaf has thedigitalSignaturekey usage and nocodeSigningextended key usage. OMS v1.0 does not require the extended usage,model_signing1.1.1 accepts the key usage alone, and NVIDIA's NGC leaf carries the key usage only, so requiring it would leave every NGC signature unverified.historical-v0.2.0-certificate: a pre-1.0 bundle (predicatehttps://model_signing/Digests/v0.1), which OMS v1.0 section 11.2 says a verifier MAY accept. Socair names it and does not verify it.
The suite makes the bundles for its verify tests with the adapter's own
signer, so the harness signs with internal/oms/omstest, Socair's test
signer. On bundles made by the reference implementation, model_signing
1.1.1, Socair's result is the same: 44 passed and the same 2 failed. The
reference implementation itself fails certificate-no-code-signing-eku_fail
and certificate-not-yet-valid_fail at that commit; Socair passes the second
because it checks the chain at the scan's time.
4. The acceptance record: review, accept, re-issue
An acceptance is the person who owns the gaps signing for them. It is what
lets a report with NOT_TESTED rows (and no FAIL or LEAD) cross the airlock as
authorized_with_conditions. It is signed by the acceptor, with their own key,
over the exact report they reviewed:
socair scan model.gguf > r.json # withheld: some rows NOT_TESTED
socair sign --key operator.key --report r.json # r.dsse.json, the report to review
# the acceptor reviews r.dsse.json, then:
socair accept --attestation r.dsse.json --key ciso.key --by "Jane Doe, CISO" \
--expires <RFC 3339 time> --store <store> [--rationale "change 4411"]
# r.acceptance.dsse.json
socair sign --key operator.key --attestation r.dsse.json \
--acceptance r.acceptance.dsse.json --store <store>
# r.conditional.dsse.json
- The acceptance (
https://socair.ai/acceptance/v1, DSSE, Ed25519) names the artifact's hash, the reviewed report's document hash, exactly its NOT_TESTED rows, the acceptor, and an expiry.--expiresmay not be later than the report'sheader.rescan_due: the scan time plus 90 days, unlessSOCAIR_RESCAN_DAYSset another number. acceptandsign --acceptanceread a store only from--store, not fromSOCAIR_STORE; without one, pass--trusted(and--acceptors) instead.- The re-issued attestation is the same report as
authorized_with_conditions, with the acceptance embedded (promotion_authorization.acceptance) and the reviewed document's hash. The report's acceptor, surfaces, and expiry must match the embedded acceptance, or it cannot be signed or verified. - Acceptor keys are their own trust list,
<store>/acceptor-keys/(socair airlock trust add --acceptor ciso.pub), separate from the keys that sign attestations, and a key may not be in both. An acceptance signed by the key that signed the report is refused: the operator does not accept their own gaps. airlock promoteverifies the acceptance against the acceptor keys and refuses one that is unsigned, untrusted, self-signed, for another report, or expired.socair verify --acceptors <dir>shows the same check.- A verifier that checks only the operator's signature (socair-verify, as in
the admission gate LLMKube is adding, with
allowConditions) admits the re-issued attestation as it admits any conditional one.
SOCAIR_ACCEPTED_BY and SOCAIR_ACCEPTANCE_EXPIRES still name an acceptor at
scan time, and the report says so, labelled unsigned. The airlock does not
promote an unsigned acceptance. SOCAIR_RESCAN_DAYS sets the report's
rescan_due (default 90).
A FAIL or a LEAD is never cleared by an acceptance, and Socair has no other path that clears one: it withholds promotion and needs a person's review outside Socair.
5. A signed reference feed, SOCAIR_FEED and SOCAIR_FEED_KEYS
A feed supplies known-bad hashes, reviewed chat templates, and canonical
tokenizers as a signed bundle; see feed.md. Its denylist joins
SOCAIR_DENYLIST, its tokenizer tables join SOCAIR_TOKENIZER_REFERENCE, and
its reviewed template texts join SOCAIR_TEMPLATE_REFERENCE.
6. Canonical tokenizer tables, SOCAIR_TOKENIZER_REFERENCE
A table file, a publisher's tokenizer.json, or a directory of them (*.json).
The tokenizer row compares the model's vocabulary, token by token, with the
table of its family; see feed.md for how a family is matched and
graded. A path that cannot be read stops the scan.
7. Reviewed chat templates, SOCAIR_TEMPLATE_REFERENCE
A reviewed template's text (.jinja), or a directory of them, each labelled by
its file name. The chat-template row renders each beside the artifact's
template on the same probe conversations: a template that renders like a
reviewed one on every standard probe but adds a content-conditional branch is
a LEAD; an identical render is noted; anything else gets no verdict. A local
reference never clears a lead; only a signed feed's templates.txt or the
embedded allowlist does. A path that cannot be read stops the scan.
8. A license policy, SOCAIR_LICENSE_POLICY
Every report names the license the artifact states, in its identity section
(artifact.license), whether or not a policy is set. The scan reads it from
the model card's front matter (license, license_name, license_link),
the LICENSE files at a directory's top level, and a GGUF's general.license
keys, and lists every statement it read and what each was identified as. A
LICENSE text is identified only when it is a license the catalogue
(internal/checks/license/catalogue.go) holds: a permissive license must be
its whole text with nothing added (a title and a copyright line aside), and a
model license must carry its title phrases. Anything else is reported as not
identified, with its first line, never guessed. Statements that name
different licenses are reported as a disagreement. A declared base model
(base_model, general.base_model.N) is listed too; when its publisher
releases every model under one license (Meta Llama, Google Gemma), a stated
license of another family is a disagreement.
Without a policy, none of this changes a row or the promotion state. With one, the report adds a License policy row:
# allowed licenses, one per line: a catalogue id, an SPDX id, or a model-card tag
apache-2.0
MIT
llama3.1
| What the artifact states | License policy row |
|---|---|
| One license, on the list | PASS, with the license's usage-policy obligations listed and not marked met |
| One license, not on the list | FAIL (license-not-allowed), naming where it is stated |
| Different licenses, one of them not on the list | LEAD (license-disagreement): which governs needs a person |
| Different licenses, all on the list | PASS, saying they disagree |
| A license on the list beside a statement the catalogue does not recognize | NOT_TESTED: the license is not established |
Nothing identified (no statement, only other, an unrecognized text, a single safetensors or pickle file) |
NOT_TESTED |
- A name the catalogue does not know, or a policy that allows nothing, stops the scan: a misspelled id would otherwise fail every model.
- The row is not legal advice. Usage-policy obligations (Llama's acceptable use policy and 700-million-user clause, Gemma's prohibited use policy, a non-commercial term) are listed in the notes, never marked complied with.
- A disagreement is common on real repos, and a quantizer's card often names a different license from the original (see false-positive-baseline.md), so it is a LEAD for review, never a FAIL.
- The report's
scope.reference_datanames the policy file by name and SHA-256, and its ids.
Catalogue ids are ScanCode LicenseDB keys where ScanCode has one, and
socair- keys otherwise:
| Id | License | Also matched as |
|---|---|---|
apache-2.0, mit, bsd-new, bsd-simplified |
Apache 2.0, MIT, BSD 3-Clause, BSD 2-Clause | SPDX ids; bsd-3-clause, bsd-2-clause |
cc-by-4.0, cc-by-sa-4.0, cc-by-nc-4.0, cc-by-nc-sa-4.0 |
Creative Commons 4.0 | SPDX ids |
openmdw-1.0 |
OpenMDW 1.0 | OpenMDW-1.0 |
llama-2-license-2023, socair-llama-3-license-2024, llama-3.1-license-2024, llama-3.2-license-2024, llama-3.3-license-2024, llama-4-cla-2025 |
Llama 2, 3, 3.1, 3.2, 3.3, 4 community licenses | llama2, llama3, llama3.1, llama3.2, llama3.3, llama4 |
socair-gemma-terms-of-use |
Gemma Terms of Use | gemma |
qwen-2024, tongyi-qianwen-2023, socair-qwen-research-2024 |
Qwen, Tongyi Qianwen, Qwen Research licenses | qwen, tongyi-qianwen, qwen-research |
deepseek-la-1.0 |
DeepSeek License Agreement 1.0 | deepseek |
socair-nvidia-open-model |
NVIDIA Open Model License | nvidia-open-model-license |
socair-mistral-research-0.1, socair-mistral-non-production-0.1 |
Mistral AI Research, Non-Production licenses | mrl, mnpl |
falcon-2-11b-1.0, socair-tii-falcon-license |
Falcon 2 11B TII License, TII Falcon License | falcon-llm-license |
bigscience-rail-1.0, bigscience-open-rail-m, bigcode-open-rail-m-v1, socair-creativeml-openrail-m, bigscience-open-rail-m2, socair-openrail |
RAIL licenses (BLOOM, BigScience, BigCode, CreativeML, CreativeML RAIL++, unnamed Open RAIL) | bigscience-bloom-rail-1.0, bigscience-openrail-m, bigcode-openrail-m, creativeml-openrail-m, openrail++, openrail |
moonshot-ai-modified-mit-2025, minimax-mit-variant-2025 |
Kimi and MiniMax modified MIT licenses (never identified as MIT) | |
socair-tencent-hunyuan-community, socair-glm-4 |
Tencent Hunyuan Community, GLM-4 licenses | tencent-hunyuan-community, glm-4 |
9. An intake policy, SOCAIR_INTAKE_POLICY
Your organization's intake rules, beside the license policy and independent of
it: the formats a model file may be in (identified from its bytes), publisher
allow and deny lists (read from the provenance manifest bound to the hash,
section 3), and publishers whose models need a verified publisher signature
(section 3a). It adds an Intake policy row that can only withhold; see
intake-policy.md. The report names the policy in
scope.intake_policy, by file name and hash.
What each input buys, in one table
| Input | Row it moves | Without it |
|---|---|---|
SOCAIR_REPO_MIRROR |
File inventory and payloads, for a single file | NOT_TESTED |
SOCAIR_DENYLIST, or a denylist in a feed |
Known-bad hash match | the row does not run; the report names it under Out of scope, and it is not a gap |
SOCAIR_PROVENANCE, or an OMS signature from a trusted publisher |
Hash, provenance, lineage | NOT_TESTED |
SOCAIR_TOKENIZER_REFERENCE, or tables in a feed |
Tokenizer config: a changed ordinary token becomes a LEAD | PASS on internal consistency only, and the row says no reference was compared |
SOCAIR_TEMPLATE_REFERENCE, or template texts in a feed |
Chat template: a reviewed template plus an added content-conditional branch becomes a LEAD | the rendered inventory only, and the row says no reviewed template text was compared |
SOCAIR_LICENSE_POLICY |
Adds the License policy row: PASS, FAIL, LEAD, or NOT_TESTED against your allowed list | no row; the license is still named in the identity section |
SOCAIR_INTAKE_POLICY (intake-policy.md) |
Adds the Intake policy row: PASS, FAIL, or NOT_TESTED against your formats, publisher lists, and required signatures; it can only withhold | no row |
A signed acceptance (socair accept) |
the promotion state | withheld on any gap |
SOCAIR_TIER2_HELPER and SOCAIR_TIER2_ENDPOINT (tier2.md) |
Adds Tier 2 measurements; one that raises a LEAD or FAIL adds a row that withholds, and one that finds nothing adds no row | No Tier 2 section; its checks are listed as not run, which is not a gap |
Falsification
Each input is proven to drive its row: TestHappyPathDropsOneInput removes the
provenance input and asserts the row drops to NOT_TESTED and the promotion
state drops to withheld. The same shape holds for the mirror: remove the input
and the row returns to NOT_TESTED. The denylist is different: remove it and
the Known-bad hash match row is not run at all, named under Out of scope with
its reason (TestUnsuppliedInputsAreNamedNotRun).
What this is not
- It does not verify a manifest's
signing_status: that field is the operator's claim, and the row says so. Publisher signatures are verified only as OMS bundles (section 3a), against keys and roots you configure. - It does not take an uploader's checksum list as provenance. A file that disagrees with one is a LEAD; agreement is noted and never moves the row, since whoever can change a file can change the list beside it. Lists in other hashes (MD5, SHA-1, SHA-512) are not read.
- It does not fetch anything. The manifest, the mirror, and the list are all
local, which is what makes the whole path air-gap safe. A
license_linkis listed, never followed. - It does not review a license. The License policy row checks the license the artifact states against your list; it is not legal advice, and it does not check that anyone meets the license's usage terms.