Security and limitations¶
Security model¶
Layering several mechanisms together — unprivileged, user-namespaced
containers, read-only binds for secrets (GnuPG, SSH, and gitconfig are
mounted read-only; note that user-declared host_mounts are read-write
unless you set readonly: true), a kernel-level egress allowlist, and
snapshot/rollback — gives stronger isolation than an in-process sandbox on
the host.
Keys stay on the host. Where a secret has an agent, JailBee shares the
agent's socket rather than the key: with gpg.enabled, the host
gpg-agent's socket is attached to the container and SSH_AUTH_SOCK points
at its SSH socket, so signing and SSH authentication are performed by the
host agent. ssh.enabled seeds only config, known_hosts and
config.d/ into the shared ~/.ssh — private keys, authorized_keys
and sockets are never seeded. A process in the container can therefore
ask the agent to sign for as long as it runs, but cannot take the key with
it; on a smartcard-backed key each signature can still require a physical
touch. This is the same "use it, don't hold it" property a credential proxy
provides, and it does not extend to plain API tokens mounted or written
into a container — those are ordinary files. That is why running an agent
with its own guardrails off is reasonable inside a jailbee container —
see Running an agent without prompts.
Host-local per-repo settings live outside the checkout at
~/.config/jailbee/repos/<container_prefix>.yaml (or under
$XDG_CONFIG_HOME). This is the supported location for per-repo secrets such
as github.token; JailBee creates the directory as 0700 and each local
config file as 0600. A file carrying github.token is rejected unless it
has mode 0600. The legacy github.api_tokens map remains in
~/.config/jailbee/global.yaml until migrated; that file must also be 0600
while it contains tokens. Keep both files private. container.env is not
secret storage: values are written into the Incus profile and are visible
with incus profile show.
Remote SSH¶
The optional SSH server is a capability boundary inside the current user's
account, not a second kernel sandbox. The service runs as the same Unix UID
that runs the local jailbee CLI and has the same Incus access. With
remote.ssh.restrict_host at its default, a remote session reaches the
containers and the host repo's refs, and every host-reaching path JailBee
knows of is closed (listed below), whatever commands.mode says. What an
enabled command does to Incus containers is otherwise its normal behavior.
The two settings are separate axes: commands.mode picks which commands a
remote caller may run, restrict_host whether any of them may reach past
the containers and the git bridge into the host.
Every authorized client key has identical access. There are no per-key repos,
roles or command policies, and the fixed SSH username jailbee does not map
keys to different operating-system users. Authorize a key only when its holder
may exercise the complete configured remote surface across every registered
repo. Removing a key blocks new connections immediately but does not revoke an
already-authenticated connection.
An operator can narrow that shared repo surface at the host-controlled
repository exclusion boundary using the
remote.ssh.excluded_repos list in ~/.config/jailbee/global.yaml:
remote:
ssh:
restrict_host: true
excluded_repos: [private-app]
Each entry is an exact registered container_prefix, not a path, glob, or
key-specific grant. The excluded repo is unavailable via direct SSH repo
selection and is omitted from the remote dashboard and console. Supported
aggregates such as ls --all, job ls --all-repos, review ls --all-repos,
base usage --all, and repo summaries such as net status operate only on
visible repos; filtering is applied before probing, rendering, totals, or
actions. Unknown and excluded repo selections give the same error. This is one
host-wide visibility boundary for all keys, not per-key authorization, and it
does not alter local CLI, TUI, or Qt behavior. Each new SSH channel takes a
policy snapshot; a running channel keeps that snapshot until it ends.
With exclusions active, commands whose output or effects cannot safely be
scoped are refused instead of exposing host-wide data: config show --layer
global, account ls, account group ls, and doctor. Commands not
classified for safe scoping also fail closed. Existing command-mode and
host-restriction checks continue to apply; exclusions require
restrict_host: true.
This is an SSH routing and JailBee-command boundary, not filesystem or kernel isolation. It does not revoke data already made available inside an allowed container. For example, an allowed container may have a host directory exposed through a configured bind mount, or may reach a service over the network or use shared credentials. Review mounts and other host-to-container channels separately; excluding a repo does not remove an allowed container's mounts.
The default listener is IPv4 loopback only (127.0.0.1:8022). It is not a
network authentication boundary and is not remote-device deployment guidance:
changing listen, exposing the port through a tunnel, VPN, firewall rule or
port forward, and securing that route are the operator's responsibility.
JailBee does not configure TLS, a firewall, a VPN or NAT for this service.
Entry points are deliberately narrow, but they are not read-only. In
particular, the remote dashboard shows registered repos only and exposes its
container and git-bridge actions: creating, starting, stopping or destroying
containers and moving commits can therefore affect host state. It withholds
what would reach the host beyond that — the config editor (a config decides
host mounts and this very policy), the diff pager (a pager can start a
shell), "Open PR" and GUI app launches (a browser or window would open on
the host's display; with remote.ssh.gui on, launches go to the shared RDP
display instead). Every process the service starts is marked as remote
(JAILBEE_REMOTE_SSH=1, inherited by everything it starts in turn) and runs
with LESSSECURE=1. Every SSH session, restricted or not, also carries
JAILBEE_SSH_SESSION=1, which keeps the dashboard to registered repos, the
Qt dashboard refused and the post-install setup offer unmade: the one at the
other end is not at this host.
While restrict_host is on, the commands that manage the host itself are
refused in every mode, full and an allowlist naming them included:
config edit/init/migrate, every remote ... command, setup, init,
apply, base build/prune, net install/migrate/refresh/unregister,
net egress add/rm (which accept the host's own and its LAN's addresses),
registry up/down, display up/down, litellm
up/down/login/logout/logs, the account commands that write, mount,
port to-container, and the GUI launchers (gui, ide, the browsers, apps
run). With remote.ssh.gui on, ide, the browsers and apps run are
permitted and draw on the shared display instead; gui stays host-only.
The startup log names any allowlisted command that stays refused this way, and every
public command is classified one way or the other by the test suite, so a
new one cannot land unclassified.
commands.mode: full is a high-trust setting. In restricted sessions it
grants classified public JailBee commands, and newly added or unclassified
commands fail closed. With restrict_host: false and no inherited restricted
session marker, full admits public leaves including future ones, except
reserved routes and hidden internal commands. It also grants every hidden
alias of a public command (merge,
pull, push, and a few others — see remote.ssh),
since those are policy-checked against the public command they alias, not
their own hidden spelling. Hidden internal commands with no public twin
(_remote-console, the deprecated jailbee claude .../chrome-pool ...
groups, and the rest) are never included. The default policy uses full,
bounded by this classifier and restrict_host; operators needing narrower
command access should prefer an exact-leaf allowlist such as ls or git
pull; allowing git pull does not grant sibling git commands, though it
does grant git --help (a public group's own help is always permitted once
some command under it is).
The SSH protocol surface is also fail-closed:
- public-key authentication is the only authentication method; password, keyboard-interactive, host-based and GSS authentication are disabled;
- agent forwarding, X11 forwarding, Unix-socket forwarding and remote
listeners are disabled, and so is TCP forwarding, with one exception:
while
remote.ssh.guiis on,127.0.0.1:13389(the shared display, see Remote GUI), for any authorized key; - SFTP and SCP are disabled unless
remote.ssh.filesis on. When it is, they serve one thing only (see File transfer) and every other subsystem is still refused; - client environment requests, including
SendEnv, are accepted by the protocol but ignored: the client's environment never reaches the child process, which is built from the service's own environment; - the interactive
shellentry point is a restricted JailBee console, not a POSIX shell, and implements no pipes, redirection, expansion or executable lookup; - one-shot commands are parsed into an argv without invoking a shell, and
--reporesolves an exact registered prefix rather than a client-supplied filesystem path; and -
in every command mode,
fullincluded, a remote command may not set a path-typed option or argument —--configwould read any host file (its parse errors echo the contents) or make any host directory a repo whose config decides host mounts — norjailbee new --mount, whose read-write bind of the host repo includes.git, where a planted hook runs on the host. The argv is parsed by the command's own parser to decide this, so short-option clusters and--opt=valueforms are covered. -
the git bridge updates refs only.
git checkout,branchon the host,git pullinto the host's checked-out branch or with--checkout, and agit fetchthat would move the checked-out branch are refused before anything is fetched; a submodule new in the container is not cloned into the host tree (skipped with a warning, the rest still transfers). Whatever lands in the checked-out tree — a repo config that decides host mounts, a build script — is what the host's own tools read next. Pull into another host branch (--into) or fetch into one (--as), and check it out on the host; - publishing to GitHub with the host's own
gh(pr,submodule pr,review apply,issue apply,outbox apply) stays available — the key holder is the human the outbox is reviewed by — but never with--yes, so each action is shown and confirmed;--web/--openare refused as host browsers; - a branch whose autostart config widens privileges (the escalation prompt
of
jailbee new) is refused outright,--yesincluded: over SSH the one answering that prompt is the remote user it exists to hold back.
A container created with jailbee new --mount shares the host repo's working
tree, .git included, so a restricted session may neither create one nor
enter one: shell, tmux, exec and the GUI app launchers refuse a
mount-mode container, and clone-mode containers are unaffected.
One host resource stays reachable from inside a container on purpose: the
Wayland display socket, attached whenever the host session is Wayland. Any
process in the container — a remote session's shell included — can open a
window on the host's screen with it, and JailBee's own GUI launchers are
withheld remotely (unless remote.ssh.gui is on) only because a window there
helps no remote user. A Wayland client draws its own surfaces and cannot read or drive other
windows, and no X11 socket is shared. The host's session D-Bus and
PulseAudio sockets, which do reach further, are opt-in
(gui). With remote.ssh.gui on, a second such resource
exists: the shared display directory, mounted read-only into every container,
through which any process can draw on the shared RDP display (see
Remote GUI).
A server imports its routing and session marking when it starts, so one left running across an upgrade would enforce the old version's rules. It therefore restarts itself when the installed version changes, before serving another session (see Installation).
remote.ssh.restrict_host: false (or jb remote ssh serve
--no-restrict-host for one run) lifts every host restriction above and in
the dashboard paragraph at once: no argument check, no session marker, no
LESSSECURE, and an allowed command then reaches the host exactly as it
does locally. It is one switch on purpose — a partly lifted boundary is
harder to reason about than either state — and the startup log announces
host restrictions: OFF. A server started from inside a restricted
session inherits its marker and stays restricted whatever the setting says.
The user journal records source address, authorized-key fingerprint, bounded route/repository/command identifiers, decision and exit status. It does not record key material, complete argv, environment values, terminal contents or user input. See Installation for key rotation, service status, journal inspection and recovery operations.
File transfer¶
remote.ssh.files: true lets an authorized key use sftp and scp against a
container's repository directory, in both directions. It is a single switch for
reading and writing together, and it is off by default. Changing it needs jb
remote ssh restart.
The server is not a stock SFTP server, which would expose the host's
filesystem. It presents a virtual tree: / lists the running containers of
non-excluded repositories, and /<container>/... is that container's
repository directory. The host filesystem is never read or written by it, and
the rest of the container (home directory, credentials, agent state) is out of
reach:
- every operation is one
incus execof a fixed script; names and offsets reach it as arguments, never as script text; - the target is resolved inside the container and refused when it leaves the
repository, so a symlink to
/etcor../..is seen but never followed, read, written or removed through (lstatshows the link itself); - links cannot be created, file names must be UTF-8, the top of the tree is read-only, and files cannot be moved between containers;
- files are written as the user who owns the repository (never root), a file
cannot grow past 2 GiB, and at most eight
incus execcalls run at once across all sessions; - containers that are stopped, belong to an
excluded_reposrepo, or are not JailBee-managed are not listed and cannot be entered; neither are containers created with--mount, since their repository directory is the host's own checkout; - every operation that touches a container, and every one that is refused,
writes exactly one audit line with the key fingerprint, container, path
(and the new path of a rename), operation and outcome; path canonicalisation,
closeandfsynctouch nothing and are not audited.
Submodules and anything mounted inside the repository directory are part of it and are reachable. Rsync is not supported.
Remote GUI¶
With remote.ssh.gui: true, GUI apps launched over SSH draw on a shared RDP
display (see Remote GUI over SSH):
- The weston compositor listens on the display container's loopback only, and
an Incus proxy device publishes it on the host's loopback
(
127.0.0.1:13389) only. The bridge address refuses connections from the host and from sibling containers. - The RDP connection is TLS with a self-signed certificate and no Network
Level Authentication; weston checks no login, so any local user of the host
can connect to
127.0.0.1:13389. JailBee assumes a single-user workstation. - Over SSH, any authorized key may forward to that one destination while
remote.ssh.guiis on; the flag is re-read for every forward request, so turning it off refuses new tunnels at once (open ones run until they close). The forward is not tied to a key that has launched an app: every authorized key runs under the same server-wide policy and could launch one, so such a binding would protect nothing. - All containers share one screen and the RDP clipboard: one container can draw over another's windows and read what is on the clipboard.
- The shared display directory is mounted into the client containers
read-only, and only while
remote.ssh.guiis on. Every client shares the host user's idmap, so a writable mount would let a compromised container delete or replace weston's socket with its own and capture every other container's windows, keystrokes and clipboard; read-only means a container cannot replace the socket. Connecting to the socket needs no write access, so any process in a container can still open a window on the shared display. That is the exposure below, and it is why containers are not isolated from each other on this screen. - The display container uses the dev user's idmap, like the client containers, so the shared socket is owned by the same host user.
- With SSH repository exclusions active, the GUI launchers (
ide, the browsers,apps run) are refused over SSH regardless of this setting.exec -d --guiis not: it is admitted as a command scoped to one container of a repo the session may see. That is not a hole in the exclusion — the display itself is one screen shared by every container, excluded repos' included, and any session allowed to forward to it sees all of it.
Running an agent without prompts¶
A coding agent asks before it acts because on your own host a wrong command is unbounded. Inside a container it is bounded by the container, so the prompts cost more than they buy, and turning them off is the intended mode rather than a corner you cut:
- Permission prompts —
claude --dangerously-skip-permissions(or--permission-mode bypassPermissions). JailBee itself runs Claude this way forjailbee pr. Put it inagents.claude.commandand every container's autostart window comes up in that mode. - The agent's in-process sandbox — a separate switch on the same axis
(Claude Code's
dangerouslyDisableSandboxopts a single bash command out of its own sandbox). Redundant inside a container that is already the boundary.
The point of the trade is that you size the blast radius before the run, with the container's config, instead of adjudicating it prompt by prompt. So it is worth knowing exactly what you sized:
Reachable by an unattended agent. The container's own clone of the repo.
Everything in host_mounts — read-only entries can be read and used, and a
read-only ~/.gnupg plus the host gpg-agent socket means the agent can ask
for signatures for as long as the container runs, even though it can never
take the key. The shared state layer, which is shared: <shared_dir>/claude
holds Claude's settings and the shared ~/.ssh holds whatever you put there,
and damage to either is not contained to one container. Claude's login lives
in that directory too, unless the repo is in a credential group — then it is
the group's directory, mounted at ~/.claude-creds and shared with every
other repo in the group. In loose
mode, the network — including a push to origin. With github.enabled, the
container's own GH_TOKEN — see the limitation below.
GitHub token scope is a documentation contract, not an enforced one.
The recommended fine-grained PAT (github.token, injected as
GH_TOKEN) is read-only by design — Contents, Issues, Pull requests, and
Metadata all set to Read — so that every GitHub write an in-container agent
proposes must go through the host-side outbox (jailbee issue apply /
jailbee review apply / jailbee outbox apply), reviewed and applied with
the host's own,
independently authenticated gh. jailbee cannot verify a fine-grained
PAT's effective permissions — jailbee doctor reminds you of the intended
scope but never probes GitHub to check it. A PAT you (or an org policy)
scope wider than read-only lets an agent (or anything running as it) write
to GitHub directly with the container's own gh, bypassing the outbox
review gate entirely — the same trust boundary the read-only recommendation
in config.md and git-bridge.md
exists to hold.
Out of reach. Your host's filesystem and dotfiles. Other repos'
containers and their shared dirs. optional_mounts you haven't attached with
jailbee mount. Private keys, which never leave the host agent. In strict
mode, every host not on the allowlist.
Shared across repos, by design. Two things deliberately cross the
per-repo line. A credential group shares one agent login between every repo
in it (see credentials); an agent in any member
repo can use, and spend, that account. And with litellm.enabled, every
strict container of every repo reaches the one host-wide LiteLLM proxy
through the jailbee-services ACL, holding a proxy key per account
(/etc/jailbee/litellm-<account>.key); a container can spend those
subscriptions and API keys through the proxy, though it never sees the
provider tokens themselves. See
LiteLLM security and limitations.
The practical shape, then: stay in strict, bind read-only what the build
genuinely needs, leave sensitive optional_mounts detached, and take a
snapshot (jailbee snapshot create) before a long unattended run.
Git remote & push¶
After jailbee new clones the source repo into the container, the clone's
origin is rewritten from the RO mount path (/mnt/host-source) to the host
repo's real upstream URL, and branch tracking is set explicitly so
git push/fetch/gh work without -u once the network ACL allows
them. The --shared clone semantics survive — objects continue to read
from the mount through .git/objects/info/alternates.
The container's remote is always named origin, whatever the host calls its
own upstream (see Which remote is the
upstream?) — the clone is jailbee's
own, so its naming is jailbee's invariant rather than something inherited.
By design, a strict container cannot push to GitHub. github.com is not
in the base strict-mode egress_allow, and only HTTPS is ever added:
api.github.com:443 while github.enabled is on, and github.com:443 with
GitHub's content hosts while Claude Code's plugins are on
(agents.claude.plugins_enabled, the default when Claude is enabled), so its
plugin installs and updates work. The GitHub token a container gets is
read-only (see GitHub CLI inside
containers), so gh reads
work in strict mode while pushes and gh writes do not. Push from the host
(jailbee git checkout <name>, then git push, or jailbee pr <name>), or
switch to loose with jailbee net loose <name> for the write and back with
jailbee net strict <name>. This keeps unattended agent runs from producing
surprise pushes.
Port forwards¶
A port forward (host_ports in config, or an ad hoc jailbee port
to-container/jailbee port to-host) is a deliberate hole through the
boundary net strict otherwise enforces. It works because the traffic
never traverses the bridge the network ACL is attached to: each forward
is one Incus proxy device, and Incus's forkproxy connects directly into
(or out of) the container's network namespace rather than sending packets
over the NIC. The ACL — applied in strict mode only — is deny-by-default
on both egress and ingress (see src/jailbee/network.py), so neither
direction of a forward is filtered by it. On the optional jailbee-work
network, loose mode attaches an ACL too (<prefix>-work-loose, scoped to the
container's own address); a forward bypasses it in the same way.
Both directions matter, and each bypasses a different half of that
default-deny: a to-container forward (the host_ports case, e.g. the adb
recipe in project-config.md)
lets the container reach a host service that the egress deny would
otherwise have blocked. A to-host forward lets something on the host
reach into the container — traffic the ingress deny would otherwise have
blocked. The to-host direction does not give the container any new
outbound reach: the host is the one initiating the connection, into a port
the container is already listening on.
It is opt-in either way — a forward exists only because it is declared in
host_ports or because someone ran jailbee port. jailbee net status
lists every active forward alongside the strict-mode summary, so the real
boundary — ACL plus whatever forwards are open — can be read off one
command, and jailbee doctor separately reports host_ports entries that
are declared in config but missing from a running container.
Autostart and the network exposure window¶
An autostart stage that sets network: loose widens the container's
egress for as long as the whole stage runs — every chain, every step in
it — not just for one step's duration the way the deprecated step-level
network did. A stage with several parallel chains genuinely needs this:
nothing may flip the profile out from under a chain that's still running,
so the switch and its restore bracket the stage as a unit. See Stages and
chains.
user.jailbee.autostart_in_progress is what keeps jailbee-net-refresh's
TTL revert from racing a stage's own network swap (see
loose_auto_revert). For a detached
stage the flag carries the supervisor's own pid rather than a bare "1",
so a supervisor that dies mid-run without reaching its own cleanup —
killed, OOM, a host reboot — stops pinning the container loose:
jailbee-net-refresh sees no live process behind the pid and reverts on
schedule instead of leaving the container exposed indefinitely. A "1"
written by an older jailbee (before the pid was tracked) still reads as
held, so an in-flight run started before an upgrade isn't reverted out from
under itself.
Restoring the entry network mode after a detached stage is
compare-and-swap: the supervisor re-reads the container's current mode
first and only restores if it's still the mode this stage set. Run
jailbee net strict/jailbee net loose by hand while a detached stage is
mid-run and your choice stands — the stage's own restore, whenever it
finally happens, silently no-ops instead of clobbering it (jailbee net
itself only warns and proceeds; it does not refuse). The foreground path
has no such race to guard against — nothing can run concurrently with a
stage the CLI is blocking on — so it keeps the old unconditional restore.
Egress overrides¶
jailbee net egress add widens a container's, or a repo's, strict-mode
allowlist without passing code review. That is the point of the feature
and also its risk: unlike egress_allow in .jailbee/config.yaml, an
override is never seen by a teammate, a reviewer, or CI — it lives in the
container's own user.jailbee.egress_extra label (container scope, the
default) or in host-local state (--repo, applying to every container of
the repo on this machine).
The mitigation is visibility, not a prompt: jailbee net egress ls shows
every applicable entry and where it came from (config, repo-override,
container), and jailbee net status lists the overrides for the repo
whose checkout you run it from — both host-local sections that never leave
the machine, so they can only be read by someone who already has a shell
there. jailbee net status (like its other sections) is cwd-scoped, not a
host-wide audit: to see another repo's overrides, run it from that repo's
checkout. --repo is the wider of the two scopes; the flag itself is the
confirmation that the change is repo-wide rather than one container.
Overrides are additive only. Neither scope can revoke what
config.yaml grants — jailbee net egress rm refuses an entry that exists
only in the config file, pointing at it instead — so a repo cannot be
quietly narrowed on one developer's machine. jailbee net egress export
prints the whole egress_allow: key with host-local overrides folded in,
for pasting over the config to promote a durable one into git; the
overrides it just promoted can then be dropped with jailbee net egress
rm now that config.yaml covers them.
No container can grant itself egress. A container holds no jailbee
binary and no access to the host's Incus socket, so code inside it —
including an agent, and including the untrusted head checked out by
jailbee new --pr for review — cannot reach these commands. That is the
first question a security reviewer should ask, and the answer is
structural, not policy: there is nothing to invoke. This is unrelated to
the branch_config escalation gate, which weighs what a branch's
committed autostart configuration is allowed to grant itself on
jailbee new; jailbee net egress is the operator, at a host shell,
typing a command.
Limitations¶
Work-network generation¶
The optional jailbee-work network is IPv4-only. Loose mode on this bridge
does not provide IPv6 egress, which may be narrower than loose mode on the
legacy unrestricted bridge on hosts where IPv6 previously worked. Returning
to strict removes the source-scoped loose exception and blocks new forbidden
connections, but does not forcibly terminate an already-established
loose-created flow (for example, a long-lived TCP session). Do not treat
strict as immediate session revocation; stop the workload or container when
immediate termination is required. See work-network activation.
- Linux host only
- One IDEA at a time across containers (shared JetBrains profile). Chrome,
Firefox, Gradle and Maven run per-container from their own pool slot
instead, seeded from the most recent slot — see
jailbee pool ls/jailbee pool pruneto inspect or clean (jailbee chrome-pool ls/pruneis a deprecated alias scoped to Chrome). - NVIDIA GPU passthrough requires extra setup (not covered by
jailbee init)