Skip to main content
A registry file is a recipes: map plus an optional default:. Registries resolve bundled → ~/.dabs/recipes.yaml (global) → ./dabs.yaml (project), later winning by name.

Field notes

  • image — a bare name reuses ~/.dabs/images/<name> (built from a bundled image recipe on first use). An inline {dockerfile, context} builds with dabs build; the image is named after the recipe, so two recipes sharing one Dockerfile still build two tags.
  • command — must not bake in agent instructions; recipes provision, callers prompt.
  • sources — exactly one origin kind per entry (mount / mkmount / copy / worktree). Space variables $NODE_VOLUME|HELD|TMP and $PARENT_VOLUME|HELD|TMP name HOST origins, and substitute in a source’s origin path only. ($NODE_EPHEMERAL/$PARENT_EPHEMERAL are permanent aliases for the held variants.) Mounts land parent-before-child regardless of declaration order.
  • $NODE_ID in box paths — a source’s destination path: and the recipe’s workdir: may name $NODE_ID (or ${NODE_ID}), which expands to the id of the box being provisioned, so a recipe can auto-namespace what it mounts per box (path: /$NODE_ID). It is the only variable a box path may name — a space variable mixed into one is still rejected — and the id is validated as a slug before it is substituted, so $NODE_ID can never become a traversal out of the directory the path names. See Sources.
  • publish — grants the box the ability to publish services (forward publish <name> --type webui|general --port <n> inside it, reached from the host with dabs services). It grants the ABILITY, not a list of names: what a box publishes is chosen at run time by the program in it. dabs realizes the grant as the box’s door — one dabs-owned unix socket, answered on the host by a relay that lives as long as the box’s node — so it needs a box (a recipe with no image: is refused) and a local target (a box on another machine has no path to a relay on this host). Without the grant there is no door, and forward publish in the box refuses by name instead of failing on a missing file.
  • user — who the box’s processes run as, written uid or uid:gid, and numeric: a name is resolved in the image’s own /etc/passwd, so it would say nothing about which host user ends up owning what the box writes. Unset leaves the image’s user, which for an image naming no USER is root. Set it when the HOST reads what the box writes: a mount:/mkmount: carries uids, not names, so under the docker driver — whose daemon is root — a root box leaves root-owned files that the ordinary user who booted it cannot open. The bwrap and apple drivers need nothing here and ignore it: a bwrap box is already a process of your own inside a namespace that maps only your uid, and the apple driver’s micro-VM translates ownership across its own boundary (and its boxes must be root to open a relayed socket at all).
  • target — "" (local) or a server from dabs servers. The reserved INTERNAL-docker-privileged-for-nested-sandboxing target exists for boxes that must boot nested sandboxes.
  • boxless recipes — a recipe with no image: provisions its places (a worktree, a copied dir) and stops; wt and scratch are the bundled examples.

Egress

egress controls the box’s outbound network. As a scalar it is open (full outbound, the default when unset) or none (no network). As a mapping it routes ALL egress through the dabs proxy engine, which enforces two independent layers:
  • Policy — allow / deny (protocol-agnostic). The engine checks the CONNECT host against these patterns on the plaintext host:port, before any tunnel, for every protocol (HTTPS, WebSocket, raw TCP — HTTP cannot skip it). allow default-denies the rest; deny default-allows it; they are mutually exclusive. A pattern is example.com (exact), *.example.com (any subdomain, not the apex), or * (all). Write a comma-separated string or a YAML list.
  • Content — http_proxy (HTTP/1.1 only). An ordered chain, box→internet, of tls boundary directives and hook modules. A tls: terminate opens a plaintext window (optionally scoped to domains:) that a tls: originate closes; module hooks inside it inspect decrypted HTTP through four streaming verbs (onRequest, onRequestChunk, onResponse, onResponseChunk). Bodies stream through chunk by chunk — the engine never buffers. Non-HTTP traffic inside a terminate window (h2, WebSocket over TLS, any raw protocol) is re-originated untouched, so terminating a domain never costs the box its other protocols to it. Module paths resolve like sources: relative to the recipe’s own dabs.yaml.
  • Connection — a module OUTSIDE any tls window (protocol-agnostic). Such a hop sees no content; it acts on the connection through a fifth verb, onConnect({host, port}, ctx), returning nothing (or {action: "allow"}) to let the dial through and {action: "deny", reason} to refuse it with a 403. It runs on both proxy paths after the allow/deny gate has already allowed the host, so it can only narrow the recipe’s policy, never widen it. Hops run chain order and the first deny wins; a hook that throws, hangs, or answers with anything else denies the connection. A refusal is recorded in the connection ledger — the hop’s name and its length-capped reason — and nowhere else. A hop out here that also exports content verbs is warned about at boot: those verbs still need a tls: terminate to run.
  • Address (protocol-agnostic, not configurable in a recipe). Whenever the engine opens a connection for the box, it resolves the name once, checks every address it gets, and connects to an address it checked. It never connects to a loopback, unspecified, private (RFC 1918), shared (100.64.0.0/10), benchmarking, link-local, multicast, reserved or unique local address, to an IPv6 address that carries one of those IPv4 addresses (IPv4-mapped, NAT64, 6to4), to IPv4-compatible, IPv4-translated, local-use NAT64 or Teredo IPv6 addresses, or to any address on a network of one of the host’s own interfaces. If any address of a name is refused, the name is refused. It tries the addresses in order and gives each 5 seconds to answer. The box gets a 403 with the reason in the body and in the X-Dabs-Egress-Refused header (inside the TLS stream for a terminated host, or a closed stream for a non-HTTP one), and the connection ledger gets a deny-address line with the address. A ledger line for a connection that opened (tunnel, forward, originate) is written after it opened and names the address, or the upstream in upstream mode, so a refused connection shows only as refused. A tunnel opens before the box gets its 200. If the box closes before sending anything, or sends nothing within 10 seconds of the 200, the engine closes the CONNECT and its tunnel and writes a no-hello line with the reason. A ClientHello that carries Encrypted Client Hello is dropped with an ech-drop line. For a tunnel, both lines name the address too. A name that does not resolve, or whose addresses do not answer, gets a 502 with the reason in X-Dabs-Egress-Failed instead, so a client can tell a failure from a refusal. A request a hook answers itself opens no connection, so it is never refused for its address. The host operator can open given addresses with DABS_EGRESS_ALLOW_LOCAL in the environment dabs runs in: a comma-separated list of addresses or CIDR prefixes, announced as a warning at boot.
DABS_UPSTREAM_PROXY=http://host:port in the environment dabs runs in makes the engine reach every destination with a CONNECT through that HTTP proxy, and never dial the network itself. It is for a host whose only way out is another proxy, such as a VM with a closed network. In that mode the engine checks IP literals (against the same table) and refuses localhost names, but does not resolve other names: the upstream proxy must refuse a name that resolves to a local address. dabs starts the engine without HTTP_PROXY, HTTPS_PROXY, ALL_PROXY or their lowercase forms in this mode, so a hook’s own requests cannot leave by another proxy. A malformed value stops the boot. When the upstream answers a CONNECT with a 4xx or 5xx, the box gets that status, its body and its X-Dabs-Egress-Refused or X-Dabs-Egress-Failed header, so a refusal by an upstream dabs engine reaches the box with its reason. A 407 becomes a 502. Proxy egress needs bun and openssl on the host PATH, and a forwarder binary to mount in the box: the CLI carries one embedded (-tags withforwarder), and a program embedding dabs as a Go module — which has no embed — supplies its own with actions.Real.WithForwarder(path), any binary speaking the forwarder protocol. An explicit supply wins over an embed. Without either, open/none still work and proxy egress fails clearly at boot. egress: open has a host dependency of its own on Linux (the bwrap driver): the box’s network namespace is built by pasta, so the passt package must be installed and dabs must run as an unprivileged user. Without either, an open-egress boot fails clearly, naming the package. pasta must be snapshot 2025_05_03 or newer, for the address flags dabs passes: Debian trixie+ and Fedora 41+ package a new enough one, Ubuntu’s and Alpine’s current packages refuse those flags, and there you build from passt.top at the version contrib/recipes/dabseption.Dockerfile pins. egress: none needs nothing. The linux drivers mount that binary into the box. The apple driver mounts no host binary — a host one cannot run in the linux micro-VM — so there the image carries the forwarder at /run/dabs/forward (the boot probes for it, and the error names the Dockerfile lines that add it), and the image’s copy is what runs.