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:
- Resolve.
jqis looked up in your local configuration, then in the signed catalogue. Nothing is installed on the host. - Pull. The tool's image is fetched once per machine and cached.
- Scan. The image is vulnerability-scanned in its own container before it runs. CRITICAL findings — or a scan that cannot run — stop the run.
- 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 ro — ls, 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.