Architecture¶
jailbee wraps Incus system containers to
give each git branch its own isolated, full-stack development environment.
This is a short overview of how the pieces fit together; see the code under
src/jailbee/ for the authoritative behaviour.
Where a container's config comes from¶
A container's Config normally comes from <repo>/.jailbee/config.yaml,
loaded once per command. A directory with no such file still works: unless
scratch.enabled: false in ~/.config/jailbee/global.yaml, jailbee.config
synthesizes one from that file's scratch: block instead, run through the
same validation pipeline and layered over the same built-in defaults and
global-config overlay every repo already gets (see
scratch). Everything downstream of the loader — every
module below — takes the resulting Config object and never asks which case
produced it. Either way the object stays read-only once loaded: no module
mutates it in place.
Golden image¶
jailbee base build provisions one Incus image (alias <container_prefix>-base)
via provision/install.sh plus the install.d/*.sh snippets. The image is
stack-neutral by default (locale, prompt, GUI libs, GitHub CLI, tmux,
build-essential); language runtimes and cloud helpers — Node, Java, Docker,
Python, the ECR helper — are bundled but opt-in, staged only when named in
golden.enable_snippets (see config.md). Every
jailbee new clones a fresh container from that image copy-on-write, so
container creation is fast and each container's disk only diverges from the
golden base by what changes at runtime. Rebuilding the golden image (jailbee
base build) does not touch existing containers — they keep running on
whatever base they were cloned from until destroyed.
flowchart TB
UB["upstream Ubuntu image<br>golden.ubuntu_version"]
BUILD["prefix-base-build<br>temporary, on the loose bridge<br>runs install.sh and the enabled install.d snippets"]
IMG["prefix-base<br>golden image"]
C1["prefix-feat-a"]
C2["prefix-feat-b"]
C3["prefix-pr-482"]
OLD["prefix-base-YYYY-MM-DD<br>archived alias"]
UB -->|"jailbee base build"| BUILD
BUILD -->|"publish, then delete the build container"| IMG
IMG -->|"jailbee new feat/a — copy-on-write"| C1
IMG -->|"jailbee new feat/b"| C2
IMG -->|"jailbee new --pr 482"| C3
IMG -.->|"the next base build renames<br>the current alias out of the way"| OLD
Here and below, prefix stands for the repo's container_prefix.
Layered profiles¶
Incus profiles are composable and independently swappable. Each container is built from a stack of profiles:
flowchart TB
P0["default<br>Incus storage + network"]
P1["prefix-base<br>GPU, Wayland, security flags, idmap, env vars"]
P2["prefix-binds<br>host RO mounts + shared RW mounts"]
MODE{"network mode<br>exactly one"}
P3A["prefix-net-strict<br>incusbr0 + prefix-allowlist ACL"]
P3B["prefix-net-loose<br>jailbee-loose bridge, no ACL"]
CT["the container"]
DEV["per-container devices, attached outside the stack<br>host-source RO clone source, GUI sockets,<br>port-forward proxies, under-repo mounts"]
P0 --> P1 --> P2 --> MODE
MODE -->|"jailbee net strict"| P3A
MODE -->|"jailbee net loose"| P3B
P3A --> CT
P3B --> CT
DEV -.-> CT
Splitting GUI/security config from bind mounts from network policy means a
container's network mode can change (jailbee net strict|loose) without
touching anything else, and profile edits from .jailbee/config.yaml changes
apply via jailbee apply without rebuilding the container.
Shared state¶
<shared_dir> (default ~/.local/share/jailbee/shared/<container_prefix>)
holds state that should persist across jailbee new/jailbee destroy
cycles and be visible to a repo's other containers, but never leak into the
host's own dotfiles: package manager caches (pnpm, Gradle, npm, m2), the
JetBrains config/data directories, and the Claude Code install + config.
Most of it is a genuine shared mount — bind-mounted read-write into
every container of the repo at once, so one container's writes are visible
to the next. A subset instead lives in pool slots: Gradle, Maven and
the Chrome profile default to one private copy per container
(<shared_dir>/<host_subpath>/slots/, attached as a disk device, seeded
from the warmest existing slot) rather than one mount every container
writes into concurrently — the tools in question take an inter-process
lock on their cache directory, and a shared mount meant one container's
lock blocked every other. See pooled_caches
and src/jailbee/pool.py. A separate host-global Docker registry mirror
container (jailbee-registry-mirror) caches image pulls across all repos.
Agent credential pool¶
Each supported coding agent gets one account pool: a host-wide store of
parked logins, a live login per holder (usually one per repo, or a shared
credential group — see claude_credentials),
and a move-only switch discipline, so a refresh-token lineage never ends up
in two files at once. src/jailbee/accounts/ holds this as a generic engine
plus one adapter per agent. accounts/engine.py is the store —
park/switch/remove, slot naming, member resolution — and knows nothing about
which agent it serves; accounts/models.py carries the agent-agnostic types
(Identity, Slot, Member, LiveAccount); accounts/groups.py resolves a
container's credential group; accounts/overview.py renders every login on
the host, across holders. Everything agent-specific — the credential
filename, how an account is named, session detection, what has to be
recorded beside a credential the engine just moved — lives behind the
AccountAdapter protocol in accounts/adapters/base.py;
accounts/adapters/claude.py is the only adapter today. The rule the split
establishes: the engine knows no agent; an adapter knows one — a second
agent's pool is a new adapter module, not a change to engine.py.
Read-only host binds¶
Secrets and host-installed tools are bind-mounted read-only rather than
reinstalled per container: GnuPG keys and SSH agent socket, JetBrains
Toolbox/IDE binaries, the Chrome binary, and the host's xterm-kitty
terminfo entry (so a kitty-terminal jailbee shell doesn't warn about a
non-functional terminal). Read-only mounts mean a compromised or
experimental process inside a container cannot modify the host source of
those files.
Egress allowlist and /etc/hosts pinning¶
strict network mode (the default) attaches an Incus network ACL
(<prefix>-allowlist) that default-denies egress except destinations listed
in egress_allow (plus feature-driven auto-adds for JetBrains/Claude/GitHub
when those integrations are enabled). Hostnames are resolved to IPv4 at
jailbee init/jailbee apply time; all returned A-records are added, which
matters for CDN-fronted services that round-robin a small IP pool. Those same
resolved IPs are pinned into each strict-mode container's /etc/hosts, so the
container's own DNS resolution can't drift from what the ACL was built
against. jailbee apply --no-restart re-resolves and refreshes both live,
without a container restart. loose mode (a dedicated bridge with no ACL)
is the other selectable state.
Port forwards sit outside this mechanism by construction. A forward
(host_ports in config, or an ad hoc jailbee port) is one Incus proxy
device, and Incus's forkproxy connects directly into or out of the container's
network namespace instead of sending packets over the NIC — so the traffic
never traverses the bridge the ACL is attached to, and neither direction is
filtered by it. jailbee net status lists the active forwards next to the
strict-mode summary for that reason; see
Security and limitations.
The two paths out of a strict-mode container, and why only one of them meets the ACL:
flowchart TB
subgraph CT["container, strict mode"]
APP["build, agent, dev server"]
HOSTS["/etc/hosts<br>pinned to the IPs the ACL allows"]
APP -.->|"resolves via"| HOSTS
end
NIC{"incusbr0<br>prefix-allowlist ACL<br>default-deny egress"}
OK(["destination listed in egress_allow<br>plus DHCP, DNS, registry mirror"])
NO(["everything else<br>rejected at the NIC"])
SVC["host service<br>e.g. adb on 5037"]
APP -->|"eth0"| NIC
NIC -->|"allow rule matches"| OK
NIC -->|"implicit default"| NO
APP <-->|"proxy device, never eth0"| SVC
Two filters, not one¶
The single "NIC" box above is really two nftables chains, and a packet has to pass both:
| Chain | Built from | Scope |
|---|---|---|
in.<ct>.eth0 / fwd.<ct>.eth0 in table bridge incus |
the container device's security.acls |
one container |
acl.incusbr0 in table inet incus |
incus network set incusbr0 security.acls |
every container on the bridge |
The second chain exists because bridge-nf-call-iptables=1 — the kernel
default, and required by Docker — sends bridge-forwarded packets through the
inet-family forward hook as well. It ends in a reject, so a destination
allowed only on the NIC fails immediately ("could not connect", not a
timeout).
Three ACLs feed those chains:
<repo>-allowlist—config.yaml'segress_allowplus host-local repo overrides. On both the NIC and the bridge.<container>-extra— one container's own overrides. On that container's NIC only.<repo>-container-extras— the union of every<container>-extrain the repo. On the bridge only, never on a NIC, so it widens the shared chain without handing one container another's grants. Rebuilt from the per-container ACLs whenever they change.
Isolation between containers therefore comes from the NIC chain; the bridge chain is repo-granular by construction and always was.
Host <-> container git bridge¶
Containers clone the source repo with git clone --shared, so a container's
.git/objects/info/alternates points at the host repo's object store —
transporting a feature branch's worth of commits is tens of kilobytes,
independent of overall repo size. jailbee git fetch/checkout/pull pull a
container's commits back to the host over an ext::incus exec ... git
upload-pack transport, landing them under refs/jailbee/<container>/<branch>
without ever touching GitHub; fetch additionally points the host branch (and
each submodule's branch of the same name) at that state without switching the
working tree, and checkout does the same and then switches onto it.
jailbee git push is the inverse: it transports a
host branch into the container under refs/jailbee/host/<branch>, fast-forwards
the container's own refs/heads/<branch> to match where it safely can, and can
then merge or rebase the pushed ref inside the container. jailbee git merge
chains two of these transports without a host checkout at all — a source
container's branch travels to the host and on into a target container, which
runs the merge itself, so the host stays a hub even when neither branch ever
becomes a host branch. jailbee pr fetches a container's
branch to the host and opens or updates a GitHub PR from it via gh. None of
this requires network egress from the container beyond what the operator
explicitly allows.
Security model¶
The isolation rationale — why a jailbee container with a disabled in-process
sandbox is a reasonable place to run agentic tools — lives in
Security and limitations.