← Ravi and agents

Where Does the Agent Live?

Ravi's message bus answered anyone who could reach the machine. One flag fixed it.

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.

ToolThe question it answersEveryday picture
Docker imageWhat is inside, and how is it packaged?A packed moving box: the app plus everything it needs
Docker container or E2B microVMWhere does that box run, and how walled-off is it?A shelf in your own warehouse, or a locked rented room
NATSHow 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.1 means 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:4222 publishes on every interface by default, and on Linux it can bypass the ufw firewall. -p 127.0.0.1:4222:4222 is the safe spelling. Same lesson as our bus, different tool.
Out works, in does not: containers sit behind NATYour server (the host)Container: raviits localhost = itselfContainer: natsreachable on the bridge onlyby name, plain TCP, no authDocker network (bridge): finds neighbours by nameNAT on the hostout: allowedInternetin: dropped unlessa port is published
container networking · 2 containers, 1 bridge, NAT

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 connectsToday: -js -sd <dir>Fixed: -js -sd <dir> -a 127.0.0.1
The same machine (127.0.0.1)answersanswers
Someone on the Wi-Fi (192.168.0.x)answersrefused
The machine's global IPv6 addressanswersrefused
Ravi's exposure checkexposed, 6 addressesloopback_only
A normal client via localhostworksworks

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 runsIsolationStartupCostWho operates it
Plain process on the server (Ravi today)Weak: same machine and user, sees the secrets and the businstantfreenobody, it just runs
Docker container on our serverMedium: own files and network, shares the kernel and hardware with every clientabout 1 sfree on our serverus
E2B microVMStrong: own kernel, on separate hardware, thrown away after useabout 1 spay per useE2B
Full VM per clientStrongminuteshigh, always onus, 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.

Agents run in disposable rooms that only dial outOur server: control planeChannel adaptersWhatsApp, SlackRavi coresessions, memory, secretsNATS busone account per client, localhost insidePublic edgeHTTPS 443 onlyDispatcherstarts sandboxesE2B cloudSandbox: client Aagent, shell, toolsSandbox: client Bagent, shell, toolsE2B APIboots a microVM per requestcreatebootsTLS + key
control plane + sandboxes · 5 services, 2 sandboxes, 1 dial-out link

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 run creates 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:

  1. Authentication plus TLS on the bus. A credential per worker, allowed only its own subjects; revocable when stolen, indifferent to IP changes.
  2. A private network (Tailscale or WireGuard). Machines get stable private names wherever they are, and the bus listens only there.
  3. 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.
  4. 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:

  1. Secure default, explicit opt-in. Loopback unless someone deliberately says otherwise. Done for the bus.
  2. Opting into the network must not mean no lock. Exposing the bus should require, or at least loudly demand, authentication.
  3. Detect, don't assume. Ravi now warns at boot and in ravi doctor when its bus is reachable from the network, so old installs and mistakes surface.
  4. Restarts can lie. pm2 restart reuses 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 manual omni stop && omni start.
  5. 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

ChangeWhereStatus
API keys no longer travel over the bus or in command lines; exposure warning at boot and in ravi doctor; private ~/.ravifilipexyz/ravi#627open, CI green
Managed bus binds 127.0.0.1 by default, configurable with server.natsHost / NATS_HOSTautomagik-dev/omni#1271open
Authentication on the busnot startednext step before any cross-machine link
Dispatcher from the bus to E2B, sandboxes dialing back with per-sandbox credentialsnot starteddepends 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.