The airlock is the controlled junction between untrusted egress and the on-prem clean store. An artifact is pulled into staging, or ingested: a local path or an offline cache entry is resolved and logged where it lies, without a copy. Only a promotion moves bytes across into the clean store, and only an attestation signed by a key the store trusts, whose subject is the artifact's own hash, is a ticket to cross.
Everything here is offline except an explicit pull (the CLI command, or the
API's pull route), the one airlock operation that reaches out to the network.
Outside the airlock, socair attach and socair verify <oci-ref> call the
registry they name (oci.md), and the opt-in Tier 2 helper calls the
inference endpoint you configure (tier2.md). SOCAIR_EGRESS=deny
refuses pull and every registry request; Tier 2 runs only when you set
SOCAIR_TIER2_HELPER. socair airlock serve-hub listens for downloads and
fetches nothing.
A model that travels as an OCI artifact (ModelPack, KitOps, Docker Model
Runner) carries its attestation in the registry instead: socair attach
binds the attestation to the artifact's manifest digest and pushes it as a
referrer, and socair verify <oci-ref> checks it there. See
oci.md. Both reach the network, and SOCAIR_EGRESS=deny refuses
them as it refuses a pull.
Store layout
<store>/
incoming/<sha256>/<file> staging: pulled, not trusted
incoming/<digest>/<name>/ staging for a model directory (a whole repo)
incoming/<sha256>/provenance.json origin facts written by a pull
incoming/<sha256>/report.json an unsigned scan result (the console's scan writes it)
incoming/<sha256>/report.dsse.json a signed attestation (socair sign)
incoming/<sha256>/report.acceptance.dsse.json a signed acceptance (socair accept)
incoming/<sha256>/report.conditional.dsse.json the attestation re-issued with it (socair sign --acceptance)
incoming/<sha256>/derivation.dsse.json a converted output's signed derivation record (socair airlock convert)
clean/<sha256>/<file> the clean store, keyed by artifact hash
clean/<digest>/<name>/ a promoted model directory, keyed by manifest digest
clean/<sha256>/attestation.dsse.json the signed attestation that let it cross
clean/<sha256>/attestation.json its report document, for reading
clean/<sha256>/recheck.json the last recheck's result (socair airlock recheck)
trusted-keys/<key id>.pub the trust policy: keys whose signatures admit
acceptor-keys/<key id>.pub keys whose signed acceptances of untested rows are honoured
feed-record.json the newest signed feed the store has used (feed.md, "Rollback protection")
log.jsonl append-only activity log
.tmp/ in-progress copies, renamed into place when verified
A model directory's <digest> is its manifest digest (socair.modeldir/v1,
see docs/dev.md): the attestation subject a directory scan computes.
The clean store is content-addressed: the hash is the ticket, so promotion is idempotent and the store holds one identity per artifact. "In the clean store" does not mean "clean": a conditional promotion crosses too, so a listing must carry the attestation state, never assume a clean entry.
<store> resolves from --store, then SOCAIR_STORE, then ~/.socair/store
for the airlock commands and socair serve. socair verify, socair accept, and socair sign --acceptance read a store only from --store; they
ignore SOCAIR_STORE.
Commands
socair airlock init [<store>]
socair airlock pull --repo <org/name> --file <name> --sha256 <hash> [--revision main]
socair airlock pull --repo <org/name> (--revision <commit> | --sha256 <manifest digest>)
[--include <glob>]... [--exclude <glob>]...
socair airlock ingest --local <file or directory> [--scan]
socair airlock ingest --cache --repo <org/name> [--file <name>] [--revision main] [--scan]
socair airlock trust add <key.pub> [--name <issuer>]
socair airlock trust add --acceptor <key.pub>
socair airlock promote <artifact or directory> --attestation <attestation.dsse.json>
socair airlock convert <checkpoint> --attestation <attestation.dsse.json> --key <operator.key>
socair airlock recheck [--rescan]
socair airlock feed accept <feed dir> --keys <key.pub|dir> --reason <why>
socair airlock log [--verify [--expect-head <head>]]
socair airlock export --out <dir> [--key <operator.key>]
socair airlock serve-hub [--addr 127.0.0.1:8081] [--allow-public] [--authorized-only]
Every command also takes --store <path>.
-
pullfetches one artifact through controlled egress into staging, verifies it against the requested hash, records the pull, and writes a provenance manifest beside it.--fileis a plain file name at the repo's root; for a file in a subfolder, pull the repo with--include <path>. The manifest names the artifact's sha256 and the immutable commit the hub resolved the revision to (itsX-Repo-Commitheader), sorevision: mainis never the only record of where the bytes came from. Scan with that manifest to fill the provenance row and the identity section:SOCAIR_PROVENANCE=<store>/incoming/<sha256>/provenance.json socair scan <store>/incoming/<sha256>/<file>The scan reads only the manifest named this way, never one it finds beside the artifact, and counts it only if it names the scanned hash. A source that names no commit leaves
commit_shaempty and the row NOT_TESTED. The manifest is not signed: the scan counts any manifest it is pointed at that names the scanned hash, so it is only as trustworthy as whoever can write that file (for a pull, anyone who can writeincoming/). -
pullwithout--filefetches a whole repo as a model directory. It must be pinned:--revisiona full 40-hex commit, or--sha256the expected manifest digest, because a branch alone could deliver different bytes tomorrow. The revision is resolved to its commit, the file list is read from the hub at that commit, and every file is fetched at that commit and checked against the hub's own hash for it (the SHA-256 of an LFS file; for a small file, its git blob id, a SHA-1). A file that does not verify, a path that would leave the directory, two names differing only in case, or a file listed without a hash refuses the whole pull, and nothing is staged. Every request, including each page of the file list, goes through the egress policy. The tree lands atincoming/<digest>/<name>/with a provenance manifest bound to the digest; the command prints the scan invocation. -
The hashes a pull checks against come from the hub: the listing, for a repo pull, and for a single file the
--sha256you copy from the hub's file page. They catch a corrupted or substituted transfer, the CDN included. They do not catch a compromised hub, which would list the hashes of whatever it serves. -
--includeand--excludenarrow a whole-repo pull to the files the serving stack loads. Patterns match a file's path in the repo the way huggingface_hub's allow and ignore patterns do:*matches any run of characters, slashes included,?one character,[seq]and[!seq]one character in or not in the set, and a pattern ending in/everything under that directory. With--include, a file must match one; a file matching any--excludeis left out either way. Both repeat and take comma-separated lists. Left-out files are never fetched; the digest, and so the attestation subject, covers the kept files alone, and the provenance manifest (selection: the patterns and every fileleft_out) and the log name what was left out. The scan's provenance row says so too, so the signed report that travels with the bytes reads as a selection, not the whole repo. When the repo ships its uploader's checksum list (SHA256SUMS), the files it lists that the selection left out are noted as not compared, never flagged (provenance-bundle.md). A selection that keeps nothing, a bare or empty--include/--exclude, or a pattern with an unclosed[is refused before any download. A comma inside[...]belongs to the class. -
Pull only what you serve. A repository often ships the same weights in several formats: safetensors, a PyTorch pickle, ONNX and OpenVINO exports, TensorFlow's
tf_model.h5, Rust'srust_model.ot. A report covers every file pulled, so a format Socair does not read, or one you will not serve, is a gap that withholds the model. For a model served from safetensors (transformers, vLLM, TGI, sentence-transformers), this keeps the weights, every JSON config, the tokenizer files, chat templates, and the model card and license, which the License row reads:socair airlock pull --repo <org/name> --revision <commit> \ --include '*.safetensors,*.safetensors.index.json,*.json,*.jinja,*.txt,*.model,README.md,LICENSE*'On
sentence-transformers/all-MiniLM-L6-v2atc9745ed, the whole repo (30 files) was first withheld on five gaps: an h5 file, a TorchScript archive, OpenVINO weights, the known-bad hash, and the chat template. Socair now reads OpenVINO IR (#268, openvino.md) and Keras and HDF5 files (#269, keras.md): the repository's twoopenvino/IRs and itstf_model.h5PASS (false-positive-baseline.md), and the whole repo is withheld on one, the TorchScript archiverust_model.ot. The known-bad hash check needs your organization's list and the chat template check needs a template, which an embedding model does not ship, so neither runs: the report names both under Out of scope. This selection (12 files, 18 left out) is authorized: every row PASSes. A repository whose loader imports its own code needs its.pyfiles too: add--include '*.py', and the Remote code row judges them. -
Every copy of a model checks for room first: a pull from the listing's sizes (or a single file's declared length), a scan's snapshot in
SOCAIR_SCAN_TMP, and a promotion into the store. One that cannot fit is refused at the start, naming the volume, what it needs, and what is free. A file system that reports no free space (some network and FUSE mounts) can be waved through withSOCAIR_ROOM_CHECK=off. -
ingestresolves a local path (a file or a model directory) or an offline Hugging Face cache entry, prints the resolved path, and records it in the log. It does not copy the bytes into staging: a scan or a promotion reads them where they are. Without--file,--cacheresolves the whole snapshot; a branch name resolves through the cache'srefs/, as the hub cache stores it. With--scanit also runs the engine and prints the report, so an ingested artifact fills Sections 2 and 3 like any other. -
promotegates an artifact into the clean store on its attestation. For a directory attestation it copies every file, hashing while copying under the scan's rules, and renames the tree intoclean/<digest>/<name>/only when the recomputed manifest digest is the attested subject. Otherwise it refuses and names each file added, removed, or changed. A repeat promotion replaces the stored tree, so an altered one is restored. -
convertrebuilds a PyTorch checkpoint as safetensors without unpickling it, from what the Pickle opcode scan's grammar read. Its attestation must verify against the trust policy, carry no FAIL or LEAD, and PASS the Pickle row, and the bytes, copied and hashed, must be the attested subject. The output lands inincoming/<sha256>/beside a derivation record signed with--key(a key the store trusts) that binds it to the input. The output is a new artifact, staged unscanned: scan, sign, and promote it like any other; the input's attestation does not admit it. See derivation.md. -
recheckre-checks every model in the clean store against the current feed and check set, and flags the ones that would now be withheld. It moves, deletes, and re-signs nothing. See Rechecking the clean store. -
feed acceptmakes a verified, current feed the newest one the store has used, even one issued before the record, and logs it with the reason. It is the deliberate way back after a feed release is withdrawn, and the repair for a feed record that does not match the log. Every scan and recheck with the store refuses a feed issued before the record (feed.md, "Rollback protection"). -
logprints the activity log;log --verifychecks its hash chain. -
serve-hubserves the models the store admits, read-only, over the subset of the Hugging Face Hub API thathuggingface_hubdownloads through, so an inference server withHF_ENDPOINTset downloads them from the clean store. It serves only whatStore.Assessadmits now and no recheck has flagged, only the files each attestation lists, and nothing fromincoming/. See hub-endpoint.md.
The promotion gate
An artifact crosses only when its attestation validates and authorizes it:
authorizedandauthorized_with_conditionscross. A conditional crossing keeps its accepted surfaces in the stored attestation, so the amber state travels with the bytes.- A conditional attestation crosses only with the acceptor's signature. Its
embedded acceptance must verify against
<store>/acceptor-keys/, be signed by a different key than the attestation, be for this artifact and exactly its untested rows, and be current. An acceptance named at scan time (SOCAIR_ACCEPTED_BY) is unsigned and refused. Seedocs/provenance-bundle.mdfor the review, accept, re-issue flow. withheld, and any state other than the two above, refuses. A FAIL or a LEAD is never cleared here: no acceptance clears it, and Socair has no other path that does. It needs a person's review outside Socair, and the artifact does not cross. A model whoseconfig.jsonhas anauto_mapentry ends at a Remote code LEAD, so it cannot cross.- The bytes that cross must hash to the attestation that authorizes them. An artifact that does not match its ticket is refused, so nothing crosses on another artifact's attestation.
- A refusal is recorded (
action: refuse,outcome: refused) and returned as an error, never downgraded to a warning.
Controlled egress
- Every host a pull touches must be on the allowlist: the first request and
every redirect hop. An https-to-http redirect is refused. The default is the
Hugging Face hub and its CDN subdomains (
huggingface.co,hf.co, and any subdomain of either, which covers the<region>.cdn.hf.coand Xet hosts LFS downloads redirect to). An entry with a leading dot (.hf.co) matches subdomains only.SOCAIR_EGRESS=denyrefuses everything regardless of the allowlist. - Every pull has a stall budget: connecting, receiving headers, and each gap between body reads must finish within it. It does not cap the whole transfer, so a multi-GB model that keeps arriving completes, while a blocked or stalled egress returns an actionable error within the budget instead of hanging.
- The hash, repo id, revision, and file name are checked for shape before any path is built, and resolved paths must stay under the store or cache root.
- A denied pull names the host and says how to allow it.
HF_TOKEN, when set, is sent as a bearer token to the endpoint's own host and port only, and only over https (or to a loopback endpoint). It is stripped on a redirect to any other host (net/http alone would keep it for the same host name at another port and for any subdomain), and it never appears in an error or the log. A failed or refused redirect is recorded without its query string, since a CDN target is a signed URL. A gated repo answers an anonymous request with 401 and masks its file hashes in the listing; both refusals nameHF_TOKEN. Pulls through the HTTP API never carry the token, because the API has no auth.- The provenance manifest records origin facts (artifact hash, repo, revision, resolved commit). It never asserts a signing status; an unsigned upstream stays unsigned.
Environment:
SOCAIR_STORE: default store root.SOCAIR_EGRESS:denyfor a hard stop, anything else follows the allowlist.SOCAIR_HF_ENDPOINT: base URL, for a mirror or a test server. Its host is added to the allowlist.SOCAIR_EGRESS_ALLOW: comma-separated extra hosts, such as a mirror's redirect targets. A leading dot matches subdomains.SOCAIR_PULL_TIMEOUT: stall budget, a Go duration such as30s.HF_TOKEN: a Hugging Face access token, for gated and private repos.SOCAIR_ROOM_CHECK:offskips the free-space check before large copies.SOCAIR_SCAN_TMP: where a scan writes its snapshot, a full copy of the model; put it on a volume with room (the default is the system temp dir).SOCAIR_HF_CACHEorHF_HOME: the offline cache root foringest --cache.
Staging evidence and stages
The four report* files are the names socair scan, sign, accept, and
sign --acceptance already write, so running the CLI against
incoming/<id>/report.json produces exactly what the console reads. Only the
console's scan writes report.json; ingest --scan prints its report and does
not. None of them changes what promote accepts.
A model's stage (staged, scanned, ready, needs-acceptance, blocked,
approved, acceptance-expired, does-not-verify, flagged) is derived from
these files by Store.Assess, the verification promote itself uses. A staging
entry's stage comes from the newest evidence file by modification time. A
clean entry whose attestation no longer verifies is does-not-verify. A clean
entry whose recheck.json flags it is flagged (see Rechecking the clean
store). A staged envelope that does not verify
offers a rescan: the newer report.json supersedes it. An entry whose
report or attestation has a FAIL or a LEAD is blocked, signed or not, and
offers no next step: no signature or acceptance moves it toward promotion, and
it needs a person's review outside Socair. Signing its report still files what
the scan found.
promote leaves the staged copy in incoming/; nothing on the gate path
deletes. The list omits a staging entry whose id is approved in clean, or was
approved and then flagged by a recheck, and keeps it when the clean entry's
acceptance expired or it does not verify, so it can be re-scanned and
re-accepted.
Because a single-file pull is staged flat beside these files, pull refuses
an artifact file named like one of them (provenance.json, the four report*
names, attestation.json, attestation.dsse.json, recheck.json,
derivation.dsse.json). A whole repo is staged under its own name, so only
the repo name is checked.
The console reads attestations and does not re-hash model bytes when it shows
them. Re-running socair airlock promote re-verifies the bytes.
Rechecking the clean store
socair airlock recheck [--rescan] [--store <path>]
A promoted model's attestation reflects the reference data and the check set
of its scan. A hash added to a known-bad list next month, or a check that
grades differently in a newer socair, does not touch it. recheck re-examines
every model in clean/ against what a scan would use now, and flags the ones
that would now be withheld.
It reads the inputs a scan reads: SOCAIR_FEED with SOCAIR_FEED_KEYS,
SOCAIR_DENYLIST, and for a rescan the rest of the scan's environment (see
feed.md). A configured feed that does not verify, or has expired,
stops the recheck before anything is written, as it stops a scan. So does a
feed issued before the newest one the store has used, and that refusal is
logged (feed.md, "Rollback protection"). A feed in the last third
of its validity is used, with a warning on stderr that names when it expires.
Two levels run for each model:
- Known-bad match, always. The entry's hash, and for a model directory
every file hash its verified attestation lists, are matched against the
signed feed's denylist and the local
SOCAIR_DENYLIST. No model bytes are read. A match flags the model. - Rescan, when due. When the attestation's
scope.check_set_versionis not this socair's check set, or for every model with--rescan, the engine scans the clean bytes again and compares its rows with the attestation's. The scan takes a snapshot and checks for room first, like any scan (SOCAIR_SCAN_TMP). The model is flagged when the rescan would withhold it: a FAIL or LEAD row, a NOT_TESTED row that the attestation's signed acceptance does not cover, or clean bytes that no longer hash to the attested subject. The flag names each row that changed, with its status before and after.
A rescan reads its inputs from the environment, like any scan, plus the
staged copy's provenance.json when there is one (the engine counts it only
if it names these bytes). Give a rescan the inputs the original scan had: the
repository mirror, the provenance manifest, publisher keys, and the denylist.
A row the attestation PASSed that the rescan cannot test is a new gap, and it
flags the model, because a scan run now would be withheld on it.
What it writes. Nothing is moved, deleted, or re-signed; signing stays
offline. Each clean entry gets clean/<id>/recheck.json, written to a temp
file and renamed into place, and the log gets one recheck entry per model.
The entry names the feed (issuer, version, issue and expiry times, signing
key), the known-bad lists, the check set and the attested one, and the
outcome:
flagged: the model would now be withheld, or an earlier flag stands.unchanged: every level that was due ran and found nothing that would withhold it.incomplete: nothing was flagged, but a level could not run: a configured local list could not be read, a due rescan failed, or the attestation does not verify. It is not a pass. With no known-bad list configured at all, the known-bad level does not run and is not a gap, as at scan time: the reason says so.
The command prints one line per model, and exits non-zero when any model is flagged or incomplete, so a scheduled run surfaces it.
{"format":"socair.recheck/v1","id":"<64-hex>","name":"model.safetensors",
"checked_utc":"2026-10-12T19:49:22Z","tool_version":"socair dev",
"check_set_version":"tier1/0.8","attested_check_set_version":"tier1/0.8",
"attestation_sha256":"<sha256 of attestation.dsse.json>","attestation":"verifies",
"feed":{"issuer":"Example Intel","version":"2026.10.2","issued":"...","expires":"...","key_id":"..."},
"known_bad":{"checked":true,"lists":["feed Example Intel 2026.10.2","denylist /etc/socair/deny.txt"],"hashes":1,
"matches":[{"sha256":"<64-hex>","lists":["trojaned community upload (feed Example Intel 2026.10.2)"]}]},
"rescan":{"due":false,"why":"not due: the attestation was scanned under this check set, tier1/0.8","ran":false,"would_withhold":false},
"flags":[{"kind":"known-bad","reason":"its hash (...) is on a known-bad list: ...","since":"2026-10-12T19:49:22Z"}],
"outcome":"flagged","reason":"its hash (...) is on a known-bad list: ..."}
A rescan also records the rescan's check set, reference data, and promotion
state, the rows that changed (changed, each was and now, absent for a
row one side lacks), and the rows that would withhold it (withholding).
cleared lists the earlier flags this recheck re-examined and found no longer
hold.
In listings. A flagged model's stage is flagged, derived from
recheck.json like every stage, so the API, the console, and an inventory
export show it. It is still in clean/, and every listing says so: the
console lists flagged models first, in red; an export lists them under
other, never as approved; the staged copy of a model that was approved and
is now flagged is not offered for promotion again. A flag outranks approved, acceptance-expired, and
does-not-verify, and keeps their reason beside its own. A recheck.json
that does not parse, is for another entry, or contradicts itself shows as
flagged too: a flag that cannot be read is not a cleared one. The next recheck
replaces it and says so.
How a flag clears. Only a later recheck that re-examines what the flag
rests on, and finds it no longer holds, clears it. A known-bad flag needs every
hash matched against every configured list, for example with a newer feed that
dropped the entry. A rescan flag needs a rescan that ran. A recheck that
covers less carries the flag forward and says why, so a run with no list
configured never clears one. The clearing recheck names the flag it cleared,
in recheck.json and in the log. Socair has no other way to clear a flag.
And the gate. Store.Assess and promote verify an attestation; they do
not read recheck.json. Promoting a flagged model's bytes again is admitted
on a valid ticket (it restores a copy that was deleted or altered) and leaves
the flag in place, even with a new attestation; the promotion's log entry says
the entry stays flagged. What to do with a flagged model, such as removing it
from clean/ or re-scanning and re-signing it, is the operator's decision,
made outside Socair.
Limits.
recheck.jsonis evidence, not a signature: whoever can writeclean/can delete it, which clears the flag from listings. The log entry stays, under the hash chain (see Activity log).- The known-bad match trusts the file hashes the attestation lists, which
promoteverified when the bytes crossed.--rescanhashes the bytes again. - A recheck runs only when someone runs it. The Helm chart does not schedule it; running it on a schedule there is a later step.
Export and inventory
socair airlock export --out <dir> [--key <operator.key>] [--store <path>]
socair inventory verify <dir> --trusted <key.pub|dir> [--allow-unsigned]
export writes a dated snapshot of the store for people who do not run the
airlock. It never contains model bytes or keys, refuses a non-empty --out,
and refuses a store whose log chain is broken.
<dir>/
index.html approved list and the other entries; snapshot time and log head
models/<id>/report.html the HTML report
models/<id>/attestation.dsse.json, attestation.json byte-identical to clean/<id>/
log.jsonl, log-head.txt the activity log and its chain head
inventory.json the inventory statement
inventory.dsse.json its DSSE envelope, only with --key
The statement is an in-toto Statement v1, predicate type
https://socair.ai/inventory/v1, one subject per approved model. Its
predicate holds generated_utc, log_head, tool_version, models (id,
name, promotion state, issuer, signer key id, attestation_sha256, and for a
conditional entry the accepted surfaces and expiry), and other: the
non-approved entries, each with its stage and reason. That includes a clean
entry that does not verify, whose acceptance expired, or that a recheck
flagged. Without --key the
snapshot is unsigned and index.html says so.
inventory verify fails, with the reason, unless all of these hold:
- The envelope verifies against
--trusted, and the predicate type isinventory/v1. An unsigned snapshot fails unless--allow-unsigned, which checks the contents only. - The statement's subjects equal its models, and every listed attestation
file is present and hashes to
attestation_sha256. - Every attestation verifies against
--trustedfor its listed id, and itspromotion_state,signer_key_id, and (for a conditional entry) accepted surfaces and expiry match the entry. A readableattestation.jsonbeside it, when present, must decode as a report equal to the envelope's document. log.jsonlverifies as a hash chain tolog_head, which must be set when models are listed.
It prints the signer key, or UNSIGNED. Verification proves the snapshot is
the one that was exported, not that the store is unchanged since: record the
log head somewhere the box cannot rewrite, as for log --verify.
The console's export button (POST /api/airlock/export) returns the same
snapshot unsigned. To sign it, run the command above with --key.
Signing and the trust policy
An attestation is an in-toto Statement v1 in a DSSE envelope, signed with
Ed25519: subject the artifact's SHA-256, predicate type
https://socair.ai/attestation/v1, predicate the report document. It verifies
offline; there is no transparency log or certificate authority to reach.
socair key gen --out operator --issuer "Acme ML Platform" # operator.key (0600), operator.pub
socair scan model.gguf > report.json
socair sign --key operator.key --report report.json # report.dsse.json
socair verify report.dsse.json --trusted operator.pub --artifact model.gguf
socair airlock trust add operator.pub [--name "Acme ML Platform"]
socair airlock promote model.gguf --attestation report.dsse.json
The last step succeeds only for an authorized report. A scan with no other
inputs is usually withheld on NOT_TESTED rows (provenance, and for a single
file the repository inventory; see
provenance-bundle.md), and promote refuses it until
those rows PASS with the inputs they need, or are accepted: the acceptor signs
with socair accept, the operator re-issues with socair sign --acceptance,
and the re-issued attestation is promoted.
Which socair verifies it. Verify an attestation with the Socair version
that signed it, or a newer one. A verifier accepts only fields it knows, so a
report signed by a newer socair, which may carry a field added since, is
refused by an older one, with an error that says so. The admission module
socair-verify reads only the fields it checks, so this does not apply to
it. Until 0.1.0 the report format can gain fields; after it, a new field
will come with a way for an older verifier to tell a newer format from a
broken one (#250).
Who issued it. The issuer is whoever signs. key gen --issuer records a
name in the key files, on a Socair-Issuer: line outside the PEM block, which
PEM parsers (OpenSSL included) ignore. sign writes that name into the
report's issuer section, under the signature. The trusted copy of a key in
trusted-keys/ carries the store's name for it. trust add keeps the key
file's name, or --name sets your own. promote and verify refuse an
attestation whose claimed issuer contradicts that name, and log or show the
issuer as confirmed. A key the store has not named shows its issuer as
"claimed": anyone can name a key "Defilan Technologies", so when you trust a
key, check the name it carries.
promote checks, in order: a signature by a key in trusted-keys/ over the
DSSE encoding; the statement and predicate types; that the subject digest is
the document's artifact hash; the recorded document hash; that the document
validates, including its promotion state against its checks; that the state
admits promotion; and, while copying, that the bytes hash to the subject.
Any failure is a logged refusal. A bare report is not a ticket.
A repeat promotion re-copies and re-verifies the artifact, so a clean copy that
was deleted or altered is restored. An artifact may not be named
attestation.json or attestation.dsse.json.
A private key file readable by other users is refused. Keep it off the airlock box if signing happens elsewhere; the store only needs the public key.
Activity log
One JSON object per line, append-only. The log is evidence, not state: nothing
reads it to decide a promotion. Its feed entries are the one place it is read
back: they witness the store's feed record, and a scan with a feed stops when
the two disagree (feed.md, "Where the record lives").
{"ts":"2026-10-09T17:02:38.40046Z","action":"promote","outcome":"conditional","source":"airlock","sha256":"8aaff9f1...cb933953","detail":"authorized with conditions accepted by Jane Doe, CISO (signed acceptance, acceptor key 1e2c157cdc14e161) on 4 surface(s) until 2027-01-07T17:02:38Z, attestation issued by Acme ML Platform, signed by fcf1dc921efb204f","prev":"1f6c460b...163f5d19"}
Actions are pull, ingest, trust, promote, refuse, recheck,
convert, and feed. Outcomes are ok, conditional, refused, for a
recheck flagged, unchanged, incomplete, and for a feed accepted (an
operator's feed accept). A convert entry carries the output's hash when it
is ok and the input's when it is refused. A feed entry carries the feed
it is about in feed (issuer, version, issue and expiry times, key id,
statement hash), and the statement hash in sha256. An entry caused through
the HTTP API also carries "actor":"local-operator"; the CLI records no
actor.
Hash chain
Each entry's prev is the sha256 of the previous line's exact bytes; the
first entry's is 64 zeros. On Unix (every release platform), appends take an
exclusive flock, so concurrent writers never fork the chain; elsewhere the
lock holds only within one process.
socair airlock log --verify
socair airlock log --verify --expect-head <head>
--verify walks the chain and exits non-zero at the first line that does not
follow: an edited, deleted, inserted, or reordered entry, or a line that is not
an entry. On success it prints the head, the hash of the last line.
The chain is unkeyed SHA-256, so it shows careless edits, not a deliberate
rewrite. Whoever can write log.jsonl can rewrite any entry and recompute
every link after it, and an edit to the last line leaves nothing to break. A
chain also cannot see lines cut off its end: what remains is still a valid
chain. Only a head recorded off the box catches a rewrite: record it
somewhere the airlock box cannot rewrite (a ticket, a change record, a second
machine), and pass it as --expect-head later. Verification then fails unless
that exact line, and so every line before it, is still in the log. Entries
after the recorded head are covered only once you record a later head. Signed
periodic checkpoints would make this automatic and are not built yet.
Entries written before chaining have no prev. They are counted and reported
as not covered; the chain starts from the last of them.
Offline and air-gapped sites
- A local artifact path is ingested directly; there is no network call.
- An offline Hugging Face cache is resolved by repo, revision, and file. The
layout is
<cache>/models--<org>--<name>/snapshots/<rev>/<file>. - A missing path or a cache miss is a clean, named error, never a silently empty report.
Testing
The airlock tests are hermetic: an httptest server stands in for the hub, the
store is a temp directory, and artifacts are generated fixtures. The one real
egress test is gated:
SOCAIR_TEST_EGRESS=1 go test ./internal/airlock -run RealEgress -v
What this is not
- A pull verifies the artifact hash, not a publisher signature.
- An operator signature says the operator ran the checks and stands behind the report. It is not a Defilan signature.
- The clean store is a directory of bytes and attestations, not an admission
gate for a serving stack.
serve-hubhands its admitted models to inference servers, but it cannot stop one from fetching a model elsewhere. On Kubernetes, LLMKube is adding an admission gate that refuses to serve a model without an admitted attestation. For the end-to-end deployment, see intake-host.md.