Skip to content

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>-allowlistconfig.yaml's egress_allow plus 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>-extra in 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.