SBOMFlow Exit Codes

SBOMFlow uses stable exit codes for CI and operator scripts.

CodeMeaningTypical cause
0SuccessCommand completed, or a release gate was informational/not enforced.
1Enforced release gate failedA manufacturer-selected --fail-on-* policy blocked the run.
2Usage, input, IO, or config errorMissing target directory, malformed config, bad arguments, or unwritable output. Also sbomflow review / review-view when a file presented as reviewer decisions could not be read: absent is the ordinary case and exits 0, but present and unusable is an input error, because a human decision that quietly stops applying is indistinguishable from one that was never made. review writes review-queue.json (with decision_intake.complete: false and one bounded record per rejected file) before the non-zero exit, so the accounting is never lost to the refusal.
3Init refused / bundle verification failedsbomflow init found an existing sbomflow.yaml without --force, or sbomflow verify-bundle found a tampered bundle / failed signature. Under verify-bundle --layered (full four-layer content/envelope/semantics/authority verification of a directory or .zip), exit 3 means any dimension failed — a tampered listed member, a wrong DSSE payload type, or a valid-but-unauthorized signer — so a signature that verifies over the manifest can never mask altered contents. Per-dimension detail is in the output, not new exit codes.
4Structural validation failuresbomflow validate or sbomflow audit found malformed/tampered artifacts, or sbomflow store verify found a stored release missing/tampered.
5Strict warnings-as-errorsanalyze --strict (or --strict=code1,code2) matched one or more scan warnings after artifacts were written.
6Diagnostic control receipt not healthysbomflow doctor --analysis-control PATH wrote the receipt and this installation's control_health is degraded, failed or indeterminate. The receipt was written — this code says the diagnostic did not come back clean, never that it could not run (an unwritable path is 2). Which of the three states it is, and which named control caused it, lives in the receipt and the human summary rather than in more exit codes. It is an installation-health observation: it says nothing about a product, and no release gate consults it.
7Required verification layer expectation not metsbomflow verify-bundle --layered --require-layers content,envelope,semantics,authority produced a well-formed layer report in which nothing failed, but a layer the operator required did not reach a trusted status — most often an absent envelope/semantics on an unsigned pack, or an authority layer with no operator trust policy supplied. The full layer report is still printed. This code exists because a report can be honestly produced and still not answer the question a pipeline asked: without --require-layers, NOT TRUSTED on three layers exits 0. A genuine verification failure stays 3 and outranks this code; unknown layer names, and --require-layers without --layered, are refused as input errors (2) before verification runs.
130InterruptedThe operator pressed Ctrl-C (SIGINT). The sbomflow console script reports this conventionally instead of a Python traceback.
141Broken pipeA downstream reader closed the pipe (e.g. `sbomflow …head`). Conventional 128 + SIGPIPE.

Codes 130/141 are emitted only by the installed sbomflow console entry point (and python -m sbomflow), which wraps the command runner to turn normal operator interruptions into conventional shell codes without a traceback. The programmatic sbomflow.cli.main(...) still raises KeyboardInterrupt / BrokenPipeError for callers and tests.

No subcommand is treated as usage error: sbomflow prints help to stderr and exits 2. Gate failures are distinct from structural validation failures so CI can separate manufacturer policy decisions from broken artifact integrity.

Release gate reason codes#

Exit code 1 says a release gate blocked; the reason codes below say why, in a stable machine-readable form. Every entry in release-gate.json under violations[] and drift_violations[] carries a reason_code alongside its human description, so CI can branch on a code (GATE_KEV_PRESENT) instead of parsing prose. Codes are registered in a versioned registry (sbomflow-gate-reason-codes-v1, sbomflow.gate_reasons) and are additive: they are never renamed once shipped.

Reason codePolicy flagBlocked because
GATE_ADVISORY_FINDINGSfail_on_vulnerabilitiesUnresolved advisory findings at/above the severity threshold.
GATE_DATA_UNAVAILABLEpolicy_data_unavailableAn enforced policy could not be evaluated: the data it depends on was never loaded. Not a finding — see below.
GATE_DENIED_LICENSEfail_on_denied_licenseComponent(s) declare a license on the operator's own deny list (engineering context, not legal advice).
GATE_DRIFT_UNAVAILABLErelease_drift_unavailableA release-drift policy was enforced with no previous release supplied to compare.
GATE_EPSS_THRESHOLDepss_thresholdFinding(s) at/above the configured FIRST EPSS threshold.
GATE_EPSS_UNINTERPRETABLEuninterpretable_epssFinding(s) carry an EPSS score with no model/score date, so it cannot be compared against the threshold. Not a finding above the threshold — see below.
GATE_EVIDENCE_GAPSfail_on_gapsMissing-evidence gaps at/above the severity threshold.
GATE_EVIDENCE_REGRESSIONfail_on_evidence_regressionPreviously-accepted evidence regressed vs the previous release.
GATE_KEV_PRESENTfail_on_kevFinding(s) in the CISA KEV catalog (known exploited in the wild).
GATE_MISSING_APPROVALSfail_on_missing_approvalsRequired multi-role release approvals are incomplete.
GATE_NEW_ADVISORY_FINDINGSfail_on_new_vulnerabilitiesNew advisory finding(s) vs the previous release.
GATE_NEW_CRITICAL_OR_HIGHfail_on_new_critical_or_highNew or escalated critical/high finding(s) vs the previous release.
GATE_NEW_KEVfail_on_new_kevNewly known-exploited finding(s) vs the previous release.
GATE_NEW_REACHABLE_FINDINGSfail_on_new_reachable_vulnerabilitiesFinding(s) that became source-referenced (reachable) in this release.
GATE_REACHABLE_FINDINGSfail_on_reachable_vulnerabilitiesSource-referenced (reachable) advisory finding(s) meeting the threshold.
GATE_SEVERITY_UNDETERMINEDundetermined_severityItem(s) have no determinable severity, so they could not be compared against the threshold. Not a finding above the threshold — see below.
GATE_STALE_VULNERABILITY_DATAfail_on_stale_vulnerability_dataConsulted vulnerability source(s) are older than the operator's freshness SLA (or of unknown age). Opt-in and off by default — see below.
GATE_SUPPORT_PERIOD_MISSINGfail_on_support_period_missingThe current release does not declare a support period.
GATE_UNREVIEWED_EVIDENCEfail_on_unreviewedObserved evidence items are not yet human-reviewed.
GATE_STALE_ACCEPTED_EVIDENCEfail_on_stale_evidenceEvidence a reviewer accepted has aged past its own recorded validity window. An acceptance that has aged out is not a current acceptance.
GATE_APPROVAL_BINDING_MISMATCHfail_on_approval_binding_mismatchA release approval was granted against a different evidence pack or a different gate policy, or records no binding at all. An approval is a decision about specific evidence under a specific policy; re-approval is a human act, never an automatic re-bind.
GATE_AI_MODEL_CHANGEDfail_on_ai_model_changedA model artifact does not hold the same bytes as in the previous release. Opt-in — a model changing is ordinary for a product that ships models; this blocks only where an operator decided it should.
GATE_AI_DECLARED_FACT_CHANGEDfail_on_ai_declared_fact_changedA material declared AI fact moved since the previous release — supplier, licence, base model, dataset provenance, pinned digest, external provider or data region, an agent's tools or permissions. Opt-in.
GATE_AI_EVIDENCE_CONFLICTfail_on_ai_evidence_conflictAn observed fact and a declared fact disagree about the same AI entity and the disagreement is unresolved. The disposition is a human one; the gate only refuses to ignore it. Opt-in.
GATE_AI_UNSUPPORTED_FORMATfail_on_ai_unsupported_formatA model artifact was recorded and hashed but deliberately never parsed (its only faithful reader would execute code). Opt-in.
GATE_AI_DRIFT_UNCOMPARABLEfail_on_ai_drift_uncomparableThe AI evidence of this release was never compared against a previous one — no baseline, an unreadable one, or no scan. The absence of a comparison, not agreement. Opt-in — see below.
GATE_AI_INVENTORY_PARTIALfail_on_ai_inventory_partialA traversal budget cut the AI walk short, so parts of the release tree were never examined and the inventory is partial. Opt-in — see below.
GATE_AI_CONTROL_UNEVALUATEDai_control_unevaluatedAn enforced AI control could not be evaluated, or raised while being evaluated. Its own code, not the control's: "we could not answer" is a different fact from "we answered and found something".

Reason codes describe engine policy outcomes only. A code means a manufacturer-selected policy blocked a release — never a claim about CRA conformity, and never a substitute for the human review that owns every evidence, VEX, and approval decision.

GATE_DATA_UNAVAILABLE: an enforced policy that could not be evaluated#

Exit code 1 means "do not release". It does not mean "a vulnerability was found" — it means at least one enforced policy said no. GATE_DATA_UNAVAILABLE is the case where the policy could not even be evaluated, because an input it depends on was never loaded.

--fail-on-kev counts findings flagged by the CISA KEV catalogue. If the catalogue was never fetched, nothing is flagged — so the old gate reported a clean pass for a question it never asked. Zero known-exploited findings in a run that never loaded KEV is not a clean bill of health; it is an unasked question. Any enforced policy whose data is absent is now indeterminate: it renders no verdict (policy_outcomes[].outcome is not_evaluated, never pass), emits a policy_data_unavailable violation naming the policy and the missing input, and exits 1. The detail is in release-gate.json under indeterminate_policies[], and sbomflow explain <dir> --gate prints it in full.

There is deliberately no new exit code: 1 already means "an enforced gate said no", CI already treats it as a build break, and inventing a third state would let a pipeline that only checks == 1 silently ship an unevaluated release. Automation that needs the distinction branches on the reason_code. This matches GATE_DRIFT_UNAVAILABLE, which has always refused to let a drift policy pass with no previous release to compare against.

Missing data is not a network requirement. A pinned offline snapshot satisfies every one of these policies — --kev-file, --epss-file, --nvd-file, --osv-snapshot, or a vulndb store. Only an absent or failed source triggers the block. A stale source does not, by default: the catalogue was genuinely consulted and every finding really was compared against it, so its age is a separate fact — the stale_vulnerability_data warning (which --strict can promote to a failure). Blocking on staleness by default would push operators onto the network, which offline-first forbids.

GATE_STALE_VULNERABILITY_DATA: an opt-in freshness SLA#

GATE_DATA_UNAVAILABLE is "the source never loaded". This is its counterpart for a source that did load and was genuinely consulted, but is older than a freshness service level the operator chose to enforce. It is off by default and set only in the gate policy — fail_on_stale_vulnerability_data: true, with an optional uniform max_snapshot_age_days. With no day count, each source is judged by its documented per-kind window (the same threshold the stale_vulnerability_data warning uses), so the bare knob simply promotes that warning to a gate.

When it is on, a consulted source older than the maximum blocks — and a source whose data date is unknown also blocks, because unknown age can never be shown to be within the SLA (unknown is never fresh, the same rule GATE_DATA_UNAVAILABLE applies to absent data). The reason names each source, its recorded as-of date, its computed age, and the maximum it was judged by, so the block is replayable. Clear it by refreshing the source (a newer pinned offline snapshot is enough — this never forces the network), raising max_snapshot_age_days, or not enforcing the policy. Like every gate reason it is an engineering policy outcome, not a compliance determination, and exit 1 is unchanged.

The knob is enforced on the primary gate path — sbomflow analyze --policy <file> and --policy-profile <preset> (and the read-only gate simulate) all honour it, so a real release run over a stale consulted source blocks with this reason.

Per-policy data requirements — what each --fail-on-* flag needs, and what happens when it is absent — are tabulated in the CI runbook.

GATE_SEVERITY_UNDETERMINED / GATE_EPSS_UNINTERPRETABLE: values the gate could not interpret#

GATE_DATA_UNAVAILABLE is the same honesty rule at source granularity — "we never loaded the catalogue". These two are that rule at finding granularity: the source loaded, and it told us nothing usable about this item.

An unknown severity is not a low severity. It is the absence of one. The severity scale runs none < low < medium < high < critical, and unknown is not on it: none is a determined zero-impact answer, while unknown means no source gave us a rankable band at all (no CVSS vector, no advisory severity). Ranking the two the same — which the gate did until this was fixed — let a finding the engine never scored slide under every threshold, so --fail-on-vulnerabilities --severity-threshold low reported a clean pass over it. It now blocks with GATE_SEVERITY_UNDETERMINED: the gate will not report a finding it never ranked as sitting below your threshold.

The same rule covers the release-drift high band: fail_on_new_critical_or_high is a threshold policy over the drift rows, and a NEW (or severity-changed) finding whose severity cannot be determined used to silently not count toward it. It now blocks with GATE_SEVERITY_UNDETERMINED — never counted as critical/high (that is not known), and never silently below the band. GATE_NEW_CRITICAL_OR_HIGH itself still fires only for findings whose stated band determinably meets critical/high. A recorded human VEX review (not_affected/fixed) clears the drift block on the same terms as every other drift blocker.

An undated EPSS score is not a low EPSS score. FIRST re-fits the EPSS model periodically and the score distribution moves with it, so a bare probability is not interpretable without the score_date it was produced under. A finding carrying a score with no model date blocks an enforced epss_threshold with GATE_EPSS_UNINTERPRETABLE rather than quietly counting as "below the threshold". (The daily FIRST CSV records its own score_date; SBOMFlow stamps it onto every finding it scores, so real enrichment never trips this.)

Neither code is a claim that the item is severe — only that it could not be cleared. Both are visible on every run, enforced or not: release-gate.json always carries undetermined_severity_vulnerability_ids, undetermined_severity_gap_ids, and uninterpretable_epss_vulnerability_ids, so "how many findings have no determinable severity?" is answerable from any output. With no --fail-on-* flag they block nothing — the gate stays informational and exits 0.

They clear the way every other blocking item clears — through a recorded human decision, never through silence:

  • supply the missing data (an NVD/OSV snapshot carrying a CVSS vector; an EPSS snapshot that records its score_date);
  • record a human VEX review of the finding (fixed, or not_affected with a valid CISA justification); or
  • waive the item with a reviewer, a reason, and an expiry — it stays visible in waiver_status and in the report.

The two AI honesty controls#

fail_on_ai_model_changed and its siblings can only ever report what an AI comparison found. If the previous release carried no AI evidence record, or one that could not be read, or this run performed no scan, then every one of them is vacuously empty — and an operator enforcing an AI policy over an inventory that was never gathered would get the most reassuring possible green.

fail_on_ai_drift_uncomparable and fail_on_ai_inventory_partial are the answer: they make "nothing was compared" and "the walk was cut short" into blockable facts. They are the AI face of the rule GATE_DATA_UNAVAILABLE states for advisory sources — not looked at is not clean — and, like every AI control, they are off until an operator turns them on.

Every AI control is evaluated independently. One control that cannot be evaluated, or that raises, is recorded as GATE_AI_CONTROL_UNEVALUATED for itself and never stops the others from being evaluated and enforced: a gate that keeps closing while most of its controls have quietly stopped running is the failure this design exists to prevent.

No AI reason code asserts that a change is harmful, that a model is unfit, or that a release is unsafe. Each says a manufacturer-selected policy blocked on something the AI evidence recorded. AI controls are configured in the gate policy file only (fail_on_ai_*: true), like the other recent knobs — there is no CLI flag.

Stable error codes on failure output#

Every user-reachable CLI failure prints a stable `[E0xx] code with a one-line fix and a docs link (see ERRORS.md, or offline: sbomflow help error <code>`). Exit code 2 covers usage/input/operation failures (families E01x usage & input, E02x review workflow, E03x outputs & artifacts, E04x integrations & integrity, E05x store & vulndb); integrity verification failures that must not read as ordinary usage errors — a broken audit-log hash chain (E043), store tamper, gate outcome mismatch — exit 4. A structural test in the offline suite fails if a new failure path ships without a code.

Exit 4 is reserved for a record that does not reproduce, never for a record that has merely been overtaken: store verify-gates reports a release whose recorded outcome replays exactly but which humans have decided more about since as outcome_superseded, and exits 0. A verification signal that fired on the ordinary review workflow would train operators to ignore it.

Troubleshooting first-run failures#

Common mistakes are designed to self-explain with an actionable message:

  • Missing/file target (exit 2) — "target directory not found … Fix: pass an existing product directory."
  • Malformed or unknown-key config (exit 2) — the parser error plus "Fix: check syntax/keys with sbomflow validate-config <file> or run sbomflow doctor .."
  • Unknown --policy-profile (exit 2) — lists the available shipped presets.
  • Malformed --kev-file/--epss-file/--nvd-file snapshot — never silently ignored: it surfaces a vulnerability_enrichment_error scan warning (and fails under --strict).

sbomflow doctor . is the first stop: it reports Python compatibility, optional extras, the external CycloneDX CLI, and any discoverable config (including parse errors and unknown-key warnings) — all offline.

--strict is opt-in and runs after all artifacts are written, so the evidence pack is always produced; the distinct exit code 5 lets CI fail on warnings without conflating them with a --fail-on-* gate decision (1). Each scan-warning code is registered in a stable, versioned warning catalog (sbomflow-warning-catalog-v1); --strict=<codes> is validated against it.