One missing flag
The message bus inside Ravi was answering anyone who could reach the machine: the café Wi-Fi, and on our test Mac, the public internet over IPv6. Nobody chose that. The program that starts the bus never said where to listen, so it listened everywhere.
Fixing it took one flag. Understanding it took a tour through Docker, NAT, IPv6, sandboxes and message buses, because that is exactly the stack we are about to build our enterprise product on. This post is that tour, written for the people who will make the product calls, not only the people who write the code.
Three tools, three different questions
Docker, E2B and NATS are not competitors. Each answers a different question, and a real system uses all three.
| Tool | The question it answers | Everyday picture |
|---|---|---|
| Docker image | What is inside, and how is it packaged? | A packed moving box: the app plus everything it needs |
| Docker container or E2B microVM | Where does that box run, and how walled-off is it? | A shelf in your own warehouse, or a locked rented room |
| NATS | How do the running pieces talk to each other? | The internal mail system between rooms |
They stack. E2B sandboxes are built from a template that is essentially a Docker-style recipe ("install Bun, NATS, Ravi"), so Docker is the recipe format and E2B is the locked room it runs in. NATS does not care what kind of room a program sits in; it carries messages between plain processes, containers and microVMs alike.
A container is a process wearing a disguise
A container is not a small computer. It is an ordinary process that the Linux kernel gives three illusions: its own files, its own process list and its own network. It shares the host's kernel, which is why it starts in about a second and why its isolation is weaker than a virtual machine's. On a Mac, Docker Desktop quietly runs a Linux VM and the containers live inside it.
The network illusion is the one that bites:
- Its own
localhost. Inside a container,127.0.0.1means the container itself, not your machine. - Out works, in does not. Containers reach the internet through NAT, exactly like your laptop behind a home router. Nothing from outside reaches them unless you publish a port.
- Neighbours talk freely. Containers on the same Docker network reach each other by name (
nats:4222) over plain TCP. There is no authentication between them; that is the application's job. - The publishing trap.
-p 4222:4222publishes on every interface by default, and on Linux it can bypass theufwfirewall.-p 127.0.0.1:4222:4222is the safe spelling. Same lesson as our bus, different tool.
Containers reach the internet through the host's NAT; nothing reaches them unless a port is published, and then on every interface by default.
Reachable is not the same as allowed
Being able to connect only means a door exists. Whether you may walk through it is decided by the program behind the door: a password, a token, a certificate, or nothing at all. NATS ships with nothing at all, and with no bind address it listens on every interface.
That is two separate facts about networks colliding:
- IPv4 is shared. Your home gets one public address and the router hands out private ones (
192.168.x.x). Strangers on the internet cannot start a connection to your laptop, because the router does not know which device they mean. Exposure stops at the local network, which is still the whole café. - IPv6 is personal. Addresses are so plentiful that many ISPs give every device its own public, routable address. There is no NAT in front of it. Only a firewall, on the router or on the machine, stands between that address and the internet.
We ran the real nats-server on a Mac, first with the arguments Omni uses today, then with the one-flag fix (-a 127.0.0.1):
| Who connects | Today: -js -sd <dir> | Fixed: -js -sd <dir> -a 127.0.0.1 |
|---|---|---|
The same machine (127.0.0.1) | answers | answers |
Someone on the Wi-Fi (192.168.0.x) | answers | refused |
| The machine's global IPv6 address | answers | refused |
| Ravi's exposure check | exposed, 6 addresses | loopback_only |
A normal client via localhost | works | works |
Nothing legitimate broke, because every program that needs the bus runs on the same machine. The fix is proposed upstream in automagik-dev/omni#1271.
The one real trade-off: where the agent runs
The only genuine "versus" in this stack is where the AI agent's code executes. Ravi's agents run shell commands while reading messages from strangers, so a prompt injection is a when, not an if. The question is what a hijacked agent can touch.
| Where the agent runs | Isolation | Startup | Cost | Who operates it |
|---|---|---|---|---|
| Plain process on the server (Ravi today) | Weak: same machine and user, sees the secrets and the bus | instant | free | nobody, it just runs |
| Docker container on our server | Medium: own files and network, shares the kernel and hardware with every client | about 1 s | free on our server | us |
| E2B microVM | Strong: own kernel, on separate hardware, thrown away after use | about 1 s | pay per use | E2B |
| Full VM per client | Strong | minutes | high, always on | us, heavily |
For many clients and untrusted input, the microVM row wins: a hijacked agent can only wreck its own disposable room. That is why the product is heading to a shared control plane with per-client sandboxes.
The shape we are heading to: a control plane and disposable rooms
Long-lived, trusted things stay on our server; risky, short-lived work runs in sandboxes that only ever dial out.
The dispatcher asks E2B for a sandbox; the sandbox then dials back to the bus over TLS with its own key, so nothing in it ever accepts a connection.
- Control plane (our server). WhatsApp and Slack connections, sessions, memory, secrets and billing. Docker Compose runs each service; only HTTPS (443) is open to the world.
- NATS does not start sandboxes. It only carries messages. A dispatcher we write listens for "work needed" on the bus and calls E2B's HTTPS API to create a sandbox. Ravi already has an early version without the bus:
ravi sandbox runcreates a sandbox, runs one task and brings back a patch. - Sandboxes live in E2B's cloud, not on our server. Each one boots from the Ravi template, runs the agent, then is killed or paused.
- The link back crosses the internet, so it needs TLS and its own key. The dispatcher mints a short-lived credential per sandbox, scoped to that client's subjects. No sandbox ever holds a master key, and nothing in it accepts inbound connections.
- Clients are separated twice: by separate microVMs for the agents, and by separate NATS accounts on the shared bus.
Crossing boundaries: trust who, not where
The moment something must reach the bus from somewhere else, the rule is to trust who is connecting, not where they connect from.
Same server, Docker. A bus bound to 127.0.0.1 on the host is unreachable from a container, whose localhost is itself. Put both in one Compose network instead, connect by name and publish nothing:
services:
nats:
image: nats:2.12
command: ["-js"] # 0.0.0.0 here means the private Docker network only
ravi:
environment:
NATS_URL: nats://nats:4222
Different machines. IP allowlists break as soon as addresses rotate, which they always do in Kubernetes, on home internet and on mobile. In order of preference:
- Authentication plus TLS on the bus. A credential per worker, allowed only its own subjects; revocable when stolen, indifferent to IP changes.
- A private network (Tailscale or WireGuard). Machines get stable private names wherever they are, and the bus listens only there.
- NATS leaf nodes. Each remote site runs a small local bus bound to localhost that dials out to the central one with credentials. No edge machine opens an inbound port.
- IP allowlists as an extra layer, for fixed servers only, never the only lock.
Kubernetes never relies on IPs either. Services give stable names (omni-nats.omni.svc), network policies select pods by label, and mutual TLS gives each workload an identity.
Lessons, with Ollama as the cautionary twin
Ollama, the local model runner, made the same bet we just made, and its history shows where the bet fails. It listens on 127.0.0.1:11434 by default, offers OLLAMA_HOST to change that, has no authentication, and restricts which websites may call it. When many users set OLLAMA_HOST=0.0.0.0 to reach it from another device, researchers repeatedly found large numbers of unprotected Ollama servers on the open internet. (From memory, not re-sourced for this post.)
What we take from it:
- Secure default, explicit opt-in. Loopback unless someone deliberately says otherwise. Done for the bus.
- Opting into the network must not mean no lock. Exposing the bus should require, or at least loudly demand, authentication.
- Detect, don't assume. Ravi now warns at boot and in
ravi doctorwhen its bus is reachable from the network, so old installs and mistakes surface. - Restarts can lie.
pm2 restartreuses the arguments a process was created with, so a fixed default never reaches an existing install. The fix has to recreate the process, and the first upgrade still needs one manualomni stop && omni start. - Localhost is the lock, not the house. Loopback stops other machines. It does not stop other programs on the same machine, and our agents are exactly such programs. That is the deeper reason the agents are moving into sandboxes.
Where things stand
| Change | Where | Status |
|---|---|---|
API keys no longer travel over the bus or in command lines; exposure warning at boot and in ravi doctor; private ~/.ravi | filipexyz/ravi#627 | open, CI green |
Managed bus binds 127.0.0.1 by default, configurable with server.natsHost / NATS_HOST | automagik-dev/omni#1271 | open |
| Authentication on the bus | not started | next step before any cross-machine link |
| Dispatcher from the bus to E2B, sandboxes dialing back with per-sandbox credentials | not started | depends on product decisions |
The fix that started this was one flag. The product decisions behind it are not: where each client's agents live, what stays long-lived, and what a client is willing to pay for isolation.
A follow-up, Ravi sandboxes: context, shared data and security, works through what a sandbox needs before it can do real client work.