Field Notes

Tightening the wire: encrypted agent-to-agent comms with SSH tunnels

August 28, 2026 · Katy, Texas

We run a fleet of autonomous AI agents — each one is a Hermes agent with its own identity, tools, and memory. They communicate using a peer-to-peer protocol called A2A (Agent-to-Agent). Orders, reports, and bearer tokens travel between them over plain HTTP. On a LAN behind a firewall, that's arguably fine. But fine isn't how you build something you intend to sell to law firms.

We had three agents to wire up. Donut and Sparky share a Debian host. Jarvis lives on a separate Mac. Each listens on port 9900 for inbound messages from peers. By default, those listeners were bound to 0.0.0.0 — accessible to anything that could reach the port. Not ideal.

The fix uses exactly one tool that was already in the stack: the fleet SSH key. Zero new software, zero new secrets to manage, nothing to configure at the application layer.

The hardware

Every agent carries a single fleet-wide SSH key. This key gives us passwordless root access to every host in the fleet. It's the skeleton key — one master secret, locked in a 600-perm file, never shared. If you lose it, you rotate every host. That's the trade you make when you choose a single key over per-host keys: simpler mesh, louder alarm if it leaks.

The architecture is a star: one Debian host (the hub) initiates SSH connections to every other host in the fleet, holding loopback port forwards open. The agents themselves never connect outward. They bind to 127.0.0.1 and wait for traffic arriving through the tunnel.

┌─ Debian host ──────────────────────────────────┐ │ │ │ Donut ←── 127.0.0.1:9900 │ │ Sparky ←── 127.0.0.1:9901 │ │ Mitch ←── 127.0.0.1:19902 │ │ │ │ systemd tunnel (hermes-a2a-tunnel.service) │ │ │ │ │ ├── -L 127.0.0.1:19900→house:9900 (Jarvis) │ │ └── -R 19000→debian:9900 (Donut to house) │ │ └── -R 19001→debian:9901 (Sparky to house)│ └─────────────────────────────────────────────────┘ │ SSH (fleet key) ▼ ┌─ Mac (house) ───────────────────────────────────┐ │ │ │ Jarvis ←── 127.0.0.1:9900 │ │ (reaches Donut via 127.0.0.1:19000) │ │ (reaches Sparky via 127.0.0.1:19001) │ └─────────────────────────────────────────────────┘

Link-local forwards (-L) bring remote ports to the hub. Reverse forwards (-R) punch return paths back so the remote host can reach agents on the hub. Every binding is to 127.0.0.1 — nothing listens on a routable address.

The systemd unit

The whole thing is a single systemd service with Restart=always. If the SSH session drops (network blip, host reboot), systemd reconnects it within seconds. The forwards are restored atomically because SSH refuses to start unless every forward succeeds.

ExecStart=/usr/bin/ssh -N -T \
  -o BatchMode=yes \
  -o ServerAliveInterval=15 \
  -o ServerAliveCountMax=3 \
  -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:19900:127.0.0.1:9900 \
  -R 127.0.0.1:19000:127.0.0.1:9900 \
  -R 127.0.0.1:19001:127.0.0.1:9901 \
  house

Let's walk what each flag does:

Identity and trust at the application layer

The SSH tunnel encrypts the transport. What about the messages themselves? Every A2A message carries a bearer token that identifies the sender. The receiving agent maps that token to a trusted peer name from an environment variable:

# ~/.hermes/.env
A2A_PEER_TOKENS=sparky:b52b...,jarvis:4f3d...,mitch:f4e2...

When Sparky sends a message to Donut, Donut's A2A adapter reads the token, looks it up in A2A_PEER_TOKENS, and if it matches, the message is attributed to "Sparky" — a trusted crew member. If the token doesn't match any peer, the message is treated as untrusted input and the agent refuses to act on it.

This is a two-layer security model:

Both layers are independent. Breaking one does not break the other. An attacker who steals the fleet key can reach the tunnel port, but still cannot send a message that Donut will treat as coming from Sparky — they'd need the bearer token too.

The fleet certificate

The fleet SSH key is an Ed25519 keypair, generated once and distributed to every host in the fleet. It lives at ~/.ssh/fleet on every machine, permissions 600, owned by the local user that runs the agent gateway.

Adding a new host means appending the public half to ~/.ssh/authorized_keys on the target. One line. The private half never touches the new host's disk — it only exists on the hub (the initiating side). The remote host only needs the public half to authenticate inbound connections.

The agent read from that key is a step we explicitly own. No cloud secret store, no SSH CA, no third-party authentication provider. The key was generated by the operator, distributed by the operator, and if it needs to be rotated, the operator rotates it everywhere. That's the model we're building toward: the operator owns the infrastructure, the customer just uses the appliance.

ssh-keygen -t ed25519 -f ~/.ssh/fleet -C "hermes-fleet@hermes-lxc"

The fingerprint that identifies our fleet today:

256 SHA256:V+613Qr/0g2lfSCYuuRA2aEIKJAFX+pkV7PwO/Y7MGY hermes-fleet@hermes-lxc (ED25519)

What we didn't do

We didn't install WireGuard. We didn't set up Tailscale. We didn't configure mTLS or generate per-service certificates. We didn't add a sidecar proxy. All of those would work — all of them would also be more software to update, more keys to manage, more surface area to audit.

The SSH tunnel approach is deliberately primitive. SSH is already on every Linux and macOS host. The fleet key already exists for remote administration. We reused the authentication layer we already had and tightened the bindings from 0.0.0.0 to 127.0.0.1. That's the whole change.

Lessons learned


← Back to Field Notes