How a run works

This page follows one invocation, start to finish. Any tool works the same way — kapsl jq below is the smallest — and each step says where the decision is made and where it is recorded, so when a run surprises you, you know which step to look at.

1. Resolve the name

A tool name resolves from exactly two places, in order:

  1. your own configuration — a [tools] entry in ~/.config/kapsl/kapsl.toml (tool mappings);
  2. the signed catalogue — the one document described in The catalogue.

Nothing else. There is no fallback that guesses a registry path: a name in neither place is an error, not a silent pull.

kapsl zzznotatool --version
  ■ ERROR      Unknown tool 'zzznotatool' — not in the tool catalogue.
               kapsl --search zzznotatool    find similar tools
               kapsl --update                refresh the catalogue

The catalogue is a pointer and an immutable payload, served from the index. The client trusts a key compiled into the binary: the pointer's signature is verified over its exact bytes before a single field of it is read, and the pointer's sha256 covers the payload it names. A background check refreshes it with a conditional GET — a 304 costs one header and changes nothing — so resolution stays current without an update command. The catalogue has the full account.

Resolution returns more than an image reference: it returns the tool's declared boundary — capabilities, seccomp class, dependencies, mounts, environment. That is what --info prints, and it is the whole point of the catalogue being a security document rather than a name→image table:

kapsl --info flake8
  ■ INFO       flake8

▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
   Check Python source for logic errors, PEP 8 style and complexity
 
   IMAGE        ghcr.io/kapsl-sh/flake8:latest
   digest       sha256:9b84…3d46   1 MB · 2 layers
 
   LINES        RESOLVES TO
 @latest      7.3.0             in use
                lines float — kapsl carries security updates onto them
                pin one with flake8@<version> · a digest never moves
 
   CAPS          ro 
   SECCOMP      net-listen (no class declared)  (tier: default)
 
────────────────────────────────────────────────────────────────────────────────
   RUN   kapsl flake8      SHIM   kapsl -i flake8

ro — a read-only view of the working directory, and nothing else. No net, no mounts, no dotfiles. flake8 gets exactly what the catalogue says it gets, because that is all it is allowed to ask for.

2. Pull if needed

If the resolved image is not local, it is pulled — and only from the registry the catalogue names, so the bytes that land are the bytes that were published, scanned, and signed.

3. Freshness: the quiet re-pull

A cached image on a floating tag (python:3.14, rg:latest) is re-checked against the registry on a freshness window — 7 days by default, image_max_age_secs. A stale image is re-pulled before the run, best-effort: if the registry is unreachable, the cached image runs and the scan gate below is the security backstop, not the freshness window. This is how a tool stays current with no update command at all — Always current is that whole mechanism.

4. Scan the composed set

Before the tool runs, everything the run will execute is scanned: the tool's image, the images of any composed tools, an environment built for -e packages, and the base — merged into one CycloneDX document and run through grype in its own container. A first run of an uncached tool shows the shape:

kapsl mkdocs --version
  ■ PULLING    ghcr.io/kapsl-sh/mkdocs:latest
  ■ PULLED     ghcr.io/kapsl-sh/mkdocs:latest · 1.7s
  ■ SCANNING   mkdocs · reading 4 inventories
  ■ SCANNING   mkdocs · 4 images
mkdocs, version 1.6.1 from /kapsl/dist/mkdocs-1.6.1/lib/python3.14/site-packages/mkdocs (Python 3.14)

Four images — mkdocs itself, the Python it requires, the environment, the base — four SBOM inventories merged into the one document that describes the container about to run. Findings are judged against a three-band policy: by default, CRITICAL findings stop and ask, lower findings print a line and run on. A scan that cannot run fails the run — it never reports zero. Scanned before it runs walks the whole gate, including what an answer of y persists.

On a warm cache the scan step is a lookup of the cached verdict (trusted for 24 hours, security_scan_max_age_secs), and the run is silent and sub-second — the output of the command above is just the version line.

5. Build the container

The container is built from the zero-trust default posture, with grants applied only where the resolved boundary or your flags say to:

What the container getsHow it is enforced
No network--network=none
Your directory, read-onlythe CWD mounted at /kapsl/workdir as :ro; the image filesystem read-only
No capabilities--cap-drop=ALL
No privilege escalation--security-opt=no-new-privileges
Your uiduid mapping, so files the tool writes are owned by you (--userns=keep-id on the podman engines that have it; rootless podman's default mapping does the same)
An empty homeHOME=/kapsl/home on an ephemeral tmpfs
The seccomp floorthe always-denied syscall baseline, on every process

The grants a tool earns at resolution are applied here: ssh's declared net and its ~/.ssh mount are why kapsl git push works without flags, and curl's is why kapsl curl can fetch. Your flags beat the declaration; the declaration beats the default. The full boundary — every layer, and what is not contained — is The security model.

6. Run the tool

Catalogue tools are composable images: built to a prefix (/kapsl/dist/<name>-<version>) so that the binary's compiled-in library paths and data paths point at that absolute prefix, and shipped FROM scratch containing only that prefix's files. Running one mounts the image read-only into a blank base at exactly the path its sh.kapsl.prefix label declares, and execs the binary there — the files land where the binary expects them, and the base supplies only what no tool bundles: the passwd entry, CA certificates, terminfo.

kapsl mkdocs above is one tool mounted that way. Composition is the same mechanism used to mount other tools into the same container: kapsl -e ripgrep,jq bash puts rg and jq's images into one bash session, each at its own prefix, one namespace, one posture. A composed tool's own declared capabilities and mounts apply to it — a composed ssh still needs the network and the keys, a composed ls still gets neither. One accepted trade: a multi-tool image such as coreutils ships ninety-some GNU tools in one prefix, so composing any one of its names exposes the rest.

7. Exit

The container is gone when the tool exits — no daemon, no state directory, nothing to clean up except the caches. Two places on disk survive, and they are deliberately different things:

  • ~/.cache/kapsl — everything regenerable: environment images, the grype vulnerability database, the catalogue cache, freshness stamps, scan verdicts. Delete it and the next run rebuilds.
  • ~/.local/share/kapsl — the human decisions: accepted CVE sets, trusted projects. Small and precious; kapsl --clean decisions is the only thing that touches it, and it asks first.

A second run of the same command is the warm path: resolution from the cached catalogue, no pull, a cached scan verdict, and the tool. The container still vanishes; only the verdict and the stamp move forward.

Where to go next