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 declares ro gets it read-only; one that declares rw (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/gh and 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 ~/.ssh reads 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 system

    Your real ~/.ssh holds a config and an agent socket. The container sees an empty directory and a write that cannot happen. Elsewhere in the container, the ephemeral HOME (/kapsl/home) simply has no .ssh at 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 .envrc in 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/opencode and ~/.local/share/opencode are 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 $HOME today and all of it is exposed to that tool; kapsl mounts them as empty stand-ins. Measured: 25 entries of ~/.claude readable from a plain kapsl bash, ~/.ssh beside it correctly empty. The agent itself is not stopped by this: claude declares ~/.claude in its own catalogue mounts — the same opt-in ssh has 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). A net-denied tool's socket/socketpair syscalls are refused for the INET/INET6/PACKET families — it cannot open a connection whatever the container was granted, so a composed ls next to a net-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, ~/.config or 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:

rw and net are 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:

  1. 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.
  2. The tool layer. Every tool the agent shells out to is a composed catalogue tool with its own declared boundary: git gets net + its own ~/.gitconfig/~/.ssh story, rg and jq get ro and nothing else, python gets 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 -rf outside the CWD is not reachable. There is no outside. The worst rm -rf / does is hit the container's own filesystem and die with it. Inside the tree, a ro tool cannot even delete:

    kapsl --cap ro bash -c 'touch /kapsl/workdir/nope'
    touch: cannot touch '/kapsl/workdir/nope': Read-only file system
  • A curl to 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_ed25519 inside 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 git puts a symlink at ~/.local/bin/git that dispatches on argv[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: the git on 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, like KAPSL_ARGS=--skip-scan on a machine whose image state is already known-clean.
  • The per-project .kapslrc as 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. claude runs with rw + 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 .kapslrc is 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_SECCOMP fails under qemu-user, so a --platform run 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.