Project configuration

A .kapslrc at the project root — YAML, committed with the code — is how a project tells kapsl what it needs. kapsl reads it from the directory you run in; the repository's example projects each carry one.

The rule: grants only

A project overlay can add to a tool's configuration. It cannot redefine the tool:

  • There are no image or command fields — which container a name runs in, and which binary inside it, stays a decision in your own config or the signed catalogue. Unknown top-level keys (like global or package_providers) are rejected at parse time, not silently ignored.
  • An alias mints a new name (django-python from python). It takes effect only if that key does not already resolve through your config + the index — a real tool wins any collision, so a project can never shadow a name you depend on.
  • Everything in the file is trust-gated (below). A repository that asks for net gets a review, not a grant.

The escape hatch for "I need a variant of a tool, not a redefinition of it" is exactly the alias: python-net = { alias: python, capabilities: [net] } — a new name, additive grants, old name untouched.

Fields

Per tool, under tools::

FieldLayeringWhat it does
aliasmints a new nameExpands this entry from a real, already-resolvable tool. Single-value fields below override the target's; list/map fields are additive to it. No chaining.
env_filesingle-value overrideDefault -e argument for this tool, so a bare kapsl python behaves as if -e requirements.txt were passed — the project analog of source venv/bin/activate. An explicit CLI -e always wins.
capabilitiesadditivenet, rw, … on top of what the index/user already grant.
dotfilesadditiveHost dotfiles mounted into the container; also the opt-in for a protected path, same semantics as your own config.
pass_envadditiveEnv-var glob patterns forwarded from the host if set there; also the opt-in for a protected env-var name.
set_envfixed values, last-winsEnv vars with fixed values authored in the file itself — the third env mechanism alongside pass_env (forward host value) and dotenv (read a file host-side).
dotenvadditive.env-family files read host-side and injected as variables; the opt-in for the protected-project-files deny list.
composeadditiveOther tools' binaries composed onto PATH in this tool's container (compose: [git, rg]). Composing a tool grants none of its own capabilities — only the binary becomes reachable.
seccompsingle-value overrideOverride the network class for this tool (net-denied/net-connect/net-listen/unconfined) — the same values kapsl --info prints. Trust-gated like everything else here.
subcommand_indexsingle-value overrideWhich args[N] the subcommands map matches (default 0). For interpreter shapes — see below.
subcommandsmap, per-key rulesports replace the image's default for that subcommand (a different port is a different server); args append after the matched subcommand, before anything the invoker typed. Publishing a port implies network, as --port does.

Top level:

FieldLayeringWhat it does
package_rulesadditiveProject-scoped package rules: a package too private or niche for the catalogue can auto-grant net when it lands in an environment image — the same shape as the catalogue's global list. Additive only; a project can grant, never mute a global entry.
capability_confirmraise-onlyAsk for more capability review than you configured (offshowask: show the final capability set with each grant's source, and ask when the set isn't already accepted); a project may never lower your setting. A review gate that a repository can widen but not narrow.

subcommand_index: the Django case

subcommands keys on exactly one argument position. For most tools that is args[0]. For "an interpreter runs a project script that has its own subcommands", it isn't — python manage.py runserver has args[0] = the literal "manage.py", forever. The django-quickstart example's .kapslrc is the canonical fix:

tools:
  python:
    env_file: requirements.txt
    subcommand_index: 1
    subcommands:
      runserver:
        ports: ["8000"]

Now runserver is args[1], and the port publishes only for manage.py runservertest, migrate, makemigrations, and shell get the environment file and nothing else. The example's README carries the RUST_LOG transcripts proving the port fires for runserver and podman inspect showing no port mapping at all for manage.py test. The same mechanism, keyed on index 2, is what cargo-rust-simple uses for cargo run -- serve.

A subcommands.<name>.args entry is a pure sequence splice — kapsl never inspects what the tokens mean — so args: ["--addr", "0.0.0.0:8090"] after the matched subcommand makes the server bind an address the host can reach. A key that matches nothing is silently inert: when a port isn't published, the trust review line (below) is where to look.

Running a built binary directly

An argument containing a / is not a tool name — kapsl ./a.out runs that file, validated as an ELF and executed against the minimal static base. Path keys work in .kapslrc too, and take the same fields as named tools (capabilities, pass_env, set_env, dotenv, subcommands) — only env_file and alias have no meaning for a binary. The match is exact and unnormalised: ./hello and hello are different keys. See the Go page for the full toolchain-vs-binary treatment.

The trust prompt, end to end

The overlay is not applied silently. First run in a directory with a .kapslrc, kapsl prints what the file declares — per tool, per grant — and asks:

kapsl make
  SECURITY  .kapslrc declares a project overlay
            make: +net, port(serve) 9000
  INPUT     Apply this project's overlay?
            [ y ] yes    [ n ] no    [ o ] once

The three answers:

  • y — yes, always. Trust is recorded in ~/.local/share/kapsl/trusted-projects.json, keyed on the sha256 of the file's bytes. The run prints Trusted · remembered until .kapslrc changes — which is the whole policy in one line: edit the file and the question comes back, because the hash changed.
  • n — no. The run proceeds without the overlay, as if the file weren't there.
  • o — once. The overlay applies for this invocation only.

The review line is the contract: every grant a file declares shows up in it, so the human decision is made against the complete list, not a summary. (The capture above was normalized to the launch naming; the dev binary prints .boksrc and the same y/n/o prompt.)

Without a terminal there is no prompt — and no overlay. Verified: kapsl --non-interactive make in an untrusted project prints nothing about the overlay and the run proceeds without it; the prompt declines rather than blocks, so CI never hangs. The consequence is that a project whose code needs the grant (a port, net) fails downstream on the missing capability, not at the trust gate.

That is the anchor sentence worth keeping: git clone evil-repo && cd evil-repo && kapsl python is inert until you say so. The overlay in the repository is data until a human reads the review and answers y or o — and even then, it can only do what the grants in front of them say.

What a .kapslrc is not

  • Not a kapsl.toml. Your config has [global], [tools], [enforce], [package_providers]; a project file has none of them and is smaller on purpose — the full reference for your own file is Configuration file.
  • Not a CI config. It describes what the project needs to run; CI still passes its own flags (kapsl --non-interactive --cap net …).
  • Not versioned by kapsl. It is your file, committed like any other; the catalogue does not speak about it except through the trust store's hash.