Configuration File
The configuration file is kapsl.toml. kapsl looks for it in:
| Platform | Default path |
|---|---|
| Linux / macOS | ~/.config/kapsl/kapsl.toml |
Override the path with KAPSL_CONFIG:
KAPSL_CONFIG=./kapsl.toml kapsl python --version
If the file does not exist, kapsl uses built-in defaults.
Full example
[global]
forward_ssh_agent = true
mount_git_config = true
protected_dotfiles = ["~/.ssh", "~/.gnupg", "~/.aws"]
# pass_env is ADDITIVE to the built-in defaults (RUST_LOG, NO_COLOR, TZ, CI,
# PYTHONUNBUFFERED, ...) — you do not need to restate those. Prefer naming
# variables exactly; a glob like "AWS_*" sweeps in credentials, and
# HTTP_PROXY can carry them in its URL.
pass_env = ["MYAPP_PROFILE"]
k8s_namespace = "tools"
[tools]
rg = "kapsl.sh/rg:latest"
python = { image = "kapsl.sh/python:3.12", command = "python3" }
# per-tool pass_env: additive to [global], and the opt-in for a protected var
aws-cli = { image = "kapsl.sh/aws-cli:latest", pass_env = ["AWS_SECRET_ACCESS_KEY"] }
[theme]
enabled = true[global] section
| Key | Type | Default | Description |
|---|---|---|---|
runtime | string | (auto) | Container engine: "podman" or "docker". Default: podman, falling back to docker when podman isn't installed. --runtime overrides per run |
offline | bool | false | Run every invocation as if --offline were passed — for a permanently offline host. See Offline Mode |
verbosity | string | "normal" | How much kapsl prints to stderr. "normal" (default) shows transient progress — a spinner while pulling/scanning/building, erased on completion, so a successful run leaves nothing behind. "verbose" keeps the output: ✔ READY / ✔ BUILT milestones persist, plus background info lines. "quiet" prints basically nothing. --verbose/-v and --quiet/-q override per run. Errors and user-actionable warnings always print. |
forward_ssh_agent | bool | true | Mount SSH_AUTH_SOCK into the container |
mount_git_config | bool | true | Mount ~/.gitconfig read-only into the container when git is present (primary, an inline-package install that pulls it in, or a composed tool) — not unconditionally into every container. Set false to suppress even when git is present. |
protected_dotfiles | list | ["~/.ssh", "~/.gnupg"] | Sensitive host dotfiles made visible but access-denied inside containers (reads fail EACCES, not ENOENT) when exposed via the working-directory mount. ~-expanded against $HOME; entries must start with ~/. A tool opts into a path by declaring it as a dotfile; set to [] to opt out of all defaults. See Security Layers → Sensitive dotfile protection. |
pass_env | list | [] | Glob patterns for host env vars to pass through, additive to kapsl's built-in defaults (see Environment Variables) — you do not need to restate RUST_LOG, NO_COLOR, TZ, CI and friends. Can also be set per-tool ([tools.<name>] pass_env = [...]), additive to this list. For a one-off, prefer -E NAME on the command line. |
protected_env_vars | list | ["AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_SECURITY_TOKEN", "GITHUB_TOKEN", "GH_TOKEN", "NPM_TOKEN", "DOCKER_PASSWORD"] | Env-var names a pass_env glob can never satisfy — the container-env analog of protected_dotfiles. A tool opts in via its OWN [tools.<name>] pass_env (naming it in [global] pass_env is not enough); set to [] to opt out of all defaults. See Environment Variables → Protected environment variables. |
protected_project_files | list | [".env", ".env.*", ".envrc"] | Project-tree FILES (matched by name directly under the CWD only, not subdirectories) a container may never read or write — the CWD-relative analog of protected_dotfiles. .env and variants hold local secrets; denying them (reads return empty, writes fail EROFS) prevents exfiltration as raw bytes, while a tool that needs the values opts in via its own dotenv. Entries must be CWD-relative (no leading / or ~/); set to [] to opt out of all defaults. See Security Layers → Project file (.env) protection. |
k8s_namespace | string | "default" | Default Kubernetes namespace for --k8s |
k8s_max_pod_seconds | int | 43200 (12h) | Hard wall-clock cap (activeDeadlineSeconds) on how long a --k8s pod may run — a backstop if the local kapsl process is killed before its own cleanup runs. See Kubernetes → Pod lifetime and cleanup |
security_scan_enabled | bool | true | Vulnerability-scan images before running them |
security_scanner_image | string | ghcr.io/anchore/grype:latest | Scanner container image (pin a tag to freeze it) |
security_scan_max_age_secs | int | 86400 | How long a scan verdict is trusted; 0 scans every run |
security_scan_deny | string | "never" | Findings at or above this severity abort the run outright, no review possible (critical, high, medium, low, never). Off by default — kapsl doesn't refuse to run a tool you asked for explicitly; set to critical to restore a hard block |
security_scan_prompt | string | "high" | Findings at or above this severity (below the deny threshold) show ONE combined summary across every image the run touches and ask Continue? y/N/d (details); fails closed without a TTY. high's "at or above" semantics already cover critical too. Same values as security_scan_deny |
security_scanner_image_max_age_secs | int | 604800 | How often the scanner image itself is re-pulled |
image_max_age_secs | int | 604800 | Freshness window for tool images. A floating tag (python:3.13, nmap:latest) cached locally for longer than this is re-pulled before a run, so tools stay current without an update command. 0 checks every run; a very large value disables it. Digest-pinned and local images are never refreshed. A failed refresh falls back to the cached image. See Freshness below. |
index_max_age_secs | int | 86400 | How often the global tool index + policy artifact are checked (a conditional GET — a new version is downloaded only if the host has one). The schedule is the last check, success or not; a name the index doesn't resolve is checked on a separate 30-minute schedule. Same "no --update needed" posture as image_max_age_secs. 0 checks every run; a very large value disables it. See Freshness below. |
Freshness
kapsl has no update or upgrade command — by design. A tool named without a
pinned digest is expected to stay current automatically. Tags like
python:3.13 and nmap:latest float forward on every upstream rebuild, so a
cached image goes stale unless something re-checks the registry.
image_max_age_secs (default 604800, i.e. 7 days) is that something. When a
run resolves an image that is already local but was pulled more than the window
ago, kapsl best-effort re-pulls it before scanning and running:
- Best-effort: if the refresh pull fails (no network, registry down), kapsl uses the cached image and continues — the freshness window is a convenience, never a blocker. The vulnerability scan remains the security backstop.
- Never refreshed: digest-pinned images (
tool@sha256:…, set viakapsl.toml) are immutable by construction, and locally-built environment images (kapsl-env-*) exist in no registry. Only registry-hosted floating tags refresh. - A failed scan also triggers a refresh: if a scan finds CRITICAL vulnerabilities in a floating tag, kapsl re-pulls the newest version once and, if the image ID changed (the tag was rebuilt), re-scans it — a rebuild may have fixed the issue. If the tag hadn't moved, or the refresh failed, the scan failure stands and the run is blocked.
Set image_max_age_secs = 0 to check for a newer image on every run (slower,
maximally current). Set it to a very large value to effectively disable the
window.
The same idea applies to the index itself: index_max_age_secs (default
86400, i.e. 1 day) governs the scheduled check of index.json/policy.json
— the data --search, --info, and tool resolution all read. The schedule
is keyed on the last check, not the last download: kapsl sends the cached
copy's ETag (a conditional GET), an unchanged host answers 304 and costs
nothing, and only a genuinely new version is downloaded. A failed check
retries on the same schedule rather than on every run. This check never
delays the command it's attached to: it's bounded to a few seconds and
simply abandoned — falling back to whatever's cached — if it hasn't finished
by the time the run would otherwise be done.
One schedule runs tighter: a tool name that resolves nowhere triggers a
check before the error, at most once every 30 minutes, since a newly
published index entry is usually the reason the name was missing. Skipped
entirely with --offline.
Unknown keys are rejected with an error — see Config Validation.
[theme] section
kapsl's own terminal branding: the orange left-bar status lines (always on,
host-side, not configurable here — see the security docs), plus a colored
PS1 and man-page (less/groff) color scheme injected into every
container.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Inject kapsl's colored PS1 and man-page (LESS_TERMCAP_*/GROFF_NO_SBIT) env vars into every container. Harmless for tools that never read PS1 or view man pages. Set false to opt out — e.g. if your own shell dotfiles already set PS1 and you'd rather kapsl not set it first. |
Both the default PS1 and the kapsl status lines automatically degrade for
the terminal they're running in: truecolor (COLORTERM=truecolor/24bit)
or a widely-supported 256-color fallback otherwise, and the ▍ bar glyph
or a plain \| when the host locale (LANG/LC_ALL) isn't UTF-8.
[tools] section
Maps tool names to container images. See Tool Mappings for the full syntax.
[tools]
rg = "kapsl.sh/rg:latest"
python = { image = "kapsl.sh/python:3.12", command = "python3" }[package_providers] section
User-defined package providers, so you can teach kapsl a package manager without a code change. Each entry is a pair of templated install commands; kapsl wraps them in the same build path a built-in provider uses (same base image, scan gate, and cache). See Custom Providers for the full model and worked examples.
[package_providers.mypm]
install_command_inline = "RUN mypm install {packages}"
install_command_file = "RUN mypm install -r /tmp/{file}"| Key | Required | Description |
|---|---|---|
install_command_inline | yes | Dockerfile RUN for an inline package list (tool:pkg1,pkg2). Must contain {packages}, replaced by the space-joined names |
install_command_file | yes | Dockerfile RUN for a file-based install (-e @name:manifest). Must contain {file}, replaced by the copied manifest's filename |
The provider name (the table key) must be ASCII alphanumeric and may not
shadow a built-in (pip, npm, apk, …). Both are enforced at load time,
along with the placeholder presence — a typo'd template fails loudly rather
than silently installing nothing.
[dev] section
Settings for working on kapsl rather than with it. Everything here changes where kapsl gets its own data from, so everything here prints a warning on every run.
[dev]
index_dir = "/Users/you/src/kapsl/kapsl-index"| Key | Required | Description |
|---|---|---|
index_dir | no | Directory holding a locally built index.json and policy.json, read instead of the fetched cache |
Point index_dir at a checkout's kapsl-index/ and your own index edits take
effect immediately, with no publish step. kapsl --update does not refresh it,
and it is under no obligation to match the published catalogue — a tool that
resolves for you may not exist for anyone else.
Why it warns every single run, and why that is not excessive. The index
decides which image every tool name resolves to, so an overridden one can point
kapsl python anywhere. The alternative people reach for instead — a symlink at
~/.cache/kapsl/index/index.json — is invisible, survives forever, and silently
outlives the checkout it points into. Both are detected and both warn:
▍ WARNING [dev] index_dir is NOT managed by kapsl — it resolves to
/Users/you/src/kapsl/kapsl-index. It will not be refreshed, and
may not match the published catalogue.
The failures this prevents all have the same shape — kapsl reading a different
file than you believe it is reading — and they surface a long way from the
cause. Real examples: hours of edits to an index that was never being read
because the symlink pointed at a different checkout; a "verification" that
passed against the wrong file and confirmed nothing; and a
catatonit: failed to exec pid1 that named neither the index nor the version
involved. A warning you only see once you already suspect the index is worth
nothing.
KAPSL_INDEX_DIR
Environment override for the same directory, taking precedence over
[dev] index_dir:
KAPSL_INDEX_DIR=/path/to/kapsl-index kapsl python --version
It exists for test suites and CI, which must not depend on whatever
happens to be in a developer's cache. kapsl's own integration tests set it to
the checkout's kapsl-index/, so a run resolves against the tree it was built
from and nothing else — the same commit then passes or fails identically on
every machine, and an index change is exercised by the suite in the commit
that makes it.
It warns like any other override, naming KAPSL_INDEX_DIR as the source so a
reader knows to look at the environment rather than the config file.
KAPSL_INDEX_URL
Environment override for the index host — where index.json and
policy.json are fetched FROM (as opposed to KAPSL_INDEX_DIR, which
changes where they are read from):
KAPSL_INDEX_URL=http://localhost:8080 kapsl --update
The value is a base URL; the artifacts are requested at
<base>/index.json and <base>/policy.json. It exists for the same reason
as KAPSL_INDEX_DIR — test suites and CI — so a suite can stand up a
local index server and exercise fetching, version changes, and the
conditional-GET path against it instead of the published
https://index.kapsl.sh.