Security model
This is the straight version of byre’s security story – the facts that are true and important but too sharp for a pitch. What’s boxed, what isn’t is the summary; this is the detail. byre is young; read the warning on the install page first.
Threat model
The threat is the agent, never the user. byre exists so an
autonomous coding agent can run at full permissions without wearing your
identity or reaching your machine. It is NOT designed to protect you
from yourself: every protection is one config edit from off, and that is
deliberate (“not your nanny” – the box is locked against the agent, not
against you). byre’s standing promise is legibility, not gates:
byre status always tells you what the box can reach.
byre is built for over-eager, reckless, or misbehaving agents – the ordinary case, where the agent isn’t trying to do anything to you. Against an actively malicious agent, including one acting on hostile instructions it read somewhere, byre takes some simple precautions; that is all it offers. This isn’t a security product. It is not built to resist an agent deliberately working to get out, or a dedicated attacker with kernel exploits.
The contract
- Boxed (what’s boxed is the one-breath
version): the agent sees the project folder, plus exactly what you
mount, pass, or enable – nothing else. byre reads no host credentials
and copies none. Agent logins happen inside the box; the one exception is the
optional shared-auth token for Claude
(ADR 0017),
which you mint yourself with
claude setup-token– wherever suits you – and hand over at an explicit prompt. Explicit hand-over, never ambient inheritance. - Not boxed, by design: the network (open by default; the default-deny firewall skill closes it to a derived allowlist) and the project tree, which stays read-write because editing it is the job.
- A container is not a microVM. The box shares your kernel. If your requirement is “the agent must never share a kernel with my machine”, use a microVM product or a separate physical box.
Specific facts worth knowing
Project credentials protect the disk, not the running box. A credential
is an age-encrypted value stored inline in your config files
— an encrypted: or encrypted-file: row in [env_from_host], opened
by the identity in that file’s own [credentials] block, which is
itself wrapped under a passphrase with scrypt
(ADR 0057).
The passphrase is the protection: byre decrypts only at launch, after
you enter it. That is real protection against off-box disk access (a
stolen laptop, a backup blob, a synced dotfiles directory), and it is
the whole of the security content. byre writes the decrypted plaintext
to exactly one filesystem place — a per-session tmpfs that empties when
the box stops — and to no host file, image layer, engine-visible config
value, or volume.
What it deliberately does NOT do:
- The agent is handed the delivered values — that is the point — and can copy or send them anywhere it can reach. An open network exfiltrates an unlocked credential, so pair credentials with the firewall skill.
- The key names are cleartext, and so is the recipient each value is encrypted to. Disk access learns which credentials exist and what they are called.
- A mount placed over the delivery tmpfs relocates the plaintext onto durable storage and can re-surface it on a bare restart without a fresh unlock (disclosed as a managed-path shadow).
- Anyone who can write your config files can change what a row
delivers. The encrypted payload is stamped with the key and kind it
was set for, which catches accidents — a blob swapped between rows, a
value replayed from git history onto a renamed key, a copy-paste
across files — but it is not integrity: anyone holding the cleartext
recipient can produce a correctly-stamped value. This bites in exactly
one place,
byre preset apply: your repo is the box’s own writable tree, so if you ship credentials through a repo preset, whatever writes that repo can mint a chosen value (it has the recipient), or swap, transplant, or replay existing blobs without it. The prize is durable poisoning of future sessions — a swapped API key that outlives the box — not plaintext, which the box is handed anyway. byre answers this at the consent gate rather than by hiding the values: the apply review flags every changed row where either side is a credential (“if you didn’t rotate this credential, reject”), rows that appeared or vanished, and any change to the[credentials]block itself. Credentials you never ship through a preset have no exposure here. - Transient plaintext in process memory — byre’s during launch, the agent’s afterwards — can reach swap, core dumps, or a hibernation image.
- A weak passphrase weakens the at-rest encryption to match. Your backups and any synced dotfiles carry the ciphertext, so the passphrase is what stands between a copied config file and the value inside it.
Multiline editing exposes the replacement to viewers/recorders after a warning; stored values are never loaded and no plaintext editor file is created. Terminal paste may translate LF to CR or drop invalid UTF-8/U+FFFD; unbracketed controls can act as shortcuts, and text pasted into the Key field is visible. Passphrase widgets remain single-line and can normalize pasted controls. For binary file-kind input, follow the CLI instructions (an existing identity is required).
--self-edit hands the agent authorship of your config files along
with the rest of the store — its own warning covers that. There is no
recovery path: a forgotten passphrase means byre credentials unset on
each row and setting the values again.
Docker daemon access is root-equivalent. Anyone who can talk to the
Docker daemon (the docker group) can mount any named volume – byre’s
included, identity volumes included – or the host filesystem itself.
This is Docker’s design, not a byre gap, and byre cannot change it. On
shared machines, treat daemon access as root. byre’s uid-qualified
naming (byre-<id>-u<uid>-... images, byre-machine-u<uid>-...
volumes) prevents users accidentally sharing state; it cannot stop a
daemon user doing it deliberately. Project state volumes
(byre-<id>-...) are not uid-qualified, so two Unix users sharing one
checkout on the same daemon share – and one’s reset/forget can
clobber – that agent state; byre is a single-operator tool and does not
re-key them. byre shell does hide a box started by another Unix user
(--skip-uid-check enters it anyway, as that box’s dev identity). The
optional docker-host skill is exactly this grant, made legible: see
docs/DOCKER-HOST.md
before enabling it.
A skill is trusted code – enabling one hands it the box. Skills
ship raw Dockerfile lines, shell hooks sourced at launch, and (for a
network-posture skill) a root helper in the box’s network namespace.
The allowlists on a skill’s typed fields (apt, env keys, egress)
exist for legibility – so a typed field always reads as data – not as
containment; there is no sandbox between an enabled skill and the box
it builds. The agent command is deliberately a shell fragment for the
same reason (flags ride in it; quoting it would contain nothing).
Treat enabling a skill like installing an editor extension: a trust
decision about its author, made once, with the consequences legible in
byre status.
Config env values are baked into the image. They are emitted as
Dockerfile ENV layers, so docker history shows them to anyone with
daemon access, and they live in the image – surviving byre reset,
which clears volumes, not images – until it is rebuilt or deleted.
To be clear about what this does NOT change: byre images never leave
your machine (there is no push path), and daemon access is
root-equivalent anyway – nobody can read the layer who couldn’t
already read the running container’s env, the volumes, and the host.
So this is a hygiene fact, not a new exposure. The reasons to know it:
a value persists in image layers after you delete it from config, and
changing one re-runs the project block’s installs. Real credentials
belong to the agents’ own login flows or the shared-auth skills, which
keep them in volumes.
An open network is an exfiltration channel. With the default open posture, an agent can send anywhere – including the project it is working on, or any credential you passed in. The firewall skill (deny-by-default, per-skill derived allowlist, applied from outside the box, fail-closed launch gate) is the mitigation; enable it if this is in your threat model. The firewall-open skill is NOT: its open-denylist posture blocks a snapshot of named hosts’ IPs – hygiene against well-behaved telemetry clients, trivially routed around by an agent that wants to. The network under it is still open, and this document treats it as such.
An allowlisted host is a channel, not a permission. The firewall
grants reachability of an endpoint, never an intent: byre cannot see
what travels over a door you opened, and does not try. Three shapes of
this are worth naming, and they get one disclaimer rather than three.
DNS still resolves under deny-by-default – the rules allow port 53 to
the nameservers the box actually uses, because a box that cannot resolve
cannot work – so data can be tunnelled out through your own resolver;
that is a hole byre accepted in v1 and has not closed
(ADR 0010),
and a filtering resolver is the v2 candidate. An allowlisted CDN or
platform host is an address that fronts many services and, on the ones
that host user content, an upload endpoint; granting 443 to it grants
everything reachable at that address on that port. And a skill whose
function is a tunnel – exposing a local port to the public internet –
declares the endpoints it needs, and byre opens them, because a skill’s
own functional egress is exactly what auto-opens
(ADR 0020):
enabling it is the grant. The firewall’s job is that the list of open
doors is short, derived from what you enabled, and legible in byre status. It is not that what goes through them is safe.
A mount over byre’s own paths wins at runtime, and byre only tells
you. byre bakes its machinery into the image – the launcher, the
launch gate the firewall’s fail-closed wait reads, and the delivery
artifacts under /etc/byre – and re-asserts the security-critical ones
at the end of the build, so a files entry cannot quietly replace them.
A bind mount or a named volume is different: the engine applies it over
the finished image, and byre has nothing that runs afterwards. A volume
at /etc/byre is filled once – from the image when the engine first
mounts an empty one, or from your seed if it has one – and is the
authority from then on, so a gate a later build bakes never reaches the
box, and anything that empties the file it holds is permanent. The
launcher treats no gate as nothing to wait for, so the next docker restart brings the box up with its netns unfirewalled. byre status,
byre develop, and the grant review a preset asks you to approve all
disclose any mount or volume covering those paths – the project’s own
and any a skill declares, named – in the Containment register, and then
run it: this is your configuration to make
(ADR 0052).
Concurrent sessions share a pathname race. Every byre bind mount is handed to the engine as a pathname, not an inode-pinned handle – the docker/podman CLI-to-daemon contract is a pathname, resolved in the daemon’s own namespace (a VM under Docker Desktop), so byre cannot pin it from the host side. The consequence: an agent in a concurrent rw session that can rename an ancestor of a bind source during another launch’s short detect-to-mount window could redirect that bind. Worktrees make concurrent sessions an ordinary workflow, which is why this is worth knowing; the window belongs to a launch in progress – a session already running is not affected. The full analysis is in ADR 0009.
The firewall’s allowlist is an IP snapshot. A hostname grant is resolved once, at session launch, and the rules pin those IPs; the name is never re-resolved while the box runs. If the host’s DNS answer moves after that – CDNs rotating a pool, and some resolvers rotating the answer on every query (Azure’s forwarding DNS does) – connections to that host start failing even though it is granted. On a per-query resolver this can bite seconds after launch, not just mid-session. The failure direction is always closed: rotation can cost you reachability, never containment. A session restart re-resolves; for an endpoint with a stable address, granting the IP directly sidesteps the race.
What comes out of the box may be executable. The project is not just
data; it is a directory your host tools run code from. An agent with the
project mount can leave behind a git hook, a git config that names a
program (core.hooksPath, credential.helper, core.sshCommand,
core.fsmonitor, a filter.* or diff.* helper), an .envrc that
direnv evaluates when you cd into the project, an .env whose values
steer a later process, an editor task, or a build script – and your
later host command that reaches it runs it, as you – which command
depends on what was planted, but ordinary use of the project is the
trigger and nothing else has to go wrong. The firewall skill
does not help here: this rides the filesystem, not the wire. Neither
does reading the diff, for some of it – the git admin directory sits
outside the working tree and never shows in git status or git diff,
and ignored files are normally omitted as well, while editor tasks and
build scripts usually do show. Git’s configuration is also more than one
file: include.path and includeIf can pull in others. The practical
rule is that a tree an agent has worked in is its output rather than
your code, and running anything out of it – git included – runs what
it wrote. When a session ends byre mentions changes it noticed in a few
of those places, in the register of “we thought you should know”; it
checks a handful of spots, not everything, and a quiet exit is not a
clean bill of health. byre holds its OWN host commands to that same
rule: the engine CLI, git, ssh, the shell it opens your $EDITOR
through and the clipboard helpers are each resolved once per run, and a
binary your PATH answers with out of a directory that project’s box can
write is declined by name – the tool, the path and the directory, so
the fix is reordering PATH. It is no comment on your PATH, and no
judgement of what is inside a binary; where byre resolved it from is the
whole test. One directory is never treated as box-writable: the
filesystem root. A project AT / would make every binary on the machine
“inside” the box’s tree and byre would decline to run anything at all –
graphical launches actually start byre from /, which is how this
shipped as a bug – so for a box you deliberately run on /, the
project-tree half of that check is gone (byre’s own store for the
project is still covered). That is a real gap, not a vacuous one:
such a box writes whatever your user can write, PATH entries included.
Running byre on / is handing the agent your whole writable surface,
and byre takes you at your word.
--self-edit is transitive trust of the agent with your host. A
self-edit agent authors the next develop’s config – mounts, run args,
build context – through the front door. There is no meaningful
containment beyond that point, and byre does not pretend otherwise: the
launch banner says exactly this, and the session ends with a diff of
what changed in the store.
A --self-edit box can stop byre status describing it. Every
container carries a launch record – what byre told the engine, in the
project store, addressed by the hash of its own bytes
(ADR 0053)
– and that store is exactly what --self-edit mounts read-write. byre
re-hashes rather than trusts, so a record an agent EDITS is refused, not
believed; but an agent can delete its own record or make it unreadable,
and status then says so and falls back to describing your current config.
The failure direction is a claim withdrawn, never a false one, and the
record only ever informs a human reading status – no host action is
driven by it. Boxes started before this shipped, or by an older byre,
degrade the same way.
Shared (machine-scoped) volumes cross project boundaries. The
shared-auth skills
(ADR 0017)
put one agent credential where every one of your projects’ boxes can use
it – that is their purpose. A misbehaving agent in ANY box can read
(and so exfiltrate) that credential, exactly as it can its own
per-project login today; the tokens involved are inference-scoped where
the vendor allows it (Claude’s setup-token). byre status names shared
volumes; reset / forget never delete them silently.
A single-writer volume is byre’s contract, not the kernel’s. A
volume can declare sharing = "exclusive"
(ADR 0054),
and byre develop then refuses to start a second box of the project
while another one holds it – reading the live boxes’ launch records to
find out, and refusing equally where it cannot establish that none is
holding it. What that does NOT cover: a docker run -v you type
yourself, anything already inside a running box, and a stopped box that
someone starts by hand while a launch is in flight. Exclusive is also
refused on a machine-scoped volume, because the boxes that could break
the contract there belong to your other projects, which byre’s scan
does not see – so cross-project data has no single-writer vocabulary
at all.
Agents hold usable credentials by construction. Whatever auth story you choose, the agent can read its own credential – it needs it to work. byre’s job is that the credential’s scope is what you chose, its location is legible, and nothing you did not choose rides along.
Reporting
Report security issues via GitHub security advisories on pjlsergeant/byre – the policy lives in docs/SECURITY.md.