The catalogue

The catalogue is the single document a tool name resolves against: one signed JSON file, format 3, served from the index. It is not a product listing and not a registry mirror — it is a security policy, and the two words are worth keeping straight. The catalogue is the signed file kapsl fetches; the Index is the whole of index.kapsl.sh — that catalogue, the vulnerability documents scans use, and the site that presents them.

What it is

latest.json                 the pointer: version, the payload's sha256, signed_at, url
latest.json.sig             the signature — base64 ECDSA P-256, what the client verifies
payload/<year>.<week>.<n>.json   the catalogue itself, immutable, cached for a year

The pointer and the payload are separate because one of them is mutable and one is not: latest.json moves on every publish, and each payload/…json is written once and never touched again (served with max-age=31536000, immutable). Verification is all-or-nothing: the public key is compiled into the binary — a key fetched from the host whose data it authenticates would prove nothing — the signature is checked over the pointer's exact bytes before a single field is read, and the pointer's sha256 then covers the payload. A stale pointer is also watched: a warning, never a refusal, when the signature is older than 30 days, because a quiet publishing fortnight must not stop the tool working but a stuck publisher should be visible.

The payload carries no versions, no digests, no dates. The schema has no temporal axis; that is the format's defining property, not an omission. The catalogue answers "what may this name do"; "which version is this, and when was it built" is answered by the index site and the registry, and by kapsl --info on your machine. Always current is why the split exists.

Scale, as of catalogue 2026.35.13 (the version these docs were checked against): 59 published projects, 241 tools, 90 scanned images, with a further 30 projects declared but not yet published (ansible, awscli, azure-cli, claude-code, helm, jupyter, poetry, ruff, typescript, openssl, and others — declared means reviewed in the index repo, built on publish). The index site shows the live number; this line is dated so its staleness is visible in the text.

What a tool entry is

Not a name→image mapping. An entry is the tool's declared boundary — everything the run is allowed to grant it, and nothing else. The wire keys:

KeyMeaning
binthe binary to exec, relative to the image's prefix
dthe one-line synopsis --search and --info show
capcapabilities granted automatically — net, rw, … and the CWD postures ro / nomount (at most one of nomount/ro/rw per tool)
secthe seccomp network class (net-denied / net-connect / net-listen); the live payload is 100% default, which --info reports as "no class declared" — security model
reqhard runtime dependencies, walked transitively — flake8 requires python, so kapsl flake8 mounts Python under it automatically
defconvenience dependencies composed in for the primary tool only
envvariables set inside the container, {prefix}-expanded
passhost variable names forwarded in, explicitly
mountshost paths mounted in, each {path, kind, readonly} — the only way a tool reaches your machine outside the CWD
argsper-subcommand argv defaults (appended for that subcommand)
portsper-subcommand port publishes (always is the exception that has to justify itself)
subper-subcommand overrides of the above — replace, not merge

Two real entries, verbatim from payload 2026.35.13:

{"bin":"bin/flake8","cap":["ro"],"d":"Check Python source for logic errors, PEP 8 style and complexity","env":{"PYTHONPATH":"{prefix}/lib/python3.14/site-packages"},"pass":["PYFLAKES_BUILTINS"],"req":["python"],"sec":"default"}
{"bin":"bin/ssh","cap":["net","ro"],"d":"OpenSSH remote login client","def":["bash"],"mounts":[{"kind":"dir","path":"~/.ssh","readonly":true},{"kind":"file","path":"~/.ssh/known_hosts","readonly":false}],"sec":"default"}

The flake8 entry is the whole security story in one line: a read-only view of the working directory, no network, no mounts — a linter that cannot phone home or read your dotfiles, so an editor subprocess that runs it cannot either (IDEs and linters).

The ssh entry is the other half: the one tool you want reaching your keys. Note the split in the mounts — the key directory ~/.ssh read-only, known_hosts writable. ssh reads your identities and never modifies them, but it must record new host keys; that is the difference between a working git push and one that writes to a scratch file. The declaration is reviewed at build time and travels in the signed document, so the grant is auditable before it is ever exercised:

kapsl --info ssh
  ■ INFO       ssh

▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
   OpenSSH remote login client
 
   IMAGE        ghcr.io/kapsl-sh/openssh-ssh:latest
   digest       sha256:1e91…2817   11 MB · 1 layers
 
   LINES        RESOLVES TO
 @latest      10.2p1            in use
                lines float — kapsl carries security updates onto them
                pin one with ssh@<version> · a digest never moves
 
   CAPS          net   ro 
   SECCOMP      net-listen (no class declared)  (tier: default)
 
   SOURCE       https://www.openssh.com/portable.html
 
────────────────────────────────────────────────────────────────────────────────
   RUN   kapsl ssh      SHIM   kapsl -i ssh

Per-tool granularity is the only granularity that can be correct: one image ships many tools (coreutils ships ninety-six of them), so an image-wide answer could only say "something in here writes", which is not a boundary anything can enforce.

Package rules

The catalogue also carries package_rules — the per-package runtime grants for packages you install with -e, which no image declaration can know in advance. The full current set, nine rules:

ProviderPackageGranted
piphuggingface_hub~/.cache/huggingface directory mount + HF_TOKEN, UV_NO_HF_TOKEN forwarded
piprequestsnet
piphttpxnet
pipaiohttpnet
pipflasknet
pipdjangonet
pipfastapinet
npmexpressnet
npm@nestjs/corenet

Two properties. The grant is keyed on (provider, package) — the narrowest place a credential can be declared: HF_TOKEN crosses the boundary only for a user who installed the thing that wants it, and it arrives with its opt-out, UV_NO_HF_TOKEN. And dotfiles a rule may mount are confined to ~/.cache/ and ~/.config/ — the client rejects anything else, so a rule cannot reach into the home directory. The caveat: a rule that matches nothing fires silently — a misspelled package or provider name is accepted, never errors, and simply never grants. Check a new rule by running the tool, not by reading it. Package providers covers the mechanism and project-scoped rules.

The index site

The catalogue is the policy; the index site is the evidence. It presents the same store the pipeline writes, and the docs never duplicate its data — anything about what tools exist, what versions they are, or what they carry, links there or to --info instead of restating it:

  • Per-project pages — the project's release lines, and per-tag findings for each image and architecture, with the published scan applied. A clean scan and an unscanned image never look alike: every artifact carries its scan state.
  • The assessed table — findings matched by a VEX assessment are shown as assessed, with the rationale from the document and a link to it. An assessment is a claim with a measured predicate, re-evaluated on every build; a stale one produces a finding, not a suppression.
  • The raw documents — every artifact's four pieces of evidence are published as-is: /sbom/ (the CycloneDX SBOM), /scan/ (the scan report), /vex/ (the OpenVEX assessment), /attestation/ (the SLSA provenance receipt). What Scanned before it runs describes as "the index already verified it", you can open and check.

Using it

All of this is reachable without leaving the terminal:

kapsl --search json
TOOL               IMAGE                                         DESCRIPTION
------------------ --------------------------------------------- ------------------------------
jq                 ghcr.io/kapsl-sh/jq:latest                    Command-line JSON processor

--search matches names and descriptions locally against the cached catalogue, ranked, capped at 50 rows (search_limit); your own [tools] entries are listed first and never capped.

--info prints the resolved entry — synopsis, image and digest, the version lines and what each resolves to, the declared capabilities and seccomp class, and the source URL. It is the per-tool answer to "what is this tool allowed to do", and it is what the index project page shows to everyone else.

man prints the tool's real upstream man page, not a capsule written about it:

kapsl man ls
  ■ MAN        ls(1)

▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
LS(1)                            User Commands                            LS(1)

NAME
       ls - list directory contents

SYNOPSIS
       ls [OPTION]... [FILE]...

DESCRIPTION
       List information about the FILEs (the current directory by default).

--update refreshes the catalogue now — one conditional GET of the pointer, a payload download only if the version moved:

kapsl --update
  ■ SUCCESS    tool catalogue already up to date · v2026.35.16

The background check does the same thing on schedule (Always current); --update is the explicit form, and it is the repair path the unknown-tool error points at.