byre is open source on GitHub -- pjlsergeant/byre

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.