Troubleshooting
The error you see is the diagnosis. Every failure below is a message the binary actually prints, and the fix is in the message — this page is the map from message to cause.
kapsl: command not found
The install script puts the binary in ~/.local/bin, which is not on every shell's PATH.
echo $PATH | tr ':' '\n' | grep -c "$HOME/.local/bin"
# 0 → not on PATH
Add it to your shell's rc file:
# bash / zsh
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
Or install shims instead — they live in the same directory but give you plain tool names:
kapsl -i jq
kapsl -i rgpodman not found on PATH
No container runtime. kapsl runs tools in containers and needs one:
$ kapsl jq --version
▍ ■ ERROR podman not found on PATH.
▍
▍ kapsl runs tools in containers and needs a container runtime.
▍ Install podman:
▍ brew install podman && podman machine init && podman machine start
▍ or see https://podman.io/docs/installation for your platform.
▍ Then try again.
The hint is macOS-shaped because that is where the install is three steps. On Linux, podman on PATH is the whole requirement (rootless). On Windows, the story is WSL2 — see Windows.
Podman machine not running (macOS)
On macOS the engine runs in a VM, and a stopped VM looks like a missing runtime to the tools that would use it:
podman machine start
If podman machine list shows no machine at all, run the podman machine init from the hint first.
Podman Desktop
If you installed Podman Desktop instead of the CLI, the CLI is bundled but not on PATH. Either enable "Add to PATH" in Podman Desktop's settings, or run the init/start commands from the hint above.
macOS: a path the VM cannot see
The podman machine on macOS only sees a fixed set of shared paths (by default /Users, /private, and /var/folders, over virtiofs). Working from anywhere else and the run fails before the container starts:
$ kapsl jq --version
▍ ■ ERROR /Volumes/data/projects is not shared with the podman virtual
▍ machine. Shared paths: /Users, /private, /var/folders.
▍ Run kapsl from a shared path, or add this path to the
▍ machine's shared volumes (see `podman machine init --volume`).
Two fixes, in order of preference:
- Run from a shared path.
cdinto~/projects/...and run there. This is also the fast path — virtiofs access from inside the VM is much cheaper than the alternatives. - Share the path with the machine. Add it when creating the machine, or add volumes to an existing one and restart it (
podman machine init --volume/podman machine set --volume).
The quieter variant of the same problem is an advisory rather than an error: if the run later fails with statfs ... no such file or directory, the mount the container asked for is outside the shared set — same two fixes.
A run the scan stops
The scan runs on every invocation by default, and it has three tiers (see Scanning): findings at or above security_scan_deny abort outright, findings at or above security_scan_prompt ask, and anything below notifies on one line. The current defaults are deny = "never" and prompt = "critical" — so on a fresh setup, HIGH and below is a one-line notice and only CRITICAL stops to ask.
On a terminal, the prompt is:
Continue? y (always)/N/d (details)/o (once)
y— accept and remember: the run continues, and the acceptance is recorded so the same findings (or a subset of them) do not re-ask.N(or Enter, or anything else) — decline. The transcript prints■ BLOCKED — Stopped·the tool did not runand the run exits 1. The tool did not run; nothing it would have changed was changed.d— open the interactive findings ledger, then the question is asked again.o— accept this run only; nothing is recorded, so the next run asks again.
Without a terminal (CI, piped stdio, --non-interactive) there is no one to ask, so a required review fails closed instead of guessing:
▍ ■ FAILED C: 1 ... — requires review, but no terminal to ask on
▍ (run interactively, use --skip-scan, or adjust security_scan_prompt)
The escapes, in order of how much you mean them:
--skip-scan— skip the scan for this run. The one-shot form; it prints a warning so the transcript says why.- Adjust the thresholds —
[global] security_scan_prompt(andsecurity_scan_deny) inkapsl.tomlmove the line between "ask" and "notify". See Configuration File. - Accept the risk —
yat the prompt, or let the acceptance record carry it.
A scan that finds a clean result says nothing: the default posture is silent on success.
The trust prompt
A project's .kapslrc is less trusted than your own kapsl.toml — it arrives with git clone, not from your hands. The first time you run a tool in a project that declares an overlay, kapsl shows the declaration (one line per tool, exactly what it would grant) and asks, SSH-host-key-style:
Apply this project's overlay? y (always)/N/o (once)
y— always, until it changes. The acceptance is keyed against the file's sha256. "Until it changes" is literal: any edit to.kapslrc, however small, changes the hash and the grant goes back through review, exactly as if the project had never been trusted. That is the point — a project file that changes has changed its mind about what it wants, and the review is for the new text.o— once. This run only; the next run in the project asks again.N— decline. The run continues without the overlay, as if.kapslrcwere not there. Declining is not an error: the exit code is the tool's, and the tool runs at the boundary it would have had without the project file.
Without a terminal the same review fails closed to decline — the overlay is simply not applied, and the run proceeds. A .kapslrc that does not parse is different: that is a configuration error, it names the file and the position, and the run does not start (exit 1). See Project Config.
Environment build failures
An inline env (-e [email protected]:pytest) builds a small image on first use. When the build fails, the package manager's own output is in the transcript, boxed:
$ kapsl [email protected]:nonexistent-pkg-zzz-42 d.py
▍ ■ BUILDING kapsl-env-python-3.12:uv-aeb86a13
▍ ■ BUILDING installing · 0%
▍
▍ ■ BUILD OUTPUT ──────────────────────────────────────────────────────────────
▍ │ Using CPython 3.12.14 interpreter at: /kapsl/dist/python-3.12.14/bin/python
▍ │ × No solution found when resolving dependencies:
▍ │ ╰─▶ Because nonexistent-pkg-zzz-42 was not found in the package registry
▍ │ and you require nonexistent-pkg-zzz-42, we can conclude that your
▍ │ requirements are unsatisfiable.
▍ └────────────────────────────────────────────────────────────────────────────
▍
▍ ■ FAILED build kapsl-env-python-3.12:uv-aeb86a13
▍ ■ ERROR Failed to prepare inline environment for
▍ '[email protected]:nonexistent-pkg-zzz-42': the package manager exited
▍ with 1
Read the box first — the cause is in it (a typo in a package name, a version that does not exist, a resolver conflict). Three specific cases worth knowing:
- npm install scripts run. The npm build stage passes
--dangerously-allow-all-scripts: inside the build container the scripts cannot reach the host, so the flag's usual caution is already the shape of every container install, and the alternative — npm's default of block-and-warn — turns "this package needs a build step" into "install succeeded, binary silently absent". A native build that still fails (a package needing headers or a compiler the image does not carry) fails loudly in the box; that is the builder telling you what to compose in, not a kapsl bug. - A lockfile that does not match.
npm ciis used when a lockfile is present, and it is strict by design: a manifest/lockfile mismatch fails the build rather than silently re-resolving. If the same spec worked yesterday and does not today, a lockfile changed under you. - Offline. An env that was never built cannot be built with
--offline— installing packages needs network. The error says exactly that and tells you to build once without the flag. See Offline Mode.
Built environments are cached by content: the same spec is built once, and the cache is what makes the second run fast.
Disk
kapsl --status is the read-only view:
$ kapsl --status
kapsl storage
cache /Users/q/.cache/kapsl
decisions /Users/q/.local/share/kapsl
scanner 15 MB 6 entries
grype database 0 B 0 entries
tool catalogue 65 KB 2 entries
freshness stamps 2.8 KB 1 entry
run scratch 0 B 0 entries
decisions 223 MB 6 entries
total 238 MB
kapsl --clean reclaims it, by target:
| Target | What goes | Asks first? |
|---|---|---|
scratch | per-run scratch dirs | no (the default, with images) |
images | pulled tool images not in use by a current run | no (the default, with scratch) |
cache | scanner cache, catalogue cache, freshness stamps | yes |
all | scratch + images + cache | yes |
decisions | the trust ledger (accepted scan findings, attestations, the env-signing key) | yes — and it is never part of all |
Practical rules: --clean with the default targets is safe to run any time — it removes exactly what the next run would re-pull or re-scan. cache clears the scanner's work, so the next run re-scans; that is the price of the space. decisions is your recorded trust: cleaning it makes every previously-accepted scan finding and every trusted project ask again. Preview anything with --dry-run, confirm with --yes. An unknown target exits 2; a refused confirmation exits 1.
The first run is slow
The first run of a tool does four things a warm run never does: pull the image, pull and update the vulnerability database (the first time on a machine, roughly 300 MB), scan, and build any env. A real first-run capture of the database step alone:
▍ ■ PULLING vulnerability database (~300MB)
▍ ■ SECURITY vulnerability database built · 1m 27s
After that, the warm path is sub-second to a few seconds: the image is cached, the verdict is cached (24 h default), and nothing is fetched that does not need to be. If a later run is slow, --verbose shows where the time goes, and kapsl --pull <image> lets you pre-warm a specific tool.
Tools whose output depends on #!
kapsl runs the tool's binary, not your shell, so a script's shebang is what runs. If a tool behaves differently under kapsl than it does in your shell, check what the shebang actually invokes — #!/usr/bin/env bash needs bash in the image, and the images are minimal by design. The fix is to compose what the script needs:
kapsl -e bash coreutils@latest bash script.shOffline behaviour
--offline (or [global] offline = true) means no host-side network at all. Everything that needs something not already local fails fast with a message that says what is missing and how to get it:
| Missing | Message |
|---|---|
| Tool image | 'ghcr.io/kapsl-sh/X:latest' is not available locally and --offline is set, so it cannot be pulled. Run once without --offline to cache it first. |
| Env build | Failed to prepare inline environment for '...': ... not cached locally and --offline is set — cannot build it (installing packages needs network). Run once without --offline to build and cache it. |
| Catalogue refresh | --update needs network access; it cannot run with --offline |
The scan still runs, against the cached database — offline affects fetching, not the check. See Offline Mode.
Getting help
kapsl -h— the one-screen reference;kapsl -h <topic>for capabilities, environments, catalogue, scanning, output.kapsl --info <tool>— what a tool is going to do before it does it.kapsl --verbose/-v— milestones and progress, so you can see which step a slow or failed run was in.RUST_LOG=debug kapsl ...— kapsl's own debug log (paths it opened, decisions it made).RUST_LOGis also one of the variables forwarded into the run, so the tool sees it too.
For anything this page does not cover, file an issue in the kapsl repository — include the full transcript (it is designed to be self-explanatory) and the output of kapsl --status.