Scanned before it runs
Every run of kapsl scans before it executes. Not the tool alone — the composed set: the tool and the packages you installed with it, merged into one vulnerability inventory. That is the difference from a native package manager: kapsl python:requests is scanned for what requests pulls in, before requests has ever run on your machine.
What is scanned
When you run a command with an environment — kapsl [email protected]:requests, or kapsl -e @pip:requirements.txt bash — kapsl builds the environment, then scans the result: the tool's image plus your installed packages, as one CycloneDX document. A transitive dependency with a known CVE is a finding, even though no package manager you have ever used has looked at it.
Two things are scanned at different places, and it is worth keeping the split straight:
- The index scans so the catalogue can promise. Every published image is scanned at build and daily afterwards, with the results published (the per-project pages on index.kapsl.sh show findings per tag and architecture, and the raw SBOM, scan, VEX and attestation documents sit next to them).
- The client scans so your composition is covered. The index's scan covers the tool image. Your environment — the packages you asked for on top — only exists on your machine, so the client scans it, every time a new one is built.
How
The scan runs in a container: the grype scanner image scans the composed image set against a ~2 GB vulnerability-database image. Both are cached and refreshed on their own freshness windows (the scanner image at most 7 days, the database at most 24 hours). A scan that cannot run — no scanner image, no database, a runtime error — fails the run. It does not report zero findings. Failing closed is the only honest answer when the question was never asked.
The three bands
Findings are sorted into three bands, each with its own behaviour:
| Band | Default | Behaviour |
|---|---|---|
| deny | never | The run does not happen, no prompt, no override at the prompt. |
| prompt | critical | The run stops and asks: y always accept this set, N refuse (default), d show the findings, o accept once. With no TTY — CI, a script, a pipe — a prompt is a failure: the run stops. |
| notify | below the prompt threshold | A visible line on stderr naming what was found. The run continues. |
The defaults, from the config file: security_scan_deny = "never" and security_scan_prompt = "critical". So out of the box: CRITICAL stops you and asks, HIGH tells you and goes on, LOW and INFO stay quiet.
An old runtime line with known CRITICALs, run without a TTY:
$ kapsl --non-interactive [email protected] --version
■ FAILED Vulnerabilities: C: 2, H: 1, M: 8, L: 2, U: 0. — requires review,
but no terminal to ask on (run interactively, use --skip-scan,
or adjust security_scan_prompt)
■ ERROR Vulnerability scan requires review, but no terminal to ask on
$ echo $?
1
The point of the TTY rule: in an interactive session a human is present to make the call, and the call is remembered. In a pipeline there is no human, so "ask" means "fail", and the pipeline is red in the same place it is unsafe.
An acceptance is not "ignore scans from now on". kapsl stores the set of accepted finding IDs — in ~/.local/share/kapsl, alongside the other human decisions, never in the regenerable cache. The next run compares its findings against the accepted set as a subset:
- the same CVEs, still accepted → the run goes on silently;
- one of them fixed and gone → still fine, the set only shrinks;
- a new CRITICAL appears → the prompt is back, because the set you signed did not contain it.
Fixing a CVE does not re-ask. Adding one does.
Two overrides, both visible, both yours:
kapsl --skip-scan python --version # no scan at all; the gate is open, stated
kapsl --force-scan python --version # rescan even if the verdict is still fresh
--skip-scan is how you run a tool you have already decided about (an old pinned line you are reproducing, a private image with a known finding). It is the one flag that says "I accept this risk", and it costs exactly that much.
When a floating tag heals
If a scan fails on a floating tag (python:3.12, not a digest), kapsl re-pulls the tag once and re-scans, because the upstream fix may have landed between your last pull and now. If the tag's contents had not moved, the block stands — re-scanning the same bytes would just be politeness. A pinned digest never heals: a digest is a promise about the bytes, and the bytes did not change. This loop is the same mechanism as keeping tags current — updates and scanning are one pipeline.
What the index already proved
Before your client scan runs, the image you are pulling has already passed the index-side gates, and the documents are public:
- SBOM at build, with gates that catch a lie: file coverage in both directions, one component per piece of software, and a falsification test that pins a component to a version with a known CVE and requires the scanner to find it.
- Per-CVE VEX, written once by a human with a measured predicate — "reachable only if this package ships" — re-evaluated against every build. An assessment that stops matching the artifact produces a finding, not a suppression.
- Daily re-scan against a pinned vulnerability database, plus the distribution's own VEX where one exists.
- SLSA provenance and a cosign attestation per image, so the scan and the SBOM are statements about these bytes.
The client scan re-verifies against today's database and — because your composition is not the tool image — covers the delta. Where you see findings: the CLI's findings ledger at the prompt, the accepted set in ~/.local/share/kapsl, and the per-project page on the index site, where a clean scan and an unscanned artifact are shown differently (a scan state, not a colour).
The one-line version
Nothing runs that has not been looked at. If the looking finds something serious, it stops and asks — and where there is nobody to ask, it stops. The scan never edits the image to fix a finding; it reports and gates, and the fix is a new build with a new scan.