Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ jobs:
run: go vet ./...
- name: build
run: go build ./...
# The browser preview (cmd/socair-preview) builds only for js/wasm, so
# the native vet and build above skip it.
- name: preview (js/wasm)
run: |
GOOS=js GOARCH=wasm go vet ./cmd/socair-preview
GOOS=js GOARCH=wasm go build -o /dev/null ./cmd/socair-preview
- name: test (race detector)
run: go test -race ./...

Expand Down Expand Up @@ -283,3 +289,8 @@ jobs:
run: npm run test
- name: build
run: npm run build
# The preview's page modules, against a fake hub: no network, no
# dependencies.
- name: preview page modules
working-directory: tools/socair-preview
run: node --test preview.test.mjs
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ jobs:
subject-path: |
dist/socair_*
dist/socair-sigstore_*
dist/socair-preview_*
# Socair's own SBOM: the Go modules linked into each release binary,
# read from the binaries' build information. It and the attestations
# file go in meta/, not dist/, so the subject globs never match them.
Expand All @@ -89,6 +90,7 @@ jobs:
subject-path: |
dist/socair_*
dist/socair-sigstore_*
dist/socair-preview_*
sbom-path: sbom.cdx.json
# The attestations as a release asset too: verifiers that read release
# assets (OpenSSF Scorecard, `gh attestation verify --bundle` offline)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ that run the model on your own GPUs, is built here in the open.
| [oci.md](docs/oci.md) | Attestations on OCI model artifacts: attach and verify, the binding to the manifest digest, which layouts are matched, cosign and Kyverno |
| [admission.md](docs/admission.md) | Kyverno policies that refuse a model pod without an authorized binding: what they check and what they do not, KServe, image volumes, the policy-controller gap |
| [intake-host.md](docs/intake-host.md) | Running Socair as the model intake host for an on-prem GPU cluster |
| [preview.md](docs/preview.md) | The browser preview at socair.ai/try: the checks on a Hugging Face model's headers before you download it, and what a preview is not |
| [kubernetes.md](docs/kubernetes.md) | The Helm chart: the airlock and the wizard in a cluster, who can reach them, and handing promoted models to LLMKube |
| [provenance-bundle.md](docs/provenance-bundle.md) | The inputs that move each row: mirrors, denylists, provenance, signatures, a license policy, acceptances |
| [intake-policy.md](docs/intake-policy.md) | An intake policy that can only withhold: allowed formats, publisher allow and deny lists, required publisher signatures |
Expand Down
83 changes: 83 additions & 0 deletions cmd/socair-preview/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//go:build js && wasm

// Command socair-preview is Socair's engine built for a browser, for the
// preview at socair.ai/try (docs/preview.md). The page mounts a Hugging Face
// repo as a read-only filesystem whose reads are HTTP range requests
// (tools/socair-preview/hffs.js) and calls:
//
// socairPreview(dir, sourceJSON, hashesJSON) // the repo as a model directory
// socairPreviewGGUF(path, sourceJSON, sha256) // one GGUF's own metadata
//
// Each returns a Promise for an engine.Preview as JSON. A preview is never a
// report: this command has no signing, no promotion, and no renderer.
package main

import (
"encoding/json"
"errors"
"syscall/js"

"github.com/defilantech/socair/internal/engine"
)

func main() {
js.Global().Set("socairPreview", js.FuncOf(func(_ js.Value, args []js.Value) any {
return promise(func() (*engine.Preview, error) {
if len(args) != 3 {
return nil, errors.New("socairPreview(dir, sourceJSON, hashesJSON)")
}
src, err := source(args[1].String())
if err != nil {
return nil, err
}
var hashes map[string]string
if err := json.Unmarshal([]byte(args[2].String()), &hashes); err != nil {
return nil, err
}
return engine.PreviewDir(args[0].String(), src, hashes)
})
}))
js.Global().Set("socairPreviewGGUF", js.FuncOf(func(_ js.Value, args []js.Value) any {
return promise(func() (*engine.Preview, error) {
if len(args) != 3 {
return nil, errors.New("socairPreviewGGUF(path, sourceJSON, sha256)")
}
src, err := source(args[1].String())
if err != nil {
return nil, err
}
return engine.PreviewGGUF(args[0].String(), src, args[2].String())
})
}))
js.Global().Set("socairVersion", engine.Version)
select {}
}

func source(s string) (engine.PreviewSource, error) {
var src engine.PreviewSource
err := json.Unmarshal([]byte(s), &src)
return src, err
}

// promise runs f off the event loop, since it reads through the page's
// filesystem, and resolves to its preview as JSON.
func promise(f func() (*engine.Preview, error)) js.Value {
var run js.Func
run = js.FuncOf(func(_ js.Value, p []js.Value) any {
resolve, reject := p[0], p[1]
go func() {
defer run.Release()
pv, err := f()
if err == nil {
var b []byte
if b, err = json.Marshal(pv); err == nil {
resolve.Invoke(string(b))
return
}
}
reject.Invoke(err.Error())
}()
return nil
})
return js.Global().Get("Promise").New(run)
}
1 change: 1 addition & 0 deletions docs/check-set.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ own suite, suite version, and probe-set digest ([tier2.md](tier2.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). |
| `tier1/0.11` | `afdc3469996cb8a96360828a31deec218b904b163f27b9bc807e00b1688f83d5` | 2026-10-19 | No rule change: the Pickle opcode scan's looks-for text becomes the exported constant `pickle.LooksFor`, so a preview's NOT_TESTED Pickle row ([preview.md](preview.md)) states it word for word. A scan's rows are unchanged. |
| `tier1/0.10` | `b7a105c69b51c5acf6c9d23bd9b9b1e209d522a341e759bca4132ddefea38c5c` | 2026-10-14 | A check whose input was not supplied is named, not run. Two rows no longer grade NOT_TESTED for want of an input, and instead are listed in the report's `out_of_scope.not_run` with the reason, as the Tier 2 checks are: Known-bad hash match runs only when a known-bad list is supplied (`SOCAIR_DENYLIST`, or a verified feed that carries one; a list the operator named that is empty or could not be read still gives a NOT_TESTED row), and Chat template runs only when the artifact carries a template (a GGUF's `tokenizer.chat_template`; a directory's `tokenizer_config.json`, `chat_template.json`, or `.jinja` file; a template file that could not be read still gives a row). Neither withholds promotion when its input is absent, as the License policy row does not without a policy: a clean model pulled through the airlock with its provenance is authorized without an acceptance. There is no standing public list of malicious model files to default to, and a model with no template (a base, embedding, or reranker model) has none to check; a template a serving stack supplies is outside the artifact. `report.Validate` refuses a check named both as a row and as not run. The benchmark's outcomes are unchanged: its known-bad case is scanned with a list. |
| `tier1/0.10` | `4faab69096968b98ea7c2ec0bec208e0c604b8196cb3b7b3f4d1c01acec79843` | 2026-10-14 | No rule change: the union of Keras models (#269, the row below) and OpenVINO IR models (#268, the two rows under it, merged as `c3c747a`), in one tree. The rule roots are both sides' (`internal/openvino`, `internal/hdf5`, and `internal/keras`). They meet in the format dispatch: a single file is read as a Keras model or other HDF5 file by its bytes first, then as an IR's XML, and the pickle row runs on neither (on an HDF5 file, only when it has a user block); a directory reads each `.h5`, `.hdf5`, or `.keras` whose bytes are one, and each IR `.xml` with its `.bin`. The Keras rules use the structure check's shared `grade` type under the name #268 gave it, and the notes for a format the scanner does not parse name both formats, which moves the fingerprint and no verdict. Neither side's rules change. |
| `tier1/0.10` | `114be0ac58f99e38f84c1a7b7b2b3a3a27d473ddfda5f3d6e6152e1ca9800123` | 2026-10-14 | Keras models (#269), made on `main` at `e0df1d1`, the tree under the OpenVINO IR rows below: two new readers, `internal/hdf5` (HDF5 File Format Specification 4.0 at HDF5 2.2.0, stdlib only, bounded, reading as the library does where the two differ) and `internal/keras` (a `.keras` archive's members, and configs decoded as `checks/pyjson` decodes them), with an embedded registry of the class names keras 3.15.1 and tf_keras 2.21.0 register (`keras-classes.txt`, written by `internal/keras/classgen`). The Format and structure and File inventory rows read an HDF5 file (`.h5`, `.hdf5`, transformers' `tf_model.h5`) or a `.keras` archive, recognised by its bytes, on its own or in a model directory, where it was NOT_TESTED (".h5 is not a container this scanner parses"); a KerasHub preset's root JSON that is a Keras serialized object is checked by the config rules. Format and structure: FAIL `keras-lambda` for marshalled bytecode (a field whose `<field>_type` is `"lambda"`, under any class; a legacy Lambda whose function is a list; a `__lambda__` object; CVE-2024-3660, CVE-2025-9905); FAIL `keras-pickle` for a `TorchModuleWrapper`'s pickled module; FAIL `keras-dangerous-reference` for a `NON_MODELING_APIS` name under a `keras` module, or a module and name the pickle check FAILs (CVE-2025-1550, CVE-2025-8747, CVE-2025-9906); LEAD `keras-name-path` for a name with a `..` component (CVE-2026-12479); NOT_TESTED, named, for a class or function from outside Keras, tf-keras, TensorFlow, KerasHub, KerasCV, and KerasNLP, a legacy class name Keras does not register, a function named with no module, a legacy Lambda's function by name, a `TFSMLayer` path, or a lookup layer's vocabulary path; KerasHub, KerasCV, and KerasNLP classes are named, not judged. HDF5: FAIL `tensor-layout` for storage, or the bytes a shape needs, past the end-of-file address, overlapping storage or metadata, a chunk off its grid, and bytes after the HDF5 data; FAIL `tensor-size` for contiguous, compact, or unfiltered chunk storage of another size than its type and shape need; FAIL `external-data-escape` for external storage or an external link (CVE-2026-1669, CVE-2026-9335); LEAD `hdf5-soft-link` and `hdf5-virtual-dataset` (CVE-2026-12480); NOT_TESTED for chunks never written, filtered chunks, chunk indexes the reader does not read, a user block, a truncated file, and every structure the reader names as unread. Archive: LEAD `keras-archive-entry` for a member Keras does not read or a repeated name, LEAD `archive-path-escape` for a name with a root, drive, or `..` (CVE-2026-11816); NOT_TESTED for `model.weights.npz` or compressed weights. File inventory scans the configs' keys and string values, `metadata.json`, string attributes (array elements of 64 bytes or more), object comments, an archive's comments, assets, and unread members, and a user block, with the existing payload patterns. The Pickle opcode scan no longer runs on a single Keras or HDF5 file, except one with a user block. New patterns: `keras-lambda`, `keras-pickle`, `keras-dangerous-reference` (critical), `archive-path-escape`, `keras-archive-entry`, `keras-name-path` (high), `hdf5-soft-link`, `hdf5-virtual-dataset` (medium). Outside the rules, the Format and structure PASS statement gains its Keras clause. Measured on 30 real files (20 HDF5, 10 archives) from 22 repositories: the reader reads exactly what h5py reads; 25 PASS, 4 NOT_TESTED naming code outside the file, 1 FAIL (a real Lambda's bytecode), no container finding; see [false-positive-baseline.md](false-positive-baseline.md) and [keras.md](keras.md). Benchmark: seven cases (six detected, one withheld as a gap) and three controls ([detection-benchmark.md](detection-benchmark.md)). |
Expand Down
109 changes: 109 additions & 0 deletions docs/preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# The browser preview

The preview runs Socair's checks on a model where it sits on Hugging Face,
before anyone downloads it. The engine is compiled to WebAssembly
(`cmd/socair-preview`) and runs in the visitor's browser. The site serves it
at socair.ai/try once a release carries the preview's files. It reads each file through HTTP range
requests, straight from the hub, so it fetches only the bytes the checks read:
safetensors headers, GGUF metadata, configs, chat templates, tokenizers, and
the model card. Nothing is uploaded and nothing is stored.

A preview is a first look. It is not an attestation, and it cannot become one.

## What a preview is not

- **Not signed, and no verdict.** A preview is an `engine.Preview`, a
different type from the report (`report.Document`). It has no promotion
state, no assurance level, no issuer, and no bounded statement, and
`attest.Sign` and the airlock take only a report. It carries a fixed notice
(`engine.PreviewNotice`) that says so. Whether a model is approved is your
organization's call, made with a full scan and your own key.
- **Not Socair's hashes.** The hashes are as Hugging Face states them for its
large (LFS) files, and computed by the preview from the bytes it read for the
small ones (`engine.PreviewHashes`). No weights are hashed, so the preview's
digest is the digest of stated hashes. A full scan through the airlock
computes every hash itself.
- **Not every check.** A check that reads a whole file is NOT_TESTED, and its
note begins "the preview reads headers only": the Pickle opcode scan, and the
structure of ONNX and Keras files. Checks that need your organization's data
(a known-bad list, reviewed templates, a license or intake policy, a signed
feed) do not run, and the preview names them as not run. Tier 2 never runs.
- **Not for gated or private repos.** The preview takes no Hugging Face token
and sends none. A gated or private repo is refused before any file is read;
pull it into your airlock with your own `HF_TOKEN` instead.

Every preview names the full commit it read. A branch name is resolved to its
commit first, and the commit is what the preview reports, so a result cannot
be taken to cover a later version.

## What it reads

The preview reads the files a serving stack loads, which is the selection
[airlock.md](airlock.md#commands) recommends for a pull:

- **A model served from safetensors:** the weights, every JSON config, the
tokenizer files, chat templates, the model card, and license files, plus any
Python, so the Remote code row judges it.
- **A repo of GGUF quants:** one quant, since a quant is pulled on its own (the
Q4_K_M by default, or the smallest), with the small files beside it. Each
GGUF's own metadata (chat template, tokenizer, quant) is previewed on its own,
as `socair scan <file>.gguf` would read it.
- **Anything else:** every file.

The page prints the selection as the airlock pull's `--include`, with the
commit, so doing it for real reads the same files at the same commit. Those
commands are meant to be pasted into a shell, and the repo id, the commit, and
the file names come from the hub, where anyone can name a file
`x';curl …|sh;'`. So a value outside a strict set of characters (letters,
digits, `.`, `_`, `-`, `/`, `*`) gets no command at all, only a note saying
why, and every value that passes is single-quoted as well
(`airlockCommands`, `shellQuote`). The page sets every value it shows from a
preview, the rows' notes included, as text, never as markup: the notes quote
the model's own files.

If any read fails (the hub answers an error, or a file needs more than the
preview's read budget), the preview shows no rows at all. A check that could
not read a file must never look like it did.

## Fidelity

For the checks it runs, a preview reads what a full scan reads, through the
same engine. `TestPreviewFindsWhatAScanFinds` pins it on fixtures: the same
rows, and a digest equal to the full scan's subject when the stated hashes are
true. On real repos (2026-10-19, Node, home broadband):

| Repo | In the repo | Read | Time | Rows |
|---|---|---|---|---|
| HuggingFaceTB/SmolLM2-135M-Instruct | 1.97 GB | 3.7 MB, 13 requests | 2.4 s | 5 PASS, provenance NOT_TESTED |
| deepseek-ai/DeepSeek-V3 | 688.6 GB, 163 shards | 59.6 MB, 181 requests | 19.2 s | 164 layouts verified; Remote code LEAD |
| bartowski/Qwen_Qwen3-0.6B-GGUF (Q4_K_M) | 12.95 GB, 24 quants | 6.4 MB, 10 requests | 4.5 s | structure, chat template, tokenizer, quant PASS |

The engine for the browser is about 13.6 MB, 3.6 MB gzipped, 2.6 MB with
brotli.

## How it works

- `engine.PreviewDir` scans a directory in place with the hashes its source
states. Only it and `engine.PreviewGGUF` reach that path: every scan passes
the directory scan no stated hashes, and `TestScanNeverTakesStatedHashes`
checks the source for it. A header-only scan of a directory is refused.
- `tools/socair-preview/hffs.js` is a read-only filesystem over one repo at one
commit, in the shape Go's js/wasm runtime calls (`globalThis.fs`). Each read
becomes range requests, cached in 16 KiB blocks, with a window that doubles
while a file is read in order. It has no way to take a token.
- `tools/socair-preview/preview.js` lists the repo, picks the selection,
mounts it, runs the engine, and returns the previews or throws.
- A release carries the engine for the browser and the page's modules as
attested assets (`socair-preview_<version>.wasm`, `..._wasm_exec.js`,
`..._hffs.js`, `..._preview.js`), built reproducibly by
`scripts/build-release.sh` and listed in `SHA256SUMS`. Verify them as
[verify-release.md](verify-release.md) describes for the binaries.

## Run it locally

```
tools/socair-preview/build.sh # tools/socair-preview/dist/
node tools/socair-preview/try.mjs HuggingFaceTB/SmolLM2-135M-Instruct
node tools/socair-preview/try.mjs org/name <revision> --all # every file
node --test tools/socair-preview/preview.test.mjs # the page's modules, no network
```
Loading
Loading