Quickstart

Five minutes, standalone tools only: install the binary, run a tool you already know, and watch the boundary fail and the grant fix it. Environments get their turn in the last section; before that, every command is a single published tool.

Prerequisite: a container runtime

kapsl drives Podman (recommended — rootless is fine) or Docker. If podman is installed it is used; otherwise kapsl falls back to Docker automatically, and --runtime forces a choice either way (runtime choice).

On macOS the runtime runs in a small VM, started once:

brew install podman && podman machine init && podman machine start

On Linux, podman or docker from your distro's packages is enough.

Install

$ curl -fsSL https://kapsl.sh/install | sh

Detects the platform, downloads the release binary for it, verifies the SHA-256 checksum, and installs to ~/.local/bin — printing a PATH hint if the directory is not on your PATH. Manual targets, building from source, and the runtime knobs are in Installation.

First command

$ echo '{"name":"kapsl"}' | kapsl jq .name
"kapsl"

It works in pipelines: stdin in, stdout out, exit codes pass through unchanged. The first run of a tool on a machine pulls and scans its image (tens of seconds); every run after is sub-second.

What just happened

Four things, in order — the full walk-through is how kapsl works:

  1. Resolve. jq is looked up in your local configuration, then in the signed catalogue. Nothing is installed on the host.
  2. Pull. The tool's image is fetched once per machine and cached.
  3. Scan. The image is vulnerability-scanned in its own container before it runs. CRITICAL findings — or a scan that cannot run — stop the run.
  4. Run, then vanish. No network, the current directory mounted read-only, all capabilities dropped, no privilege escalation. When the tool exits the container is gone; only regenerable caches and your recorded decisions persist.

Find tools

Search runs locally against the cached catalogue, ranked:

$ kapsl --search dns
No tools found matching 'dns'.

That is the honest answer, not an error: DNS tooling (bind9 and friends) is declared in the catalogue and building, not published yet. A query with hits:

$ kapsl --search py
TOOL       IMAGE                                   DESCRIPTION
---------  --------------------------------------  --------------------------------
pylint     ghcr.io/kapsl-sh/pylint:latest          Static code analyser for Python
pyreverse  ghcr.io/kapsl-sh/pylint:latest          Generate UML class and package diagrams from Python code
pytest     ghcr.io/kapsl-sh/pytest:latest          Run a Python test suite and report the results
python     ghcr.io/kapsl-sh/python:latest          The Python programming language interpreter
dmypy      ghcr.io/kapsl-sh/mypy:latest            mypy as a long-lived daemon, for repeated checks of one tree
mypy       ghcr.io/kapsl-sh/mypy:latest            Optional static typing for Python

--info shows the tool's synopsis and the sandbox it will run in — the boundary is declared per tool, in the signed catalogue:

$ kapsl --info curl
  ■ INFO       curl

▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
   Transfer a URL

   IMAGE        ghcr.io/kapsl-sh/curl:latest
   digest       sha256:e73d…5180   20 MB · 1 layers

   LINES        RESOLVES TO
 @latest      8.18.0            in use
                lines float — kapsl carries security updates onto them
                pin one with curl@<version> · a digest never moves

   CAPS          net   rw
   SECCOMP      net-listen (no class declared)  (tier: default)

   SOURCE       https://curl.se/

────────────────────────────────────────────────────────────────────────────────
   RUN   kapsl curl      SHIM   kapsl -i curl

CAPS net rw is curl's measured boundary: it is a network tool that writes, so both are granted; a tool that declares ro gets the read-only CWD and nothing else. kapsl man ls prints the tool's real upstream man page — the same ls(1) you would read after a native install, without the install:

$ kapsl man ls
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).
       Sort entries alphabetically if none of -cftuvSUX nor --sort is
       specified.

The captured page is the full GNU coreutils ls(1) — every option, the SIZE and TIME_STYLE references, the exit codes — served from the published image.

Versions

$ kapsl [email protected] --version
Python 3.12.14

$ kapsl node@22 --version
v22.23.2

Both are multi-line tools: @3.12 resolves to the newest published build of that line, and the lines float — the LINES table in --info says so plainly, and so does the line beneath it. Pin a version to reproduce a run; pin a digest to freeze the bytes. You never upgrade, and you don't have to: how currentness works.

Security in action

Two demonstrations you can run and watch fail. The rule behind both: a tool's boundary is declared in the signed catalogue, and --cap overrides it — ro and nonet tighten a declared grant away; rw and net grant one.

The working directory

The CWD is the only filesystem a tool ever sees. dash is the catalogue's POSIX shell, and it declares rw — a shell's redirects are its job, so plain kapsl dash -c 'echo x > file.txt' already writes. --cap ro tightens that declared grant back to the read-only mount, and the kernel answers:

$ kapsl --cap ro dash -c 'echo x > file.txt'
/kapsl/dist/dash-0.5.12/bin/dash: 1: cannot create file.txt: Read-only file system

The grant:

$ kapsl --cap rw dash -c 'echo x > file.txt && cat file.txt'
x

For the tools that declare rols, rg, jq, grep, flake8, most of the read-only coreutils — the read-only CWD is the default itself, and --cap rw is what turns that EROFS into a write.

Network

curl declares net (the CAPS line above), so plain kapsl curl reaches the network. The negation forces a declared grant off:

$ kapsl --cap nonet curl https://example.com
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed

  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0curl: (6) Could not resolve host: example.com

No socket was ever opened — that is curl's ordinary "cannot resolve host" failure, exit 6. For tools that declare no network — python, node, the linters — --cap net is the equivalent grant:

$ kapsl --cap net curl -s https://example.com
<!doctype html><html lang="en"><head><title>Example Domain</title><link rel="icon" href="data:,"><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{background:#eee;width:60vw;margin:15vh auto;font-family:system-ui,sans-serif}h1{font-size:1.5em}div{opacity:0.8}a:link,a:visited{color:#348}</style></head><body><div><h1>Example Domain</h1><p>This domain is for use in documentation examples without needing permission. Avoid use in operations.</p><p><a href="https://iana.org/domains/example">Learn more</a></p></div></body></html>

Make it native

A shim is a symlink at ~/.local/bin/jq pointing at the kapsl binary. On invocation the binary reads argv[0]: called jq, it runs jq — containerized, with jq's declared boundary. No wrapper script, no shell function; the rest of your PATH is untouched.

$ kapsl --install jq
  ■ INSTALLED  jq → ~/.local/bin/jq

$ kapsl --list
Installed shims (1):
  jq

From now on, bare jq is the confined jq:

$ jq --version
jq-1.8.1

The full mechanics — argv[0] dispatch, KAPSL_ARGS, removing shims — are in shims.

One taste of environments

Append :packages and kapsl builds the tool plus those packages as one environment — built once, cached by content hash, scanned, and reproducible by the same line on any machine:

$ kapsl [email protected]:pyyaml --version
Python 3.12.14

$ kapsl [email protected]:pyyaml -c 'import yaml; print("pyyaml", yaml.__version__)'
pyyaml 6.0.3

The first build takes a couple of minutes; the cache makes it a one-time cost. Nothing on the machine learned about pyyaml — the declaration is the environment, and deleting the cache rebuilds it identically. Declarative packages, in depth.

Next: Installation for the other ways in and the runtime knobs, and how kapsl works for the pipeline end to end.

Pages in this section

  • Installation The installer script, manual install targets, building from source, runtime choice, and uninstallation.