Governance & Provenance (v0.59.0)
Procurement-ready ML compliance from a single CLI. v0.59 ships 4 governance surfaces that previously required a stack of SaaS tools and a security team: CycloneDX/SPDX BOM emitter, in-toto / SLSA-3 attestation, HIPAA / SOC2 audit log, and EU AI Act Annex XI/XII auto-documentation.
soup bom emit — ML Bill of Materials
Generates machine-learning Bills of Material in CycloneDX 1.6 (with ML-BOM extension) or SPDX 2.3 AI-profile formats — or both in a single invocation.
soup bom emit \
--name llama3-8b-finetuned --version 1.0.0 \
--base-model meta-llama/Llama-3.1-8B-Instruct \
--base-sha <64-hex> \
--config-sha <64-hex> \
--data-sha <64-hex> \
--task sft --license apache-2.0 \
--format both --output ./manifests/llama3-bomThe three SHA values are SHA-256 digests that you supply, and each must be exactly 64 hex characters: a shortened or elided value exits 2 with base_sha must be 64 hex chars. Soup records them in the BOM and does not compute them or check them against anything. sha256sum soup.yaml | cut -d' ' -f1 prints one for a file.
Atomic file write (tempfile.mkstemp + os.replace) with symlink rejection (TOCTOU defense). --format=both produces <prefix>.cdx.json and <prefix>.spdx.json side by side.
--license is written according to what it is. A listed SPDX id, in any case, goes into the document in its canonical spelling (apache-2.0 becomes Apache-2.0, under license.id in CycloneDX and licenseConcluded in SPDX). An SPDX expression such as Apache-2.0 OR MIT is canonicalised and written under expression. Any other name goes under license.name in CycloneDX and becomes a LicenseRef- entry in SPDX 2.3, with its text in hasExtractedLicensingInfos. Since v0.76.0 the CycloneDX serialNumber is an RFC 4122 urn:uuid: value, and the SPDX document records the training data as a BUILD_DEPENDENCY_OF the model (the relationship was reversed before). The three spellings above were run through soup bom emit --format both on the v0.76.0 CLI.
Since v0.73.3, --attach-to-registry <id> registers what it wrote as a bom artifact on that entry, so soup card links it in the card's artifact table for free. Those were the two compliance documents soup card existed to publish and could not reach, because the registry had no such artifact kinds. The flag needs --output (omitting it is a usage error, exit 2), and a lookup or attachment failure exits non-zero after leaving the emitted files on disk. With --format both, both files are attached.
soup attest emit — SLSA-3 in-toto attestations
Per-stage attestation aligned with SLSA-3 (Supply-chain Levels for Software Artifacts) and in-toto.
soup attest emit --stage train \
--subject adapter.safetensors --sha <64-hex> \
--builder soup-cli \
--invocation "soup train --config soup.yaml" \
--sign unsigned --output ./attestations/train.json--attach-to-registry <id> works here too, as an attestation artifact, and a signed statement attaches its detached .sig sidecar alongside, so the card links every file a verifier needs (v0.73.3).
Stages: extract / train / eval / export / publish. --sha must be the 64-hex SHA-256 of the artifact, and it is recorded as given: Soup does not hash --subject and does not check the value against the file, so compute it yourself (sha256sum adapter.safetensors | cut -d' ' -f1). Backends: unsigned (default) writes the statement with an empty signature, so the statement itself is not tamper-evident; it records the subject digest you supplied. ed25519 is live as of v0.71.2 ([sign] extra; soup attest verify <statement> --signature <sig> does a canonical-JSON cryptographic verify). sigstore is live as of v0.76.0 ([sigstore] extra, described below).
--invocation is recorded in the statement at predicate.buildDefinition.externalParameters.invocation, signed or unsigned, up to 4096 characters (a longer value is refused, not cut). Through v0.75.2 it was stored under a key the statement builder never read, so it did not reach the statement. Without --output the statement is printed to standard output as the same JSON, verbatim.
soup attest verify without --public-key does not tell you who signed. It checks the signature against the public key stored in the .sig sidecar itself, so a statement re-signed by anyone with their own key and shipped with their own sidecar also passes (exit 0). That shows only that the statement and the sidecar agree with each other: the statement was signed by whoever holds the key recorded in the sidecar. It does not show who that is, and it does not show that the statement is the one the original signer produced, because an edited statement that was re-signed passes the same way. --public-key <pem> takes a key you obtained out of band: the key in the sidecar must then match it, or verification exits 3 with Signed by an untrusted key, and that is the only check that authenticates the signer. Exit 0 is a valid signature. Exit 3 is an invalid signature, a key mismatch, a statement that is not valid JSON, a sidecar that is neither ed25519 nor Sigstore (an unsigned sidecar included), an ed25519 sidecar with no public key when --public-key is not given, or a Sigstore sidecar with no bundle or verified without both --cert-identity and --cert-oidc-issuer. Exit 2 is an unreadable input (including an unreadable --public-key file, a sidecar that is not a JSON object or a sidecar over 16 MiB), a statement or sidecar path outside the current directory, --cert-identity without --cert-oidc-issuer (or the reverse), --cert-identity with a sidecar that is not Sigstore, or --public-key with a Sigstore sidecar. Exit 1 is a Sigstore verifier that is unavailable, which includes the [sigstore] extra not being installed (run on the v0.76.0 CLI). soup attest verify has no --strict option (that flag belongs to soup adapters verify). A pass also prints that the subject digest is asserted by the signer and is not re-checked against any file.
--sign sigstore is a real signing backend since v0.76.0. Install it with pip install "soup-cli[sigstore]" (sigstore>=4.4.0,<5.0.0; the extra is not part of [all]). Signing is keyless: it uses an ambient OIDC identity (for example GitHub Actions) to get a Fulcio certificate and submits to the Sigstore public-good Rekor log, so the signer's identity ends up in a public transparency-log entry. A browser sign-in is never opened implicitly; pass --interactive-oidc to allow it. soup attest emit --sign sigstore requires --output, so the complete Sigstore bundle (certificate, signature and transparency proof) is written to the <output>.sig sidecar. Every local argument and path is checked before the signing request is made, so a bad path never creates a log entry; a write that fails after signing would still leave the public entry.
soup attest emit --stage train --subject adapter-v1 --sha <64-hex> --sign sigstore --output att.jsonAn explicit Sigstore request never degrades to an unsigned statement. Without the extra, with no ambient credential and no --interactive-oidc (when the Sigstore trust root can be loaded; if it cannot, for example offline, the same run exits 1), without --output, or with --key (which belongs to ed25519), the command exits 2 and writes nothing; an OIDC, Fulcio or Rekor service that is unreachable at run time exits 1. Through v0.75.2 the same flag printed a "Signing infra-blocked" notice, wrote an unsigned statement and exited 0, so a pipeline that passed --sign sigstore believed it signed and did not. If you ran one, those statements carry no signature, and there is no .sig file next to them.
Verify with an identity and issuer that you trust, supplied out of band:
soup attest verify att.json --signature att.json.sig \
--cert-identity 'https://github.com/acme/repo/.github/workflows/release.yml@refs/heads/main' \
--cert-oidc-issuer 'https://token.actions.githubusercontent.com'The two flags go together (either one alone exits 2) and a Sigstore sidecar cannot be verified without them (exit 3): the identity embedded in the bundle is not trusted on its own, which would reduce authentication to self-consistency. As with ed25519, a pass shows only that the signer asserted the statement; the subject digest is not re-checked against an artifact. soup adapters sign --backend sigstore does the same for an adapter directory; see Supply chain security for that command and for one difference between its verify side and this one. This is what shipped: upstream's tests replace the signer with a mock and benchmarks/ has no end-to-end record, and the refusals and exit codes above were run on the v0.76.0 CLI without the extra installed, so no real Fulcio or Rekor round trip is claimed here.
soup audit-log — HIPAA/SOC2 audit trail
Every command execution records timestamp, command-line, exit code, operator identity, and host into ~/.soup/audit.jsonl (or $SOUP_AUDIT_LOG_PATH). Token-shaped values and the values of credential-named options are masked before write; nothing else is redacted. The masking covers values that look like hf_..., sk-... or Bearer ..., plus (from v0.75.1) the values of options whose name contains token, key, secret, password or credential, described below. A Slack or Discord webhook URL passed with --slack-url or --discord-url carries its secret in the URL, matches neither rule, and is recorded in full. Every other argument (a prompt passed with --goal, a file path, a model id), the operator's OS user name and the host name are recorded as typed, so do not put personal data or webhook URLs on a command line whose audit trail you ship to a collector.
Until v0.75.1 that masking was by value SHAPE, not by option name, and three credential flags fell through it. A value was masked if it looked like
hf_...,sk-...orBearer ...; anything else was written verbatim. So--tool-auth-token,--auth-tokenand--api-key, whose values usually look like none of those, were recorded in full, in a log this very page encourages you to collect centrally (0.71.3 to 0.75.0). Masking is now keyed on the option name, covering long and short forms and both--opt valueand--opt=value. If you passed any of those three on an affected version and your audit log is collected anywhere, rotate those tokens and purge the log.
# Tail the most recent 100 records (rich table)
soup audit-log tail --limit 100
# Raw JSONL for piping
soup audit-log tail --limit 50 --json
# Rotate at a 500 MB cap
soup audit-log rotate --cap-mb 500Both subcommands take --path <file> to act on a log other than the default ($SOUP_AUDIT_LOG_PATH, else ~/.soup/audit.jsonl). Unlike SOUP_AUDIT_LOG_PATH, which is honoured only inside your home, current or temp directory, --path is not checked. rotate renames whatever file you name, audit log or not, to <file>.1 when it is larger than --cap-mb (1 to 10000, default 100), replacing an existing <file>.1; otherwise, or when the file does not exist, it prints No rotation needed.
Turning it off
It is on by default for every command, so the opt-out belongs next to the feature rather than three pages away:
soup --no-audit-log train --config soup.yaml # global flag, before the subcommand
SOUP_NO_AUDIT_LOG=1 soup train --config soup.yamlBoth suppress the one-line record entirely. The writer refuses to follow a symbolic link or reparse point at the log's path on every platform; on Windows that guard is new in v0.76.0, because the no-follow flag it relied on does not exist there, so a link at audit.jsonl was followed before. $SOUP_AUDIT_LOG_PATH is the third control, and it moves the file rather than silencing it, which is what you want when the default location is the problem and the record is not.
EU AI Act Annex XI/XII
soup train ships two new flags that emit the documentation required by the EU AI Act:
soup train --config soup.yaml \
--annex-xi ./docs/annex-xi.md \
--repro-receipt ./receipts/repro.jsonThe reproducibility receipt captures every seed, kernel version, library version, and dataset hash needed to reproduce the run under SR 11-7 model-risk-management standards. Since v0.76.0 it also fills driver_version on a CUDA host.
What Annex XI/XII records, and what it does not. Training compute (FLOPs), energy and CO2 are written as not measured unless something measured them: energy and CO2 exist only when the run used soup train --track-energy, and FLOPs are never measured by Soup, so that row always reads not measured. Through v0.75.2 the documents wrote 0 and 0.000 kWh / 0.000 kg for those rows as if they were measurements (#1446). If you filed an Annex XI or XII document written by an earlier release, regenerate it before relying on it: a compliance document with zeros presented as measurements misrepresents the run. Code read at the tag: train_compute_flops=None, # #1446: Soup does not measure FLOPs; never claim 0 in commands/train.py.
CO₂ energy tracking schema
Energy is tracked by the CLI, not by a config block: soup train --track-energy (with --energy-country) records consumption offline via codecarbon, and soup bom emit --energy folds the measurement into the BOM and the Annex XI doc. There is no co2: key in soup.yaml, and no live grid-intensity lookup.
Numbers
+93 new tests in v0.59.0 (9193 → 9286).
See also
- Compliance pack — v0.71.35 wraps these commands into a regime-shaped workflow (
soup init --template,soup card,soup ci init). - Supply-chain security — v0.60 LoRA backdoor scanner, Merkle signing, air-gap bundles.
- Registry — every BOM and attestation can be attached as an artifact.
- Pre-flight & tooling (v0.64) —
soup license-advisor --target b2c|defense|embeddedreturns ok/warn/block per (license, deploy-target, MAU) and composes with the v0.59 license-matrix onsoup adapters merge. - Adapter lifecycle (v0.67) —
soup lockSHA256(base || dataset || env) closure makes governance artifacts reproducible across teams.
Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.