Agentic coding
An agent that runs its commands through kapsl can touch the checkout —
read-only by default — and structurally nothing else. Not because the model
was well-behaved, and not because a prompt told it to, but because there is
no ~/.ssh in the container to read, no socket to open, and no path outside
the mounted directory to rm.
What the scope actually is
The containment the agent inherits is the same default posture every kapsl-run tool gets (the full table is on the security model page), tightened into the concrete surface an agent's shell can reach:
-
The CWD mount is the whole world. The directory you started the agent in is mounted at
/kapsl/workdir. A tool that declaresrogets it read-only; one that declaresrw(the agent's own shell does — it has to edit the tree) can write the tree and nothing else. -
Protected dotfiles exist as empty stand-ins, not absences.
~/.ssh,~/.aws,~/.gnupg,~/.config/ghand the rest of the protected list are covered by empty read-only stand-ins wherever the CWD mount would expose them. A directory lists as empty; a file reads as empty and refuses writes. The path stays visible on purpose — an empty~/.sshreads as a boundary, a missing one reads as a bug. Run the agent from your home directory and watch it:cd ~ && kapsl bash -c 'ls -la .ssh && touch .ssh/leak' total 0 touch: cannot touch '.ssh/leak': Read-only file systemYour real
~/.sshholds aconfigand an agent socket. The container sees an empty directory and a write that cannot happen. Elsewhere in the container, the ephemeralHOME(/kapsl/home) simply has no.sshat all:kapsl bash -c 'ls -la /kapsl/home/.ssh' ls: cannot access '/kapsl/home/.ssh': No such file or directory -
Protected
.env*files..env,.env.*and.envrcin the CWD are on the protected project-files list by name: the agent's shell cannot read them even though they sit in the mounted directory, and a tool opts in per file the same way it opts into a dotfile. -
The agent's own state is protected too — from every other tool.
~/.claude,~/.claude.json,~/.codex,~/.omp,~/.config/opencodeand~/.local/share/opencodeare on the protected list: live credentials for long-running sessions, and a full record of the work — some of these are sqlite databases of everything the agent has been asked to do. Run any tool from$HOMEtoday and all of it is exposed to that tool; kapsl mounts them as empty stand-ins. Measured: 25 entries of~/.claudereadable from a plainkapsl bash,~/.sshbeside it correctly empty. The agent itself is not stopped by this:claudedeclares~/.claudein its own catalogue mounts — the same opt-insshhas for~/.ssh— so the agent reads its own state and no other tool does. -
Per-tool network class. Each catalogue entry carries a seccomp class (
net-denied,net-connect,net-listen,unconfined). Anet-deniedtool'ssocket/socketpairsyscalls are refused for the INET/INET6/PACKET families — it cannot open a connection whatever the container was granted, so a composedlsnext to anet-granted tool cannot phone home through the shared namespace. The honest status: the container-wide seccomp floor that carries the always-denied baseline ships and runs on every process today; the per-binary enforcement of a tool's declared class is built and tested but switched off for launch, because every catalogue tool currently declares the default class, so the shim would re-deny exactly what the floor already denies. The CLI repo's ARCHITECTURE.md records the status in those terms. -
Ephemeral HOME. Whatever the agent writes into
~/.cache,~/.configor any other home path succeeds and vanishes with the container. Nothing it leaves persists except what a declared mount says persists.
The agents themselves
The agents run through kapsl too — same rules, same signed declarations.
The state of the three the docs cycle, checked against the live catalogue
(2026.35.16, 2026-08-25):
claude is declared, and its declaration is the worked example. The
boundary was read off the 2.1.245 binary, not remembered — the index
project file states it this way:
rwandnetare both unconditional, and for this tool neither is a widening anyone has to argue for: claude edits the working tree (that is the product) and it is an API client (without egress it cannot answer a single prompt) …
What the declaration grants: rw + net; the two mounts that are its
entire persistent state — ~/.claude (config, session transcripts, the
OAuth token on Linux) and ~/.claude.json, both writable because the token
file is created by a login and rewritten on a schedule; a compose set of
bash, coreutils, git, jq, rg, curl and friends — the shell
environment the agent actually drives; and five env vars set that make no
telemetry the default (DISABLE_TELEMETRY, DISABLE_GROWTHBOOK,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC, DISABLE_UPDATES,
DISABLE_ERROR_REPORTING) with pass_env as the door back out of each one.
Notably declined: browser (only the auth and setup-token
subcommands get it), clipboard (measured: claude reads the clipboard
through wl-copy/xclip/xsel, none of which is published), and pid
(composing procps would put the host pid namespace inside a container
that runs model-authored commands).
What kapsl claude does on this machine today, captured verbatim — the
image is published and pulls in a second, the scan passes, and the run
stops where two sources claim the same mount:
kapsl claude --version
▍ ■ PULLING ghcr.io/kapsl-sh/claude-code:latest
▍ ■ PULLING ghcr.io/kapsl-sh/unzip:latest
▍ ■ PULLED ghcr.io/kapsl-sh/claude-code:latest · 1.1s
▍ ■ PULLED ghcr.io/kapsl-sh/unzip:latest · 1.4s
▍ ■ SCANNING claude · reading 19 inventories
▍ ■ SCANNING claude · 19 images
▍ ■ ERROR two different mounts claim '/kapsl/home/.gitconfig' in the
▍ container: '/Users/q/.gitconfig' (read-write) and
▍ '/Users/q/.gitconfig' (read-only). kapsl will not choose between
▍ them -- a mount is a hole in the sandbox, and silently keeping
▍ one would grant access the other source never asked for. Drop
▍ one of them (a `dotfiles` entry in kapsl.toml, a project overlay,
▍ or the tool's own `mounts` in the catalogue).
That is the current state of "building now", exactly: the declaration and
the image exist, and the first run is what it is. The error names the two
sources that disagree (git's own ~/.gitconfig mount versus the default
read-only git-config mount a container with git gets) and the three places
one of them can be dropped.
omp is in the live catalogue (2026.35.16; it is not in the older
2026.35.13 payload most of the site's counts were checked against). Its
first run on this machine
fails closed in the way the scanning page promises: one CRITICAL finding,
no terminal to ask on, so no run.
kapsl omp --version
▍ ■ PULLING ghcr.io/kapsl-sh/omp:latest
▍ ■ PULLED ghcr.io/kapsl-sh/omp:latest · 1.3s
▍ ■ SCANNING omp · reading 7 inventories
▍ ■ SCANNING omp · 7 images
▍ ■ FAILED Vulnerabilities: C: 1, H: 10, M: 13, L: 3, U: 0. — requires
▍ review, but no terminal to ask on (run interactively, use
▍ --skip-scan, or adjust security_scan_prompt)
▍ ■ ERROR Vulnerability scan requires review, but no terminal to ask on
That failure is the security model doing its job on a not-yet-clean image,
not a defect: interactively the same run would ask y/N/d, and in a
pipeline it fails the build. kapsl --info omp shows the rest of the
boundary: net + rw, a writable ~/.omp state directory, a
pass_env list of provider API keys, and browser only on the
setup/stats subcommands.
codex is not in the catalogue as of 2026.35.16 (checked
2026-08-25); opencode and mistral-vibe are declared — their images sit
in the payload with no tool entry yet.
The two-layer pattern
The pattern is two layers, and both layers run containerized:
- The agent layer. The agent runs with exactly its own state
directory mounted (
~/.claude/~/.omp), its own declared capabilities, and its own compose set. Its session state persists; its scratch does not. - The tool layer. Every tool the agent shells out to is a composed
catalogue tool with its own declared boundary:
gitgetsnet+ its own~/.gitconfig/~/.sshstory,rgandjqgetroand nothing else,pythongets whatever the environment it is run in declares.
The composed tools' boundaries compose into the session: a grant the agent
makes (say, --cap net) is a grant for the session, and the per-tool
classes still apply inside it. The agent cannot widen a tool's boundary
from inside the container — the boundary was decided at build time, in a
file a human can read, not at run time, by the model.
What an LLM accident looks like
The interesting question is not "does the agent finish the task" but "what is the worst thing a bad turn can do". With this setup:
-
rm -rfoutside the CWD is not reachable. There is no outside. The worstrm -rf /does is hit the container's own filesystem and die with it. Inside the tree, arotool cannot even delete:kapsl --cap ro bash -c 'touch /kapsl/workdir/nope' touch: cannot touch '/kapsl/workdir/nope': Read-only file system -
A
curlto a hostile URL from a net-denied tool cannot happen. The container may have a network namespace (a composed tool needed it); the tool's own syscalls still may not use it. And for the tools that are net-denied because their code cannot open a socket at all (the flake8 case, walked on the linter page), even that is a proof, not a policy. -
Reading the SSH key returns an empty file, not a key.
cat ~/.ssh/id_ed25519inside the agent's shell fails on a missing or empty stand-in. There is no key bytes for the model to echo, log, or POST.
Compare that to the native setup, where the agent's shell is your
shell: same PATH, same ~, same credentials, and the blast radius of a
bad turn is whatever your user account can reach.
The practical layer
Three mechanisms make the setup usable rather than theoretical:
- Shims.
kapsl --install gitputs a symlink at~/.local/bin/gitthat dispatches onargv[0], so a tool typed by name goes through kapsl with its declared boundary — the shims page has the mechanics. Inside an agent's container the equivalent is composition: thegiton its PATH is the catalogue's git. KAPSL_ARGS. The only way a shimmed tool gets kapsl flags. Shell- split, CLI flags win, and no tool names inside — it is for flags the wrapper needs, likeKAPSL_ARGS=--skip-scanon a machine whose image state is already known-clean.- The per-project
.kapslrcas the human yes-gate. A project that needs the agent to have more than the defaults says so in a committed file: capabilities, dotfiles, ports, per-subcommand args. First run in such a project triggers the trust prompt, which lists every grant the overlay makes — and the agent's grants are reviewed there, the same SSH-host-key-style moment, before anything runs. The project config page walks it end to end.
Honest limits
What is not contained, each with its current status:
-
The agent's own declared grants are real grants.
clauderuns withrw+net: it can write the checkout and it can reach the network. That is the product — an editor with no write access and no egress answers no prompts — but it is the boundary a human reviews at declaration time, not a ceiling the mechanism imposes. The per-project.kapslrcis where a tighter session is stated. -
--cap nest— nested runs — works on Linux, not yet on macOS. A nested call (kapsl bash -c 'kapsl jq …') is proxied through a brokered socket rather than handing the inner container the engine socket; the socket has to share a kernel with the container, and on macOS the containers live in a VM. Captured today:kapsl --cap nest bash -c 'kapsl jq --version' ▍ ■ ERROR --cap nest is not supported on macOS yet. ▍ kapsl runs on the host but its containers run inside a virtual ▍ machine, and the broker socket has to be on the same kernel ▍ as the container using it. The fix is to run the broker as a ▍ sidecar container; that is designed but not yet built. ▍ Nesting works today on Linux. -
Cross-architecture runs fail loudly instead of running unconfined.
PR_SET_SECCOMPfails under qemu-user, so a--platformrun of a foreign-arch image errors at the confinement step rather than silently losing it. The one outcome this mechanism must never produce is a run that looks confined and is not. -
Per-binary seccomp confinement is off for launch. The container-wide floor ships; the per-tool class enforcement (the
kapsl-seccomp-shim) is built and tested but switched off, because with every catalogue tool declaring the default class it currently re-denies exactly what the floor already denies. It stops being redundant the first time a tool declares a real class. -
The agents are mid-rollout. claude's first run stops on a mount conflict that names its own fix; omp's first run fails closed on a CRITICAL finding; codex is not in the index at all. None of that is a promise about tomorrow, and the captures above are what "today" is.