Containment backends
A containment backend is the substrate that provides Lever’s jail. The jail is a contract, not a single product, and one of its guarantees decides whether a backend can even exist in code.
The jail contract
- A hypervisor boundary between the agent workload and the host kernel. Mandatory. Agents run
arbitrary, potentially adversarial code with real credentials (see the threat model in
security model §1); a network/mount/user namespace is not a substitute for a
separate kernel, one kernel-level exploit from any agent reaches everything sharing that kernel.
No backend without a VM boundary is added to
lever backends. - No host filesystem beyond the one chosen project tree.
- A network namespace Lever controls, so the egress allowlist can be enforced outside the agent containers.
- Egress enforced in that namespace, not by the agent behaving.
- A host-reachable capability-broker endpoint for capability, LLM, and tool traffic.
See security model §2.4 for the full contract in context.
Run lever backends to print the live matrix; this page mirrors it. A candidate exists in
code iff a registry constructor exists for it. Select a backend with the
backend: key in lever.yaml; an unrecognised name is rejected at config
load rather than silently substituted, so a containment posture is never quietly swapped for
another.
Implemented
| Backend | Kernel | FS bounded by | Egress enforced at | Version-fragile |
|---|---|---|---|---|
orbstack |
shared | isolated machine: no host files + project tree mounted at /lever |
jail netns iptables/ip6tables | true |
lima |
separate | VM: no host files + project tree mounted at /lever |
jail netns iptables/ip6tables | true |
(Columns mirror lever backends’ own output.)
orbstack — reference
macOS on Apple Silicon with OrbStack. The runtime, the Scion server/broker,
rootless podman, and every agent run inside one OrbStack isolated machine that shares no host
files and has its own network namespace. lever creates it as ubuntu:resolute (Ubuntu 26.04 LTS,
the release of the Lima guest); OrbStack reads that only when it creates the machine, so an
existing machine keeps its release. This is the reference substrate the other backends are
measured against. Its trade: a single kernel shared across the manager and all
workers (a kernel-level container escape reaches the whole jail — see security model §8).
lima — the non-OrbStack path
The Lima VM backend: macOS (vz) and Linux (QEMU/KVM), for anyone who does
not run OrbStack. It preserves the VM boundary, its own kernel, not shared with the host or with
other jails, so its guarantees match orbstack’s. The VM type is vz (with a virtiofs mount)
on macOS and qemu on Linux, booting an Ubuntu 26.04 LTS cloud image pinned by URL and sha256 per
architecture (a VM created by an earlier lever keeps its 24.04 guest until it is recreated; see
moving a Lima VM to Ubuntu 26.04). The
containment surface is a lever-owned template (stock Lima templates are not used), built from
three mechanisms:
- Exactly one writable mount: the project tree, at
/lever. Nothing else, in particular not Lima’s stock~read-only home mount. - All automatic guest→host port-forwarding suppressed. Lima’s default forwards every guest
listener to the host’s
127.0.0.1: on a stock template, a guest-side0.0.0.0listener is reachable at the host’s loopback. Left on, a jailed agent could squat a host-loopback port and impersonate a local service (a dev server, a credential helper). The lever template’sportForwardsblock carries an ignore-all rule for both0.0.0.0and127.0.0.1guest binds, closing it. - Lima’s bundled containerd disabled (
containerd: {system: false, user: false}). Lever provisions rootless podman itself, exactly as it does fororbstack.
Requires Lima ≥ 2.0.0, checked at bring-up: the ignore rules depend on Lima 2.0’s
portForwards semantics, and an older limactl forwards guest ports despite the rendered ignore
rules. The template sets guestIPMustBeZero: true explicitly, so the containment property does
not depend on Lima’s auto-inference.
host.lima.internal (resolving to 192.168.5.2) is the host alias, the direct analog of OrbStack’s host.orb.internal: it’s how an agent
reaches the broker and any allowlisted host tool port.
The run user. lever reads the guest user’s name and uid (whoami, id -u) from the machine
instead of assuming them. On OrbStack the guest user carries the host uid (501 on a typical Mac);
on Lima the guest user is uid 1000, not 501. The rootless podman runtime directory
(/run/user/<uid>) and the staged worker tickets follow that uid.
Guest DNS goes through the host alias too. With qemu, Lima’s guest resolver (192.168.5.3)
is a nat-table DNAT (Lima’s LIMADNS chain) to the host agent’s DNS server at
host.lima.internal:<per-boot port>, and nat OUTPUT runs before filter OUTPUT, so every lookup
reaches LEVER_EGRESS as a new dial to the alias on a non-allowlisted port. With vz the
LIMADNS chain is empty and the guest’s systemd-resolved sends to the alias on port 53 directly.
Under egress: open, lever reads the live LIMADNS chain at apply time (or, when it has no
targets, the alias nameservers in /run/systemd/resolve/resolv.conf) and ACCEPTs exactly those
targets ahead of the alias DROP, so the guest and every agent container resolve names through the host’s own resolver (VPN and split-DNS
included). Without that carve-out (lever < 0.22.2) a subscription-mode agent never resolved
api.anthropic.com and every turn ended in Request timed out while lever up and lever doctor
stayed green. Under egress: closed DNS stays dropped by design; agents dial the broker by IP.
lever doctor’s guest DNS row resolves a public name from inside the guest so a DNS-dead jail
is no longer indistinguishable from a healthy idle one. OrbStack is unaffected: its resolver path
is not in the dropped set.
Nested virtualization (nested_virt). On an x86_64 Linux host, nested_virt: true gives the manager
container /dev/kvm, so the manager can run KVM guests (for example Lima, to test lever itself).
Workers and the hub do not get the device. Apply refuses on a host that is not x86_64, and when the
host’s kvm_amd/kvm_intel nested parameter is off. The template renders vmOpts.qemu.cpuType: host, which Lima reads at
create time only: an existing VM needs a recreate. Inside the guest, lever writes a udev rule
(/etc/udev/rules.d/65-lever-kvm.rules, mode 0666). The manager gets the device as a bind-mount
volume on its scion record (scion has no per-agent device field; rootless podman passes a
--device as the same bind mount). The volume is create-time only: to give an existing manager
the device, or to take it away, run lever up --fresh --keep-conversation. When the
key is off, apply removes the rule and sets the device back to mode 0660. Each apply also removes
the podman drop-in (20-lever-kvm.conf) that earlier builds wrote, which gave every container the
device. lever doctor has a nested virt row. The key is rejected on OrbStack and on darwin.
With nested_virt the guest kernel defaults to Ubuntu’s HWE kernel (guest_kernel), and every
Lima guest is set to reboot rather than hang on a kernel crash; see
nested virtualization.
See the nested_virt config row for the full contract.
Sizing. disk (default 24GiB; Lima’s own default is 100GiB), cpus (1-256) and memory
(at least 2GiB) size the VM. They are Lima only (config load rejects cpus, memory,
nested_virt, guest_kernel and guest_profile on OrbStack, and OrbStack ignores disk) and create-time only: apply warns when
cpus or memory differ from the existing VM’s, and a bigger disk needs limactl disk resize or
a recreate. Unset cpus and memory keep Lima’s defaults (4 vCPUs and 4 GiB, or fewer on a smaller host). guest_profile: small
trims the guest’s services and adds zram swap, on an existing VM too. See
system requirements & sizing for the numbers and a small-machine
profile.