Configuration File

The configuration file is kapsl.toml. kapsl looks for it in:

PlatformDefault 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
protected_dotfiles = ["~/.ssh", "~/.gnupg", "~/.aws"]
# pass_env is ADDITIVE to the built-in defaults (RUST_LOG, NO_COLOR, TZ, CI,
# POSIXLY_CORRECT, ... — the full list is on the environment-variables page)
# and to whatever the tool's own catalogue entry declares (python gets
# PYTHONUNBUFFERED from its entry, not from you). 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

Precedence

When the same setting is declared in several places, the most specific wins:

SourceScopeWins over
built-in defaultseverything—
[tools.<name>] in kapsl.tomlone tool, every projectdefaults
a trusted project .kapslrcone project[tools]
[enforce.<name>] in kapsl.toml sits above all of those, including a project's .kapslrc: it is a floor a project cannot override. A project overlay grants; [enforce] limits. See [enforce] below.

[global] section

KeyTypeDefaultDescription
runtimestring(auto)Container engine: "podman" or "docker". Default: podman, falling back to docker when podman isn't installed. --runtime overrides per run
offlineboolfalseRun every invocation as if --offline were passed — for a permanently offline host. See Offline Mode
verbositystring"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_agentbooltrueMount SSH_AUTH_SOCK into the container (Linux only — on macOS the container is inside podman's VM and the host socket is not a live socket there; see Environment Variables)
protected_dotfileslist["~/.ssh", "~/.gnupg", "~/.aws", "~/.kube", "~/.config/gcloud", "~/.config/gh", "~/.azure", "~/.netrc", "~/.git-credentials", "~/.npmrc", "~/.pypirc"]Host dotfiles a container may never see when the working-directory mount would expose them (e.g. running from ~). Shadowed so the path exists inside the container but appears empty — the threat they stop is exfiltration, not a stray read. ~-expanded against $HOME; entries must start with ~/. A tool that legitimately needs one opts in by declaring it in its own dotfiles. The field IS the complete list — set it to [] to disable all defaults, or add entries to extend. See Security Model.
pass_envlist[]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_varslist["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_fileslist[".env", ".env.*", ".envrc"]Project-tree FILES (matched by name anywhere under the working tree — .env.* catches subdir/.env.local) a container may never read or write: reads return empty, writes fail EROFS, even under --cap rw. .env and its variants hold local secrets; the working-directory mount would otherwise expose them as raw bytes. A tool that needs the values opts in via its own dotenv, which reads the file host-side and injects the variables — the raw file stays denied for every tool. The field IS the complete list — set it to [] to disable. See Security Model.
k8s_namespacestring"default"Default namespace for --k8s pod execution. A later feature.
k8s_max_pod_secondsint43200 (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. A later feature.
security_scan_enabledbooltrueVulnerability-scan images before running them
security_scanner_imagestringghcr.io/kapsl-sh/grype:latestScanner container image (self-hosted; pin a tag to freeze it)
security_scan_max_age_secsint86400How long a scan verdict is trusted; 0 scans every run
security_scan_denystring"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_promptstring"critical"Findings at or above this severity (below the deny threshold) show the combined summary and ask Continue? y (always)/N/d (details)/o (once); fails closed without a TTY. Default critical — HIGH is common enough that prompting on every one is too intrusive, so only CRITICAL interrupts by default; set "high" to restore the ask-on-HIGH behavior. Same values as security_scan_deny
security_scanner_image_max_age_secsint604800How often the scanner image itself is re-pulled
image_max_age_secsint604800Freshness 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. |

Git config. There is no key for this anymore. The ~/.gitconfig mount now comes from the signed catalogue — git's own entry declares it — so it is applied only when git is actually involved (primary, composed, or pulled in by an inline package install), not unconditionally into every container. The old [global] mount_git_config key no longer exists; a typo'd key is rejected at load time, which is how the removal is enforced.

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 via kapsl.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 Validation below.

[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.

KeyTypeDefaultDescription
enabledbooltrueInject 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. Two forms — the bare image string, or a table with the full field set:

[tools]
rg = "kapsl.sh/rg:latest"                              # simple form
python = { image = "kapsl.sh/python:3.12", command = "python3" }
aws-cli = { image = "kapsl.sh/aws-cli:latest", pass_env = ["AWS_SECRET_ACCESS_KEY"] }
FieldDescription
imageThe container image (required in table form).
commandThe binary to run instead of the tool name — for images whose entry is not the tool name.
aliasAdditional names that resolve to this entry.
descriptionShown by --search / --info for your own entries.
capabilitiesThe capabilities the tool may declare: net, rw, browser, clipboard, pid, gpu, rwimg, netraw (see Capabilities).
dotfilesHost dotfiles this tool may see — the per-tool opt-in that overrides protected_dotfiles.
system_pathsExpose a subtree of the tool's image at an absolute container path, read-only — for tools with compiled-in data locations no prefix can satisfy. A list of { from, at } pairs — below.
pass_envThis tool's own pass-through patterns — additive to [global] pass_env, and the only way to grant a name that is on protected_env_vars.
dotenv.env-style files to read host-side and inject into the run — the raw file itself stays denied (see Environment Variables).
seccompOverride the network class the seccomp shim confines the tool to — below.

The full mapping syntax — env files, version lines, groups — is Tool Mappings.

seccomp

The values are the ones kapsl --info <tool> prints, so what you read there is what you write here:

ClassMeaning
net-deniedNo reachable network, whatever the container was granted. AF_UNIX and AF_NETLINK still work.
net-connectMay call out, may not listen/accept.
net-listenNo network restriction.
unconfinedNo filter at all — opts the tool out of the shim entirely, including the always-denied baseline every class carries. For a tool that needs a syscall the baseline denies (strace needs ptrace); it removes a boundary rather than choosing one.

Most tools need nothing here: the catalogue declares a class and kapsl enforces it. This exists for the case where that classification is wrong for you — a net-denied tool you genuinely use over the network. An entry needs no image alongside it, so overriding a class does not pin a version: gawk = { seccomp = "net-listen" }.

There is deliberately no CLI flag for this. Relaxing a confinement boundary should cost opening a file someone can review. It also means an exploited tool cannot reach it: config is read on the host before the container exists.

system_paths

Some tools find their data at an absolute path compiled in at build time, which no relocation of the image's prefix can satisfy. autoconf hardcodes /usr/share/autoconf; move its binaries to a kapsl prefix and it dies in BEGIN, before its own code runs. system_paths declares the exposure and the run honours it:

[tools]
autoconf = { system_paths = [{ from = "{prefix}/share/autoconf", at = "/usr/share/autoconf" }] }
  • from is a DIRECTORY inside the tool's own image. {prefix} expands to the image's own install prefix, so the declaration survives a republish moving the version segment. A composable image's rootfs is the prefix's contents, so from must live under it.
  • at is the absolute container path the subtree is exposed at, read-only. The catalogue restricts it to /usr/share/ and from to a directory, both checked at publish time.

Mechanically it is the engine's own image-subpath mount (--mount type=image,subpath=) — the same primitive the composable prefix mounts use. The subtree is projected out of the image's rootfs directly: no extraction, no host-side state, no cache to clean, and read-only is the mount type's only mode.

Two run-time rules: no two tools in one run may claim the same at with different content (the run fails and names both), and a host mount you already asked for at at wins — the image's declaration is dropped.

The Apple container runtime has no image mounts at all (warn-only, like every other composed tool there) and composable runs do not reach k8s yet, so the exposure is a no-op there.

[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 Package 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}"
KeyRequiredDescription
install_command_inlineyesDockerfile RUN for an inline package list (tool:pkg1,pkg2). Must contain {packages}, replaced by the space-joined names
install_command_fileyesDockerfile 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"
KeyRequiredDescription
index_dirnoDirectory 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.

[enforce] section

The same per-tool settings as [tools], but a project cannot override them. By default a project's .kapslrc wins over [tools] — it is the more specific scope, and you reviewed it at the trust prompt; that is usually what you want. What .kapslrc cannot express is "I set this limit and I mean it". For a confinement boundary that is the whole reason to set one, so [enforce.<name>] takes precedence over everything, .kapslrc included:

[enforce.gawk]
seccomp = "net-denied"

Only seccomp is honoured here today. Any other field is a load-time error rather than a silent no-op — an [enforce] entry that looks binding and is not would be the worst possible failure for this table.

[groups] section

Named sets of composable tools:

[groups]
mylinters = ["flake8", "mypy", "black"]

A group name expands at a compose position — kapsl -e mylinters vim composes the three tools into vim's container. A group names a set; it grants nothing and cannot nest. The catalogue's own projects (coreutils and the rest) are looked up the same way — a group or a project is only ever expanded where a compose entry is expected, never as a fallback for an unresolved tool name, and a user-defined group of the same name shadows the built-in.

Validation

kapsl validates kapsl.toml when it loads it. A TOML syntax error, an unknown key, or a value of the wrong type prints an error naming the file and exits before anything runs — a broken config fails loudly on every run, never silently falling back to defaults. That is deliberate: a config that quietly stops applying is indistinguishable from one that is, and this one at least tells you which.

The same rule covers the explicit-path case: a missing file at the default location is normal (fresh install, defaults apply); a missing file at a path named by KAPSL_CONFIG is an error, because silently running with defaults would hide the typo.