The decision: one tunnel per tool group, no exceptions
Ankita is my open-source desktop AI companion — "a little companion, a lot more possible". Its ChatGPT Browser provider lets ChatGPT act through your own open chat tab: no API key, your ChatGPT plan's limits apply. And once that door is open, ChatGPT gets tool groups with very different blast radii: Core (files and commands in your workspace) and Desktop (task browsers, and your manually connected computer-use driver that can actually move your mouse).
The decision I made, stated up front: every tool group gets its own dedicated OpenAI tunnel, and Ankita refuses to let two groups share one. Reuse a tunnel ID and the save is rejected — before a key is stored, before a competing connection starts, including simultaneous setup attempts. One tunnel, one group, enforced by the code, not by the setup guide.
The pain this is for
The threat model here isn't a hacker. It's the plumbing.
Each group runs its own instance of the official OpenAI secure-tunnel helper to publish its MCP endpoint to your ChatGPT workspace. Put two helpers on the same tunnel ID and they compete for the same channel. The docs spell out the failure mode plainly: it "can send a Core request to Desktop" — a file operation's request arriving at the surface that can drive your desktop. A confused request landing one surface over, on the machine that can click things.
I could have routed all three surfaces through one tunnel and demultiplexed at the gateway. I chose not to. The tunnel is where I draw the boundary, because the channel competition happens below anything my code can mediate — it's the official helper's own channel, and two of my helpers fighting over it is a race I can never win with admission logic.
How the boundary is built
Three surfaces, declared as separate discovery/permission boundaries in protocol.mjs (NATIVE_TOOL_SURFACES = core, desktop, plugins): Core reads and writes files in the selected workspace; Desktop drives task browsers and the computer-use driver; Plugins exposes only the external plugins currently enabled. Core uses the tools you've already connected — changing global plugin or MCP connections stays in Ankita's own settings; ChatGPT through the tab can't rewire the companion's connections from inside the chat.
Per group, the user creates one OpenAI tunnel and one restricted key scoped to Tunnels Read and Use — nothing broader. The tunnel ID is validated against the official grammar (tunnel_[a-f0-9]{32}) before anything else happens, and the key is saved through the device's secure storage under a per-surface key name (chatgpt-tunnel- + surface), so a Core key and a Desktop key are never the same stored secret.
The helper itself doesn't get trust on install either. The binary is SHA-256 pinned against a vendored publisher record (vendor/openai-tunnel/source.json) — mismatch throws before spawn. Its environment is filtered to an OS/TLS allowlist only; the restricted key travels in as an env var and is deleted from it in a finally block. What the UI can see is deliberately narrow: the status view returns surface and status, and "credentials, argv, URLs, raw helper logs and health paths never become UI events." The health signal I do watch is the helper's own control-plane poll counter, read from its loopback health file.
And even inside a dedicated tunnel, the gateway admits nothing on vibes. Each surface gets its own endpoint URL with a random capability token baked in, matched with a timing-safe comparison and marked no-store so it never enters a shared cache. Batch admission is deliberately absent — one authoritative receipt per HTTP/RPC call. Hosted MCP reuses RPC id 0 across distinct native calls, so the receipt identity can't be the id alone: it's [leaseId, surface, callId, id-type, id]. Every call must prove request ownership — a request that doesn't own the lease gets a 403. Once admission commits, a dropped socket can't cancel or forget the side effect; the scoped ToolHost owns Stop. Gateway shutdown drains every admitted execution first: client disconnect never authorizes replay.
The tunnel/gateway/origins/ownership suites pass 27/27 locally.
The honest limits
This boundary defends against helper channel competition and confused routing — not against a compromised official helper. SHA-pinning a vendored binary catches a swapped file; it says nothing if the publisher itself ships something hostile. And the setup friction is real: three tunnels, three restricted keys, three ChatGPT MCP apps to create and enable. I judged the friction cheaper than the failure.
The question I'd actually argue about
Here's the design question, and I want the adversarial version: the gateway already admits every call per surface — secret capability URLs, per-surface receipts, ownership checks, no batch admission. With all of that in place, what does the second tunnel actually buy? Is per-group tunnel isolation a genuine failure boundary, or is it ceremony over an admission layer that was already sufficient? If you'd merged all three surfaces onto one tunnel with strict per-surface admission, where exactly would it break first — and is the answer "the helper's channel competition," i.e., is this a security boundary or a correctness hack wearing a security costume? Tell me I'm wrong; the code is at https://github.com/akyourowngames/A.N.K.I.T.A if you want to check my work.
Top comments (0)