Socket Truth
A local-first app is a web server running on your machine, and every defensive header a browser sends is something the attacker can write. o8 stamps the real address on the other end of the socket onto each request and treats that — not Host, not Origin, not sec-fetch — as the only authoritative answer to “is this request actually local?”
“Local-first” means there is an HTTP server on your laptop, and your browser can reach it.
o8 runs its backend on localhost. That is the whole pitch of local-first: your repos, your agents, your approval history never leave the machine. But the moment you bind an HTTP server to a port, you have created something any web page in your browser can try to talk to — and, on a shared network, something a machine down the hall can try to reach too.
This is not hypothetical. A malicious site you have open in another tab can fire requests at http://localhost:3001all day. If even one of those requests can dispatch an agent, read a diff, or merge a branch, the “local-first” story is a liability instead of a feature. So the real question o8 has to answer on every single request is narrow and load-bearing: did this come from the app on this machine, or from somewhere else pretending to be?
Host, Origin, sec-fetch-site — every signal the naive defense reaches for is written by the client.
The obvious defenses are all header checks. Reject anything whose Origin isn't us. Trust sec-fetch-site: same-origin. Require a Host of localhost. Every one of those is a string the requester chose. An attacker scripting a request sets Host to whatever they like, omits sec-fetch entirely, or forges an Origin. You are asking the person trying the door to fill out a form swearing they live there.
You can't build a trust boundary out of fields the untrusted party gets to write.
There is exactly one thing in an HTTP request the client cannot forge: the source address of the TCP connection it opened. You cannot spoof the far end of a socket and still receive the response — the packets have to come back to you. That address is the truth. The entire gate is built on moving that truth up out of the network layer and putting it where the request handler can see it, before the handler trusts anything else.
The bundled server writes the real peer address onto every request before any route runs — and overwrites whatever the client sent.
In a packaged build, o8's server wraps Node's request handling so that the first thing that happens to any incoming request is a stamp: it reads the actual remote address off the socket and writes it into x-o8-client-addr, clobbering any value a client tried to supply for that header. By the time the middleware sees the request, that header is not a claim. It is a fact, set by our own process from the kernel's view of the connection.
The middleware then inverts the usual order of trust. When the socket-truth header is present, it is authoritative: a request that arrived on a non-loopback socket can only pass with a valid token, regardless of how friendly its Host, Origin, or sec-fetch-*headers look. Those client-controlled signals only get a vote in the one case where they can't do harm — when the socket itself already says the request is loopback.
| Request arrived on | Header evidence | Verdict |
|---|---|---|
127.0.0.1 / ::1 | ignored — not needed | Pass (loopback) |
tauri://localhost | ignored — not needed | Pass (the app webview) |
| A non-loopback socket | perfect same-origin headers | Blocked unless token |
| A non-loopback socket | valid Bearer token | Pass |
The subtlety in row three is the whole point. A page loaded over your LAN issame-origin with itself; it can present every header a legitimate request would. Socket truth is what lets us say “nice headers, but you came in from off-box, so no” without ever having to reason about whether the headers were honest.
Everything that touches agent or repo state, from off the machine, needs a secret the page can't read — compared in constant time.
There has to be a legitimate way in from off-box, because the whole point of o8 is that you can drive your fleet from your phone. That path is a bearer token. A cross-origin request carries Authorization: Bearer <token>, and the middleware compares it against the secret in ~/.o8/ws-tokenusing a constant-time comparison, so an attacker can't learn the token a character at a time by measuring how long the rejection takes.
The token reaches your phone the only safe way: the pairing QR code, or the #tk= fragment in the link the desktop hands you. The /mobilepage only embeds the token in its HTML when the page itself was loaded over loopback — a LAN browser that stumbles onto the page gets the shell, never the secret.
Not every route needs the gate, and pretending it does would break first-run. The middleware keeps two narrow, explicit allowlists:
- Read-only, public GET— the first-run setup wizard and the auth callbacks, which have to work before any token exists. Listed by exact prefix, GET only.
- Any-method, allowlisted— the handful of OAuth callback shapes that legitimately arrive as a POST before you're authenticated.
Everything else — every route that can dispatch an agent, read a diff, touch a lane, merge a branch — sits behind the gate by default. The rule we enforce on ourselves is simple: a new route that touches agent or repo state is added to the gated set in the same change that creates it, or it doesn't merge.
In dev, there is no socket stamp — and we tell you exactly what that costs.
The stamp only exists in the packaged server we ship. Under a plain dev server, the wrapper isn't there, so the middleware falls back to best-effort Host-based heuristics. That is a real, deliberate seam: a determined attacker on your LAN, while you are running a raw dev build, could get further than they could against the shipped app.
We would rather document the dev-mode gap in plain language than pretend the heuristic fallback is as strong as the socket. It isn't, and saying so is the point of writing this down.
We accept that cost because the people running raw dev builds are building o8, on their own machines, behind their own firewalls — and because the path every actual user is on, the signed and packaged app, has the stamp on by default. Security you have to remember to turn on is security most people don't have.
The IDE is where you work. The gate is what makes it safe to put autonomous agents behind it.
A code editor that talks to a model only has to protect a text buffer. o8 is a control plane that can spawn workers, rewrite branches, and merge code — on your machine, with your credentials. The blast radius of one forged request is not a typo; it's an agent doing real work you didn't ask for. So the boundary between “the app” and “a web page that found the port” has to be something stronger than good manners.
Socket truth is that something. It is the layer underneath the friendly part of o8 — the part you never see, doing the one job that lets everything above it be as open and fast as it is. Other tools ask the request to behave. We check the address on the envelope.
The full design record — the trust tiers, the token delivery paths, and the rule for adding a new gated route — lives in docs/loopback-api.md in the o8 repo.