docs: bots run anywhere outbound-only — trust model + Go sidecar direction (#261)
A bot is a member whose "device" is a machine the operator chooses; the sidecar is a pure outbound client (no inbound ports), so running it off the chat server — home NAT, or a no-inbound VPC with egress allowlisted to the instance + model API — restores the full "server compromise gets nothing" guarantee. Record the decision to port the sidecar to Go (lchat bot subcommand), keeping agent/ as the Node reference implementation.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+115
−2
CLAUDE.md+16 −0README.md+11 −0agent/README.md+28 −0docs/bots.md+60 −2
diff --git a/CLAUDE.md b/CLAUDE.md| index 21a296c..393c539 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -43,6 +43,17 @@ Save-passkey card — so they can't be E2E-encrypted like profile/group photos. |
| Keep branding to this narrow set; anything that is *user* content still gets |
| encrypted client-side. |
| +**Bots** are the rule's scoped, disclosed exception-by-membership: a bot is an |
| +ordinary E2EE member whose "device" is a machine the operator runs (`agent/`), |
| +so that machine holds the bot's private key and can decrypt exactly the threads |
| +the bot is in — never anything else, and the lchat *server process* still never |
| +holds a usable key. Two invariants follow: **bot code never runs inside the |
| +server process** (compromising the server must not yield bot keys or the |
| +Anthropic key), and the sidecar stays a **pure outbound client** (no inbound |
| +ports), so operators can move it to a better-defended machine — off-box, behind |
| +NAT, or in a no-inbound private network. Trust model + the planned Go port (a |
| +`lchat bot` subcommand; `agent/` remains the Node reference): `docs/bots.md`. |
| + |
| Native iOS push goes through a central APNs relay (`push.elevenmessenger.com`, |
| `push/`) so the Apple `.p8` signing key lives in exactly one place instead of on |
| every instance. Like the admin token, the `.p8` is *auth*, not a decryption key: |
| @@ -72,6 +83,11 @@ in, so deploy is one file (no cgo). |
| **Authoring guide: `web/static/apps/README.md`** — read it before writing or |
| changing a mini-app. |
| - `push.go` — Web Push (self-hosted VAPID, `SherClockHolmes/webpush-go`). |
| +- `agent/` — the bot sidecar (Claude / Eleven / Announcer): a separate headless |
| + Node process that is just another E2EE member; holds bot private keys + the |
| + `ANTHROPIC_API_KEY`, outbound-only, runs on any machine (see `docs/bots.md`). |
| + A Go port (`lchat bot` subcommand) is the planned default; the Node version |
| + stays as the reference implementation. |
| - `unfurl.go` — OpenGraph link previews (SSRF-guarded fetch + cache). |
| - `web/pages/*.html` — served with `__GROUP_NAME__` substituted (HTML-escaped). |
| - `web/static/*` — vanilla ES modules, **no build step**, served via `go:embed` |
diff --git a/README.md b/README.md| index 5286095..139c373 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -47,6 +47,17 @@ see Trade-offs.) |
| guarantee above holds), but an *active* malicious server could substitute a |
| public key to intercept *new* conversations. Out-of-band key verification is |
| planned to close this. |
| +- **A bot is a member whose "device" is a machine you choose.** Adding a bot |
| + (see `agent/`) to a thread hands that thread's key to the machine the bot |
| + runs on — so whoever controls that machine holds the key to those |
| + conversations, exactly as if they were a member. Threads without a bot are |
| + untouched. Crucially, the bot doesn't have to live on the chat server: it's a |
| + pure outbound client (it dials the instance and the model API; nothing ever |
| + dials it — no open ports), so you can run it on any machine you already |
| + trust — a home server behind your router, or a box on a private network |
| + whose only way out is to your instance and the model API. Run it there and a |
| + fully compromised chat server still yields nothing: the keys are on a |
| + machine an attacker can't even reach. |
| ## Run it |
diff --git a/agent/README.md b/agent/README.md| index 8d29f29..0ee31b9 100644 |
| --- a/agent/README.md |
| +++ b/agent/README.md |
| @@ -6,6 +6,11 @@ ever sees ciphertext** — private keys and your `ANTHROPIC_API_KEY` live only o |
| this host. It shares `web/static/crypto.js` verbatim (imported directly), so |
| its wire format is guaranteed to match the app. |
| +> This Node sidecar is the **reference implementation**. A Go port (a `lchat |
| +> bot` subcommand — one static binary, no Node runtime) is the planned default; |
| +> see `docs/bots.md § Direction: a Go sidecar`. The Node version stays supported |
| +> as a working example for self-hosters. |
| + |
| | agent | what it does | |
| |---|---| |
| | **Claude** | The plain assistant: replies to every DM; in groups only when @mentioned (the default listen mode — the console can widen a bot to also answer its bare name, gated by a cheap "am I being addressed?" classifier). (`runtime.mjs`, identity via `LCHAT_SESSION`/`LCHAT_INVITE`.) | |
| @@ -51,6 +56,29 @@ an individual bot's listen mode to `name`, where a message that just uses its |
| name also gets a reply — but only when a small classifier call agrees the bot is |
| being spoken *to* rather than *about*, so chatting about a bot never summons it. |
| +## Where to run it |
| + |
| +Anywhere. The sidecar is a **pure outbound client** — it dials the instance |
| +(HTTPS/WSS) and the Anthropic API, and nothing ever dials it: no inbound |
| +ports, no webhooks, no public address. The server doesn't know or care where |
| +it runs. |
| + |
| +That choice is the biggest security decision you make about bots, because the |
| +bot's private keys unlock every thread it's a member of: |
| + |
| +- **On the instance box** (the hosted default): convenient, but a compromise of |
| + the chat server also reads the bot's threads. |
| +- **On a machine you trust more** — a home server behind NAT, a spare box, a |
| + Raspberry Pi: now a fully compromised chat server yields only ciphertext, |
| + same as for human members. The attacker can't even route to the keys. |
| +- **Inside a private network with no inbound at all**, egress allowlisted to |
| + the instance + `api.anthropic.com` (or Claude via Bedrock/Vertex private |
| + endpoints for zero public egress). The Announcer never calls the model API, |
| + so it needs only the one route to the instance. |
| + |
| +See `docs/bots.md § The sidecar runs anywhere` for the full threat-model |
| +discussion. |
| + |
| ## Eleven & the Announcer |
| **Eleven** needs to see members it shares no thread with (to welcome them), so |
diff --git a/docs/bots.md b/docs/bots.md| index 4c2a7f0..47afa9d 100644 |
| --- a/docs/bots.md |
| +++ b/docs/bots.md |
| @@ -39,9 +39,67 @@ Two consequences to design around: |
| many private threads, so its host transiently holds all their plaintext + keys. |
| Compromise of the host = read every thread that host's bots are in. Mitigate: |
| one sidecar per instance, minimise threads per bot, harden the host, keep no |
| - ambient prod credentials on it. |
| + ambient prod credentials on it — and above all, **run the sidecar somewhere |
| + better defended than the chat server** (next section). |
| - **No forward secrecy on removal** — removing a bot doesn't retroactively |
| - protect past messages, same as removing a human today. |
| + protect past messages, same as removing a human today. Future hardening that |
| + would bound both consequences: **rotate the thread key when a bot joins or |
| + leaves**, so a stolen bot key decrypts only the bot's membership window, not |
| + the thread's whole history. |
| + |
| +## The sidecar runs anywhere: it's a pure outbound client |
| + |
| +The sidecar never accepts a connection. It *dials out* to exactly two places — |
| +the instance (HTTPS/WSS) and the Anthropic API — and nothing ever dials it: no |
| +inbound ports, no webhooks, no route. The lchat server has zero knowledge of |
| +where it runs (a bot is just a member on normal endpoints). That makes the bot |
| +host a free variable in the threat model: |
| + |
| +- **Same box as the instance** (the default today, as separate systemd units): |
| + convenient, but it puts the bot's keys on the most exposed machine in the |
| + system. Root on the chat server reads every bot thread. |
| +- **Any other machine the operator trusts**: a home server behind NAT, a |
| + workstation, a Raspberry Pi. Root on the chat server then yields ciphertext |
| + and nothing else — the full "server compromise gets nothing" guarantee is |
| + restored, because the bot's keys sit on a box the attacker can't route to. |
| +- **A private network with no inbound at all**: NAT-only, egress allowlisted to |
| + the instance + `api.anthropic.com`. The bot machine's attack surface shrinks |
| + to supply chain and the message content it processes. For a no-public-egress |
| + deployment, Claude is also reachable via AWS Bedrock / GCP Vertex over |
| + private endpoints — and the Announcer needs no model API at all. |
| + |
| +The honest framing (for consent copy too): a bot is **a member whose "device" |
| +is a server**, and adding one to a thread extends that thread's E2E envelope to |
| +the machine the operator chose. Where that machine lives is the single biggest |
| +security decision the operator makes about bots. |
| + |
| +## Direction: a Go sidecar (decided 2026-07-06) |
| + |
| +The sidecar will be ported to **Go** — ideally a subcommand of the single |
| +binary (`lchat bot`), still run as its **own process** with its own env (never |
| +inside the server process: the bot holds private keys + the Anthropic key, and |
| +folding it into the server would collapse the trust boundary above). The Node |
| +sidecar (`agent/`) stays in-repo as the **functional reference implementation** |
| +for self-hosters who prefer it. |
| + |
| +Why: |
| + |
| +- **Kills the Node runtime dependency** — today the router box needs Node 24 + |
| + `npm ci` + a separate rsync deploy path solely for bots. |
| +- **Makes "run it anywhere" real**: copying one static binary to a Raspberry Pi |
| + or a locked-down VPC box is a far better story than standing up Node there. |
| +- Nothing in the sidecar is Node-specific: `ws` → `coder/websocket` (already a |
| + dependency), `@anthropic-ai/sdk` → the official Go SDK (which also speaks |
| + Bedrock/Vertex), and the crypto is ECDH P-256 + HKDF + AES-GCM, all stdlib. |
| + |
| +The one real cost: Go must reimplement `crypto.js`'s wire format |
| +(ECIES seal/wrap, `iv||ciphertext` base64url) byte-for-byte and stay in |
| +lockstep — the Node sidecar gets that for free by importing the file. The |
| +native-app work is already committed to publishing **cross-language crypto test |
| +vectors** for its Swift port; the Go sidecar becomes the second consumer of the |
| +same fixture set. Incremental path: port the **Announcer** first (write-only, |
| +no model API — exercises provision/connect/encrypt with the least surface), |
| +then the shared runtime, then Eleven's persona layer. |
| ## What exists today (`agent/`) |