Package providers

The theme: a declaration, not a state

A dependency set is a declaration you can re-derive, not state you maintain. Name the packages — inline or in a file — and kapsl builds the environment, caches it by content hash, scans it, and runs. Same declaration, same bytes. Delete the cache and it is rebuilt identically; the machine's state is irrelevant to what the build is.

Name the difference to the alternatives, because it is the point:

  • A Containerfile is a build recipe for an image you own and age. Somebody has to remember to rebuild it, push it, re-pull it, and the image's packages drift from the project's manifest the moment they stop being edited together. The recipe and the running bytes are two things.
  • A native package environment is host state someone else's tool mutated. A global site-packages, a node_modules you forgot about, a venv you must source, an image you must remember to rebuild — each one is a fact about this machine that the project directory does not record, and therefore cannot reproduce.

Neither is re-derivable from the project directory alone. The requirements.txt plus the runtime name is, and that is the entire mechanism.

The three forms

Inline

Packages after a colon, run the tool with them:

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

The first run builds (57 s on this machine, including the scan of the whole composed set — 13 images, the tool and the environment); every subsequent run starts from the content-hashed cache in under three seconds. The environment image's tag is the declaration: kapsl-env-python-3.12:uv-ad44534d is the runtime, the provider, and the hash of what was installed.

File-based

-e @provider:file loads the dependency set from a manifest; the provider is always named explicitly:

kapsl -e @pip:requirements.txt python -c "import yaml; print('pyyaml', yaml.__version__)"
  ■ BUILDING   kapsl-env-python-latest:pip-df4be484
  ■ BUILDING   installing · 0%
  ■ BUILDING   exporting kapsl-env-python-latest:pip-df4be484 (534 KB)
  ■ BUILDING   cataloguing kapsl-env-python-latest:pip-df4be484
  ■ BUILDING   signing kapsl-env-python-latest:pip-df4be484
  ■ SCANNING   python · reading 13 inventories
  ■ SCANNING   python · 13 images
pyyaml 6.0.2

Note what the file did that the inline form could not: requirements.txt pins pyyaml==6.0.2, and the environment got 6.0.2 — the inline pyyaml above resolved to 6.0.3. The manifest is the declaration, and pins are part of it.

Omit the filename (keep the provider) and kapsl auto-detects the right manifest in the current directory — -e @pip finds requirements.txt, -e @npm finds package.json. And --watch watches the file and rebuilds on change, so an editor saving requirements.txt updates the environment the next run picks up:

kapsl --watch -e @pip:requirements.txt python script.py

Shebang

A script that is its own environment:

#!/usr/bin/env -S kapsl [email protected]:pyyaml
import yaml
print("pyyaml", yaml.__version__)
chmod +x script.py
./script.py
pyyaml 6.0.3

0.6 s once the environment is cached. Anyone with kapsl on the machine can run the script — no virtualenv setup, no pip install. One mechanical note, because the binary dispatches on argv[0]: the shebang must name the binary itself (CLI mode is "argv[0] is my own name"; a symlink with any other name is a shim for the tool of that name). On a machine where the binary is still spelled boks, the line says boks.

The built-in providers

The registry is a curated name-to-builder table in boks-core/src/env/mod.rs. Seventeen builders, reached through the package-manager name and the runtime name where the two differ:

Provider(s)BuilderEcosystem
pip, uv, pythonpip / uvPython
npm, nodenpmJavaScript/TypeScript
yarnyarnJavaScript/TypeScript (yarn.lock)
apt, ubuntuaptDebian/Ubuntu packages
poetrypoetryPython (poetry.lock)
maven, java, javac, mvnmavenJVM
gem, rubygemRuby
cpanm, perlcpanmPerl
cargo, rustccargoRust
cabal, ghccabalHaskell
composer, phpcomposerPHP
go-install, gogo-installGo
renv, rrenvR
hex, elixirhexElixir
luarocks, lualuarocksLua
galaxy, ansibleansible-galaxyAnsible collections

The alias rule is deliberate: the name the user types for the runtime (python, node, ruby, go, r, lua, php, java, perl, rustc, ghc, mvn, javac, ubuntu) resolves to the same builder as the package manager's own name, so tool:packages inline form and -e @name agree on what "install" means. One alias is load-bearing and worth the footnote it carries in the code: python routes to uv, not pip. The reason is lockfiles, not speed — every other ecosystem records what it resolved, so an unpinned manifest is still reproducible; plain pip produces no resolved set at all, and requirements.txt is a manifest rather than a lock. uv can emit one (uv pip compile), so routing the default through uv is what lets Python join the rest of the design rather than being permanently exempt from it. -e @pip remains available and unchanged for anyone who wants plain pip — the inline form above is uv under the hood, the file-based form is pip, and both are the same mechanism with different install commands.

Custom providers

A provider is almost entirely data: the only thing that varies between built-ins is the install command. So a custom provider is two templated commands in kapsl.toml, no Rust, no plugin system, no separate process:

[package_providers.mypm]
install_command_inline = "RUN mypm add {packages}"
install_command_file   = "RUN mypm install -r /tmp/{file}"

{packages} is the space-joined inline list; {file} is the copied manifest's filename. A typo in a placeholder fails at config load rather than silently installing nothing. The containment invariants are structural, not policy checks — the Dockerfile is assembled in a fixed shape and the template goes into exactly one slot:

FROM <kapsl-resolved base>      ← kapsl chooses this, never the template
[COPY <file> /tmp/<file>]      ← only for file-based installs
USER root                      ← kapsl injects, so installs can write system dirs
<template>                     ← the provider's install command, verbatim
USER 1000                       ← kapsl injects LAST, so the image runs unprivileged

The base is kapsl's resolved one (same image, same freshness window, same digest-based cache invalidation); USER 1000 is always the final line, so the built environment runs unprivileged; and the template cannot add --privileged or host mounts, because a Dockerfile has no such directives. A user provider also cannot alias a built-in name — [package_providers.pip] is rejected at config load rather than silently overriding the curated provider.

And the honest line, from the code that implements this:

The security boundary is NOT "can the user run arbitrary install commands" — every built-in provider already does that via RUN as root in podman build, and the user already controls their [tools] image and --skip-scan. The boundary is: the scan gate still runs on a user-provider env, a user provider can't alias a curated name like pip, and there's no backdoor into host-path mounts beyond the index-only package_rules

A custom provider is invoked explicitly (-e @mypm:file or -e @mypm tool:pkg1,pkg2); it is never inferred from an image name and has no default manifest filenames. It also has no index entry, so it participates in no package_rules — no package-driven dotfile mounts or capability auto-enablement, the same position as the manifest-DSL built-ins (gem, hex, luarocks).

Composition

Environments are not islands; the compose set is part of the declaration:

  • req is walked transitively. flake8 declares requires = ["python"]kapsl flake8 runs with python present, with no -e anywhere, because the hard dependency is a property of the built artifact that the client resolves. The --pull output on the CI page shows the shape: mkdocs prepared four images — base, git, mkdocs, and python:3.14 — the last one transitive.
  • def is the primary tool's convenience set, applied only to the primary: what the agent-style tools compose for their shell environment (the agentic page walks claude's fifteen composed tools).
  • [groups] name a set and grant nothing. The built-in coreutils group expands to ~95 tool names and is what makes a bare kapsl bash work — bash's compose default is the group, and the group members are individually declared (most ro, a few that write). A user group shadows a built-in of the same name, and groups cannot nest.
  • The -e segment form composes tools into the environment's container: -e git,python:flake8 vim runs vim with git and a flake8-bearing python mounted into the same container, each tool at its own declared boundary.

Package rules

Per-package runtime grants live in the signed catalogue's package_rules — grants keyed by (provider, package), because a grant that no image declaration could know belongs to the package the user asked for at run time, not to the image it lands in. The full current set (catalogue 2026.35.16, nine rules):

ProviderPackageGrantWhy (from the catalogue's own comments)
piphuggingface_hubmount ~/.cache/huggingface (dir); pass HF_TOKEN, UV_NO_HF_TOKENthe hub client re-downloads multi-GB weights every run without the cache; the token is a credential and a package rule is the narrowest thing that can carry it
piprequestscap: net"might make an outbound call" — the package exists to make requests
piphttpxcap: netsame
pipaiohttpcap: netsame
pipflaskcap: neta framework is useless without network — every quickstart's first command is a dev server
pipdjangocap: netsame
pipfastapicap: netsame
npmexpresscap: netsame reasoning, npm side
npm@nestjs/corecap: netsame

Dotfile grants in a rule are confined to ~/.cache/ and ~/.config/ — the client rejects anything else, so a rule cannot reach into the home directory. A project can carry its own package_rules in the .kapslrc for internal packages that will never get a catalogue entry — the same shape, project-scoped, trust-gated.

The caveat, from the file's own header comment, and worth internalizing before adding a rule: a rule that does not match fires silently. An unrecognised field name is ignored rather than refused, and an unrecognised provider is accepted too; neither shows up as an error, only as a grant that never arrives. Check a new rule by running the tool, not by reading it.

What "declarative" buys you, concretely

  • Scanned. The environment is part of the composed set: the 13-image scan in the inline capture above includes everything pyyaml pulled, before it ever runs. Transitive CVEs are findings with a version, not a hope — native package managers run the resolve and never look back.
  • Reproducible. The content hash in the tag is the reproducibility claim: same declaration, same base digest, same bytes. Pin the base digest for exact reproducibility of the runtime itself.
  • Disposable. kapsl --clean removes every environment image and package-cache volume; the next run rebuilds from the declaration. There is no un-do of a pip install to track down, because there is no install to un-do.
  • Reviewable. The project's dependency file goes through the same trust review as everything else in a .kapslrc: first run lists what the env_file would install and what grants its packages trigger via the rules, tagged with the source, and a human says yes. A requirements.txt that adds requests is a network grant, and the review says so.

Caching and lifecycle

Where things live (the split the --status output on the CI page makes visible): ~/.cache/kapsl holds the regenerable — environment images, package caches, the scanner database, the catalogue cache — and ~/.local/share/kapsl holds the decisions. --clean (default targets) removes the environment images and caches and never touches base images or decisions.

The base-image freshness loop closes the loop: floating base tags re-pull on the freshness window (default 7 days), and the environment hash covers the base digest — so when the base moves, the next run builds a fresh environment on the new base, automatically. The known open item, stated as such: a rebuild triggered by the base moving re-resolves the package spec, which can pick up newer package versions. Package-set replay is planned — record the resolved set when an environment is first built, so a rebuild reuses the same versions instead of silently re-resolving, with re-resolution triggered only by a scan failure naming a locked package or an explicit request. Until it ships, pin exact versions in the spec if you need cross-rebuild package stability. --watch (above) is the development-side half of the same lifecycle.

The dogfood: this site builds with kapsl zola

The docs site you are reading builds through the product: kapsl zola build and kapsl zola serve, with the project overlay in this repo's .kapslrc. The whole deployment is the six lines under tools:; the rest of the file is the reasoning, which is the point:

tools:
  zola:
    subcommands:
      serve:
        ports: ["1111"]
        args: ["--port", "1111"]

The mechanism is the per-subcommand args/ports the catalogue carries: the zola index entry supplies --interface 0.0.0.0 and the 1111 port publish for the serve subcommand (the image's own labels dropped them in the 0.23 rebuild, so the index carries them), and the project overlay pins the listen port on the command line as well — so the port zola binds and the port the index publishes cannot drift apart, and it must not repeat --interface, because the index already supplies it and clap rejects the argument a second time. Keyed on serve, zola build and zola check get neither the port nor the bind address — they never asked to be a server. That is the per-subcommand pattern the catalogue page describes, doing real work: a default that is the exception, and an exception that justifies itself.