Supply-Chain Scan
The supply-chain formation is a nightly, working-tree dependency-advisory scan across every managed project. It is advisory and never fails a project run. Its remediation engine is gated off by default; when explicitly enabled, it may apply verified dependency fixes and commit them locally without pushing.
Why it is its own formation
A supply-chain advisory is an external, time-triggered fact. It appears because the world changed — a CVE was published against a dependency — not because the project's own code regressed. It can go red on a repo with zero diff landed.
That makes it categorically different from a quality gate, which answers "is my code correct?" and fails because of your change. Cramming a time-triggered, externally-owned advisory check into the change-triggered, blocking preflight gate set has three failure modes:
- it aborts preflight, killing the very maintain step that could bump the dep;
- it has no memory — it re-discovers and re-fails the same CVE every night;
- it conflates "my code regressed" with "an advisory dropped" under one red checkmark, so a project with perfect code reads as failed.
The release-tag audit action (ReleaseTagAudited) already handles supply-chain
at release time. This formation is the missing nightly working-tree lane:
it scans what is checked out now, on a schedule, independent of whether code
changed.
The chain
nightly-supply-chain sentinel → SupplyChainScanStarted
→ ScanSupplyChain → SupplyChainScanned
→ RemediateSupplyChain → SupplyChainRemediated
→ WriteSupplyChainDigest → SupplyChainScanCompleted
ScanSupplyChainiterates every active registry project, runs the stack's audit tool (cargo audit,npm audit,mix deps.audit) against the working-tree lockfile, classifies each advisory against that repo's committed allowlist, and emitsSupplyChainScanned. Each finding carries a fix version when the audit tool reports one and, where npm supplies it, the direct fix package whose upgrade removes a vulnerable transitive package. Python is the exception to "global tool":pip-auditis a project dependency, so it is run from the project's own.venv/bin/pip-audit— a repo that hasn't installed it reports cleanly under "Not scanned" rather than relying on a global PATH.RemediateSupplyChaintriages every live finding by fix availability and emitsSupplyChainRemediated, carrying the scan through. A populated fix version means the advisory is mechanically auto-fixable; an empty one means a policy call — an exploitability judgement about our usage that stays human. When the auto-fix engine is enabled (see below) it also applies the fixable ones, behind a verify-and-rollback rail; otherwise it only classifies.WriteSupplyChainDigestrenders a deterministic markdown digest (no agent — CVE identifiers must never be paraphrased or hallucinated) and writes it atomically to{FOUNDRY_SUPPLY_CHAIN_DIR}/{YYYY-MM-DD}.md. Dry-run skips the write.
The schedule is 0 6 * * * (06:00 local), offset past the 02:00 maintenance
run. The sentinel ships enabled in the canonical seed; disable it with
foundry sentinel disable nightly-supply-chain.
The allowlist — committed per-repo memory
A gate is stateless: it re-fails the same advisory forever. A function
remembers a decision. Each repo may carry a committed
.supply-chain-allow.json at its root — a neutral artifact Foundry reads (it
never writes it; acceptances are authored by a human and land through the repo's
normal commit flow, so every decision lives in git history):
{
"version": 1,
"allowed": [
{
"cve": "GHSA-gv7w-rqvm-qjhr",
"reason": "transitive dev-only dependency; not reachable in our runtime",
"expires": "2026-09-01"
}
]
}
Each entry classifies one advisory on the day of the scan:
| State | Condition | Effect |
|---|---|---|
| live | not in the allowlist | reported as a finding |
| accepted | present, expires today-or-later (or absent) | suppressed; noted under "Accepted" |
| lapsed | present, expires has passed | resurfaces as a live finding and is flagged under "Lapsed acceptances — re-decide" |
The expiry is deliberate: an acceptance is a decision to revisit, not a
permanent mute. A malformed expires string fails safe — the advisory
resurfaces rather than hiding.
The digest
The digest opens with a triage line — N auto-fixable · M policy-call —
splitting the live findings by fix availability. It then groups findings into
sections: Live findings (a per-project CVE / package / severity / version /
fix table, where the fix column shows the resolving version or policy call), Lapsed acceptances (now live, need a fresh decision), Accepted
(active allowlist entries, for transparency), and Not scanned (projects
whose audit tool was unavailable or had no lockfile — reported, never failed). A
clean scan reads "No live supply-chain advisories."
The auto-fix engine (gated dark)
RemediateSupplyChain can do more than classify: it can apply a fixable
advisory's fix. This is off by default and stays inert on every install
until two conditions both hold — the env var FOUNDRY_SUPPLY_CHAIN_REMEDIATE is
truthy and the run is at Full throttle (never under dry_run). With the gate
off, the block is byte-for-byte the classifier.
When enabled, each fixable finding goes through a mandatory verify-and-rollback rail, and every change is reversible — committed locally, never pushed:
- Refuse a dirty tree. A project whose working tree carries uncommitted
changes is skipped, so a rollback can always return to a known-clean
HEAD. - Apply. The fixer follows the project's stack and lockfile:
- Rust:
cargo update -p <pkg> --precise <fix>updatesCargo.lock. - TypeScript: Bun and npm projects update their native lockfile. A matching
direct dependency or override pin is rewritten in
package.jsonfirst; transitive advisories target npm's explicitfixAvailable.namepackage. - Python: uv projects rewrite a matching
pyproject.tomlrequirement and runuv lock --upgrade-package <pkg>==<fix>. Unsupported stacks or projects without a supported lockfile report a visibleapply_failed/no_fixeroutcome rather than guessing.
- Rust:
- Verify. The repo's own
.hone-gates.jsongates are re-run. A repo with no gates is skipped — an unverifiable fix is never applied. - Commit or revert. If the required gates pass, only the dependency files
touched by that fixer are committed (
chore(deps): bump … (supply-chain auto-fix)); otherwise those files are unstaged and restored fromHEAD. Each applied fix commits immediately, so a later finding's rollback can never clobber an earlier success.
The digest gains a Remediation section — Auto-fixed, Reverted, and Not auto-fixed (needs attention) — only when the engine actually ran. Enable it by adding the env var to the daemon's launch environment; disable by removing it.
Release-tag audit scan errors
The ReleaseTagAudited event carries an optional scan_error field
(Option<String> in the SDK; absent from the JSON wire format when None).
Invariant: a scan that could not run is reported as unknown, never as a
clean result. When scan_error is set:
vulnerablereflects the upstream payload value (the last known state), not a fresh clean reading.- The
cvefield is likewise forwarded from upstream unchanged. - Downstream blocks that branch on
vulnerable: falseshould check for a non-nullscan_errorbefore treating a result as authoritative.
scan_error is populated in three situations:
| Cause | Example message |
|---|---|
git checkout <tag> returns non-zero | "git checkout v1.2.3 failed: ..." |
| Scanner tool returned a tool-level error | "cargo audit not found" |
Scanner gateway itself returned Err | "I/O error spawning audit tool" |
Environment variables
| Variable | Default | Purpose |
|---|---|---|
FOUNDRY_SUPPLY_CHAIN_DIR | ~/.foundry/supply-chain | Digest output directory |
FOUNDRY_SUPPLY_CHAIN_REMEDIATE | (unset → off) | Set truthy (1/true/yes/on) to enable the auto-fix engine. Off by default. |