The security model
The model in one line: a tool gets the smallest world that lets it do its job, and every way out of that world is a decision someone made and can review. The defaults are zero-trust — nothing is shared, nothing is reachable, nothing is escalated. The layers are defence in depth — each one holds the line on its own, so an exploit has to break several of them at once to reach anything that matters.
The default posture
Every container kapsl starts begins here, before any tool's own declaration:
| Control | Default |
|---|---|
| Network | off (--network=none). No DNS, no sockets, no egress. |
| Your directory | mounted read-only at the working path. |
| Linux capabilities | all dropped (--cap-drop=ALL). |
| Privilege escalation | off (no-new-privileges). |
| Scanned | yes — and an unscanned or critically-found image does not run. See scanning. |
A tool that needs more than this does not get it from the default; it gets it from its own declared boundary in the catalogue — reviewed at build time, signed into the document — or from you, at the command line, where it is visible in the command itself.
The CWD is the whole world
The mount of your current directory is the only view a tool gets of the host filesystem. There is no ~, no /etc/hosts edit, no /tmp you can leave a payload in for the next run:
ro(the default): the tool sees the checkout and can write nothing into it.kapsl jq,kapsl flake8,kapsl rg— readers and analysers — run here.rw: the tool writes into the checkout and nowhere else. Build tools and formatters that must edit files.nomount: no host path at all. The tool runs against its own empty world.
The home directory inside the container is an ephemeral tmpfs (/kapsl/home), not your home: a tool that writes ~/.python_history succeeds, and the file vanishes with the container. Tools that need persisted state get it by declaration — a dotfile mount for exactly that path, see below.
The CWD's posture is itself a declared fact, measured per tool: the linter that is ro in the catalogue is ro in your editor and in CI and on every machine, and the index page shows that boundary to everyone else.
Your keys are not on the table
This is the default that "should have always been": a tool cannot read your SSH or GnuPG keys unless it specifically declares that it needs them.
The mechanism is an empty stand-in. Every path on the protected list is mounted into the container as an empty, read-only directory:
~/.ssh ~/.gnupg ~/.aws ~/.kube ~/.config/gcloud ~/.config/gh
~/.azure ~/.netrc ~/.git-credentials ~/.npmrc ~/.pypirc
~/.claude ~/.claude.json ~/.codex ~/.omp
~/.config/opencode ~/.local/share/opencode
Whole directories, not the one secret file inside each, because in every case the neighbours are secrets too: ~/.aws/sso/cache and ~/.kube/cache/oidc-login hold live session tokens, and naming only credentials would deny the long-lived key and hand over the short-lived one.
The agent directories are the sharpest case of the same rule. An agentic coding tool's state directory holds live credentials for long-running sessions and a full record of the work — ~/.claude carries a control key and a key per session, ~/.claude.json is account state, and ~/.codex and opencode's ~/.local/share/opencode are sqlite databases of everything the agent has been asked to do. Running any tool from $HOME today exposes all of it to every command: it was measured, with 25 entries of ~/.claude readable from a plain kapsl bash while ~/.ssh beside it was correctly empty. With kapsl that data is protected by default — and the agent itself is not stopped by it: claude declares ~/.claude in its own catalogue mounts, the same opt-in ssh has for ~/.ssh, so the agent reads its own state and every other tool does not.
The mount is on purpose visible: ls ~/.ssh inside the container lists an empty directory. An empty ~/.ssh reads as a boundary — a missing one reads as a bug, and a bug is where a tool finds a workaround.
A tool opts in per path, and the opt-in lives in the signed catalogue, not in your configuration:
sshdeclares~/.sshread-only — plus~/.ssh/known_hostsseparately, writable, becauseStrictHostKeyChecking=accept-newwrites the host key on first connect. The key material stays read-only; the key list is not key material.azdeclares~/.azure(its credential store).ghdeclares~/.config/gh.
The same shape applies to environment variables (protected_env_vars — AWS_SECRET_ACCESS_KEY, GITHUB_TOKEN, NPM_TOKEN, and the rest: present on the host, absent in the container unless the tool's own declaration names the variable) and to project files (protected_project_files — .env, .env.*, .envrc: a name match in the CWD only, never a recursive sweep; a tool opts in with dotenv).
Syscalls are policed too
Dropping capabilities controls what a process may ask for; seccomp controls what it may call. kapsl runs every process under an always-deny baseline for the syscalls that matter for escape: ptrace, mount, setns, bpf, the set*uid family, keyctl, and the rest of the kernel-entry surface. The baseline is independent of --cap-drop=ALL — deliberately redundant, because the two layers fail differently and an exploit that beats one is stopped by the other.
Tools are assigned seccomp classes in the catalogue: net-denied, net-connect, net-listen, unconfined. The class is the tool's network-syscall posture, and it is config, not CLI: relaxing one is seccomp = "…" in your config file for that tool — there is no --seccomp flag, because relaxing a confinement boundary should cost opening a file someone can review, not a keystroke.
The honest status: the container-wide baseline ships on every run today. Per-binary confinement — a different class for each executable inside the container, enforced through a shim — is built and tested but not yet enabled by default.
Escalation is explicit
Every way out of the default posture is a visible, nameable flag:
| Flag | What it opens |
|---|---|
--cap net | egress (and ingress for listening tools). |
--cap rw | write access to the CWD mount. |
--cap nomount | no host mount at all (the other direction of the dial). |
--cap rwimg, browser, clipboard, pid, gpu | the image's own writable paths, a browser, the clipboard, the process table, the GPU — each a distinct, named grant. |
--cap nonet, nobrowser, noclipboard | the negations: a grant a tool would otherwise get, forced off. |
--port 8080:8080 | publish one port to the host. |
--cap nest | run kapsl inside kapsl — the nested call is proxied over a brokered socket, not handed the engine's. |
--privileged | the whole posture off. Stated in the command; no pretence. |
The negations are how a project tightens a tool's declared boundary the way --cap loosens the default: the same vocabulary in both directions.
A project can't redefine a tool
A project's .kapslrc overlay can grant — capabilities, ports, dotfiles, environment — but it cannot change what a name means: there is no image or command field on a plain entry, and an alias mints a new name rather than renaming the old one, with real tools always winning a collision. A malicious or careless project file can ask for more; it cannot make kapsl python run a different python.
Above everything sits [enforce] in your own config: a floor that neither the catalogue, the project, nor the CLI can clear. An [enforce] entry that looks binding and is not would be the worst possible failure for that table, so unknown fields in it are a load-time error.
Two review gates, one pattern
The security model has exactly two points where a stranger's file names a grant and a human has to say yes:
- The scan prompt. The findings ledger names what the bytes contain;
yis a recorded decision, remembered and subset-compared (scanning). - The project trust prompt. First run in a project with a
.kapslrcshows every grant the file would make — including what its environment file would auto-grant through package rules — and asks once / until it changes / always, keyed to the file's content hash (project configuration).
Same shape twice: the machine reads a file and proposes a boundary change; the human approves it against the content, not the filename; a change to the content changes the answer. git clone evil-repo && kapsl python is inert until you say otherwise.