CI/CD
A pipeline wants three things from a toolchain: prompts that become failures, versions it can account for, and a runner it does not have to babysit. kapsl's CI posture is built for all three, and each one is a decision the pipeline makes rather than something that happens to it.
Non-interactive is the default, and it is fail-closed
--non-interactive disables the TTY, and it is auto-detected: the binary
enters non-interactive mode when the flag is set, when the CI
environment variable is present, or when either stdio end is not a
terminal — so a pipeline gets it without configuring it. What changes is
what a prompt does: every decision point that would otherwise ask — a
scan finding at the prompt tier, a destructive --clean — becomes a
failure with a non-zero exit code. A CRITICAL finding failing the build
is exactly what a pipeline wants, and it is what actually happens:
CI=1 kapsl omp --version
▍ ■ FAILED Vulnerabilities: C: 1, H: 10, M: 13, L: 3, 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
Exit 1, no hang, no stray prompt eating the runner. The same run
interactively would stop and ask y/N/d; in a pipeline there is no one
to answer, so it declines itself. The knobs are on the
configuration page:
security_scan_prompt (default critical), --skip-scan, --force-scan.
Determinism, stated as a decision
There are exactly two postures, and choosing between them is the pipeline's decision:
- Floating, for "current and clean". A name without a pinned digest is expected to stay current: floating tags re-pull on the freshness window (default 7 days), and a failed scan on a floating tag re-pulls and re-scans once, because a rebuild may have fixed the CVE. The always-current page is the full story; the pipeline consequence is that "the tool my build ran" is a moving target that you are opting into.
- Digest-pinned, for bit-for-bit. A pinned digest never moves and is never re-pulled. Pin in the project or runner config and the build is reproducible to the byte.
The catalogue itself carries no versions, no digests, no dates —
that is a defining property of the format, not an omission. So "which
bytes ran" is not answered by the index document; it is answered by the
registry and the index site, which is why the CLI surfaces it: --info
prints the resolved digest, and --pull lists the digests of the whole
image set it prepared:
kapsl --pull mkdocs
▍ ■ SCANNING mkdocs · 4 images
▍ ■ READY prepared mkdocs · ready to run with --offline
▍ ghcr.io/kapsl-sh/kapsl-base-static-ubuntu:latest sha256:219a…c73d
▍ ghcr.io/kapsl-sh/git:latest sha256:f8a2…021d
▍ ghcr.io/kapsl-sh/mkdocs:latest sha256:740e…c986
▍ ghcr.io/kapsl-sh/python:3.14 sha256:619e…b645
The workflow for auditing a pipeline's tool versions: read the digests
the run resolved (they appear in the pull/ready lines and in --info),
and follow them to the index site's per-project pages, which show the
findings, the VEX assessments and the raw provenance documents for that
exact tag. The catalogue page explains the
split; the docs never restate the site's data.
Old version lines are kept deliberately — so you can reproduce a bug
against the version that had it — they age rather than being maintained,
and the scan still runs on them. Running a known-vulnerable old line is
--skip-scan with the risk stated, not a silent pass.
Caches, disks, and the first-run cost
Everything regenerable is cached, and the cache is addressable:
kapsl --status
kapsl storage
cache /Users/q/.cache/kapsl
decisions /Users/q/.local/share/kapsl
scanner 9.6 MB 6 entries
grype database 0 B 0 entries
tool catalogue 60 KB 2 entries
freshness stamps 7.3 KB 1 entry
run scratch 0 B 0 entries
decisions 222 MB 6 entries
total 232 MB
catalog.json regular file
not written by kapsl (never removed automatically):
/Users/q/.local/share/kapsl/accepted-capabilities.json
/Users/q/.local/share/kapsl/attestations
/Users/q/.local/share/kapsl/env-signing-key.pem
Two roots, with the split being the point: ~/.cache/kapsl holds
everything regenerable (env images, the scanner database, the catalogue
cache), ~/.local/share/kapsl holds the human decisions — accepted CVE
sets, trusted projects — which are small and precious. --clean with
--dry-run previews, and the default targets are the ones that cost
nothing to recreate; cache and decisions ask first, and in a pipeline
the ask becomes a failure unless you pass --yes. Captured on a machine
that had built several environments:
kapsl --clean
Removing 6 cached environment image(s)...
Untagged: localhost/kapsl-env-python-3.12:uv-ad44534d
removing localhost/kapsl-env-python-3.12:uv-ad44534d ... done
Untagged: localhost/kapsl-env-python-latest:pip-900e02cc
removing localhost/kapsl-env-python-latest:pip-900e02cc ... done
...
Removing 4 package cache volume(s)...
removing kapsl-cache-uv-arm64 ... done
removing kapsl-cache-yarn-arm64 ... done
removing kapsl-cache-npm-arm64 ... done
removing kapsl-cache-pip-arm64 ... donekapsl --clean --dry-run
▍ kapsl-env-* images, package caches and the vulnerability database (not enumerated under --dry-run)
▍ ■ INFO --dry-run: nothing removed
The cost shape a pipeline should plan for, measured on this machine (today's numbers, warm runs after):
| Step | First run | Warm |
|---|---|---|
| Tool pull + scan | 30–120 s (new tool) | sub-second |
Env build + scan ([email protected]:pyyaml) | 57 s | 2.7 s (scan verdict fresh: 0.4 s) |
Vulnerability database rebuild (after --clean cache) | 1 m 7 s | — |
kapsl [email protected]:pyyaml -c "import yaml; print('pyyaml', yaml.__version__)"
▍ ■ BUILDING kapsl-env-python-3.12:uv-ad44534d
▍ ■ BUILDING installing · 0%
▍ ■ BUILDING capturing · 66%
▍ ■ BUILDING exporting kapsl-env-python-3.12:uv-ad44534d (3.2 MB)
▍ ■ BUILDING cataloguing kapsl-env-python-3.12:uv-ad44534d
▍ ■ BUILDING signing kapsl-env-python-3.12:uv-ad44534d
▍ ■ SCANNING python · reading 13 inventories
▍ ■ SCANNING python · 13 images
pyyaml 6.0.3
Two mitigations:
--pullto pre-warm a stage. Pull and scan the image set without running anything, so the stage that runs later starts warm. TheREADYline above is the pre-warmed state: prepared and "ready to run with--offline".--offlinefor sealed environments. Skips pulls, catalogue refresh and scanner-DB updates, and runs against what is already present — the offline mode page has the full semantics, including that the scan still runs against the cached database.
kapsl --offline python --version
Python 3.14.7Exit codes pass through — and set -e works
The tool's exit code is the run's exit code, pass-through, and the supervising parent maps a signal death to the right code. A lint stage that fails stops the pipeline at the lint stage:
bash -c 'set -e
echo "=== lint stage"
kapsl flake8 .
echo "=== build stage (never reached)"'
=== lint stage
./probe.py:1:1: F401 'os' imported but unused
./probe.py:2:1: F401 'sys' imported but unused
./probe.py:4:1: E302 expected 2 blank lines, found 1
./probe.py:4:15: E201 whitespace after '('
./probe.py:4:17: E231 missing whitespace after ','
./probe.py:7:1: E305 expected 2 blank lines after class or function definition, found 1
./probe.py:7:80: E501 line too long (90 > 79 characters)
The script exits with 1 — flake8's own code — at the lint stage, and the build stage never runs.
And the passing path runs the next stage, which is itself a kapsl run — lint, test and build are all containerized steps in one pipeline:
bash -c 'set -e
echo "=== lint stage"
kapsl flake8 .
echo "=== build stage"
kapsl python clean.py'
=== lint stage
=== build stage
3Pipelines inside pipelines
When a step needs to call another tool from within a tool — a build
script that shells out to a containerized formatter — the nested call
runs under --cap nest: the inner run is proxied by the outer kapsl
through a brokered socket instead of the inner container being handed the
engine socket (which would be a full sandbox escape), and the nested tool
gets its own sandbox. It works on Linux today; on macOS the containers
live in a VM and the socket cannot cross into them, so the call fails
loudly:
kapsl --cap nest bash -c 'kapsl jq --version'
▍ ■ ERROR --cap nest is not supported on macOS yet.
▍ kapsl runs on the host but its containers run inside a virtual
▍ machine, and the broker socket has to be on the same kernel
▍ as the container using it. The fix is to run the broker as a
▍ sidecar container; that is designed but not yet built.
▍ Nesting works today on Linux.The runnable examples
The CLI repo ships boks-examples/ — one directory per project shape
(flask, django, fastapi, express, nextjs, cargo, go, ruby, …), each with
a captured transcript of every phase including failures, run from inside
the directory with the output copied verbatim. The transcripts there are
the pipeline patterns in their fullest form: environment builds, port
publishing, .env handling, and the deploy phase, with the security
notes for each. Cite the example for a project shape rather than
re-deriving it here.