Always current
kapsl has no upgrade command, by design. There is nothing to upgrade: a tool named without a pinned digest is expected to stay current, and the machinery that keeps it current is the same machinery that keeps it patched. This page is that machinery — three clocks, one heal loop, and the trade you make when you opt out.
The three clocks
Three independent freshness windows run in the background of every invocation. All three are configurable in kapsl.toml; the defaults are what is shown:
| Clock | Default | What it moves |
|---|---|---|
image_max_age_secs | 7 days | A cached image on a floating tag (python:3.14, rg:latest) older than this is re-pulled before a run. Best-effort: a failed refresh falls back to the cached image — the scan gate, not the freshness window, is the security backstop. |
index_max_age_secs | 24 hours | A background check for a new tool catalogue. A conditional GET: a 304 changes nothing, and the payload is downloaded only when the pointer's version moved. |
security_scan_max_age_secs | 24 hours | How long a passing scan verdict is trusted. A rebuilt image gets a new digest and re-scans immediately; the window applies to the same bytes. |
Two details worth knowing. First, the clocks are best-effort by construction — a network hiccup never blocks a run; it delays a refresh by a window. Second, one path tightens the catalogue window deliberately: when the tool you asked for is not in the cached catalogue, the refresh probe runs on a 30-minute window instead of 24 hours, because in that one case a stale catalogue is a failed run, not just an old one. The most likely cause of a miss is a tool published after your last check, and that is the case worth paying for.
The catalogue carries no versions at all — no release lines, no dates (The catalogue) — so "which bytes did python run as" is answered by the index site and the registry, not by the document the client resolves against. The line table a tool publishes is visible per tool:
kapsl --info python
▍ ■ INFO python
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
The Python programming language interpreter
IMAGE ghcr.io/kapsl-sh/python:latest
digest sha256:619e…b645 59 MB · 1 layers
LINES RESOLVES TO
▸ @latest 3.14.7 in use
@3.14 3.14.7
@3.13 3.13.15
@3.12 3.12.14
@3.11 3.11.16
@3.10 3.10.21
lines float — kapsl carries security updates onto them
pin one with python@<version> · a digest never moves
CAPS rw
SECCOMP net-listen (no class declared) (tier: default)
────────────────────────────────────────────────────────────────────────────────
RUN kapsl python SHIM kapsl -i python
Every line above is a floating tag: @3.12 does not mean "3.12.14 forever", it means "the newest 3.12.x build", and it advances as upstream security updates land and the line is rebuilt. That is what keeps a maintained line patched without anyone running anything.
The CVE-heal loop
The clocks and the scanner are not two systems. The scan fails on a floating tag — CRITICAL findings, or the verdict you declined — and kapsl does not just block: it gives the tag one chance to heal. The re-pull happens, and if the tag moved, the new bytes are re-scanned, because the rebuild may have fixed the vulnerability upstream. If the tag had not moved — same digest, same bytes — the block stands: re-scanning the same image would only re-find the same CVEs. Digest-pinned and locally-built images skip the retry entirely; a refresh cannot change what they refer to.
So the sequence for a tool with a newly-fixed critical CVE is: block → re-pull → clean → run. Nobody ran an upgrade. The fix arrived with the image that the freshness machinery was already tracking, and the scan is what confirmed it.
Why this is security, not convenience
In a pip install --upgrade-or-never world, the update is a human decision with a schedule, and the time between "upstream shipped the patch" and "your box got it" is the supply-chain attack window — and the transitive dependencies your tool pulled are patched by someone else's upgrade, if any. The pipeline above removes the schedule: the same mechanism that keeps the tag current is the one that rebuilds on upstream fixes and re-scans the result before anything runs. Current is the default state, and the scan gate is the check that "current" actually means "clean".
That is also why pinning is stated as a trade, not a feature. A digest pin (python@sha256:… in your configuration) is opting out of currency: bit-for-bit reproducibility, yes — and no floating, no re-pull, no rebuilds carrying security updates onto it. The pin is exactly the bytes you named, forever, and the scan still runs on it every window. For reproducibility that is the point, and it is CI's job, not your laptop's: CI/CD covers the pinning workflow — digest pin in the pipeline for bit-for-bit runs, floating tags for "current and clean".
Old version lines age deliberately
A version line upstream stops maintaining is not deleted and not quietly dropped: the index resolves each project's lines from upstream's own support calendar, and a line past end-of-life is reported, never rebuilt, never deleted. Its tag keeps resolving, kept so you can reproduce a bug or test against several versions of a tool — not maintained as a distribution channel. Reach for an old line when you need that version, and expect it to be vulnerable: the scan still runs on old lines by default, and its findings stand in for the security updates the line no longer gets. A test setup that accepts that risk opts out per run with --skip-scan (or globally, at the cost of the whole gate — see Scanned before it runs).
| What you pin | What moves | What you give up |
|---|---|---|
Nothing (kapsl python) | The line floats, the line's build floats | Nothing — this is the default |
A version line ([email protected]) | Builds within the line float, carrying security updates | Newer lines — 3.12 does not become 3.13 |
A digest (python@sha256:…) | Nothing | Currency entirely — no re-pull, no rebuilds, no patches |