← Blog Security & Trust 1 September 2026 9 min read

Driving Chrome without leaving a port open

An agent that can click in your signed-in browser is powerful, and so is anything that can talk to it. Here is how Luminair's Chrome link went from a loopback port to a pipe that only a signed browser, a signed helper and you can open.

Fig 01  Three versions of the same wireChrome to Luminair
CHROME LUMINAIR 1 · 5 AUG, MORNING ExtensionWebSocket client 127.0.0.1:8788 open TCP port Appapproves the id once any local process can connect 2 · 5 AUG, NIGHT ExtensionconnectNative() stdio Relay, launchedby Chrome /tmp/ow-nmh-<uid>.sock no port · fixed path · JavaScript relay Apppinned extension id 3 · FROM 1 SEP Extension+ nonce challenge Signed native hostcompiled, hash-pinned private socket, random path, rotated secret peer uid, pid, signature checked · HMAC before any browser byte Appyou arm it per launch
Drawn from the commits of 5 August, 31 August and 1 September 2026. Dashed lines mark the transport that is gone: the loopback WebSocket fallback was retired on 31 August. Row 3 is how Luminair's Chrome bridge works today, on macOS.

The short version

  1. Luminair's Chrome extension lets an agent read pages, click, type and take screenshots in your own Chrome, where you are already signed in.
  2. It clicks through the Chrome DevTools Protocol, so a page sees real input, and it asks for access to your sites only when you first use it.
  3. Its first link to the app was a WebSocket on a loopback port. Loopback keeps other machines out, not other programs. The same night it moved to Chrome native messaging, and the port was retired for good on 31 August 2026.
  4. Today a signed helper, a private socket, kernel identity checks and a per-connection secret stand between Chrome and the app, and the agent only gets its Chrome tools after you turn them on for this launch.
02Built with Luminair

Tested on a live page.

The extension was built in one day, 5 August 2026, in Luminair sessions, and every commit that day carries a Claude co-author line. The first version was not checked by reading code. The commit records a run in a real Chrome, 10 of 10 operations against a live page: read it, typed into it, clicked a button and confirmed the page changed, captured a PNG.

That run caught a bug worth knowing about. An error thrown inside a function injected into a page never came back as a failure, so clicking a selector that matched nothing reported success. Injected code now returns its error explicitly and the bridge raises it. An agent that is told “done” when nothing happened is worse than one that fails.

03The year browsers got agents

The browser became the agent.

On 21 October 2025 OpenAI launched ChatGPT Atlas, a browser with ChatGPT built in. As TechCrunch reported, “By using ‘agent mode,’ users can ask ChatGPT to complete small tasks in the browser on their behalf.” Perplexity's Comet was already out, and Anthropic's Claude in Chrome extension was in preview.

The same day, Brave's security team published more prompt injection findings in Comet and other AI browsers. In one, instructions were hidden in an image as “faint light blue text on a yellow background”, invisible to the user but read by the assistant. Their summary of the whole category is the sentence to remember: agentic browser assistants can be prompt-injected by untrusted webpage content, “rendering protections such as the same-origin policy irrelevant because the assistant executes with the user's authenticated privileges.”

In November, Anthropic described its own defenses in Mitigating the risk of prompt injections in browser use: training, classifiers that scan untrusted content, and red teaming. It was frank about the limit: “No browser agent is immune to prompt injection”.

Those are problems of what a page can say to a model. There is a second, plainer question that gets less attention: who else can talk to the thing that drives your browser? That part is on the app, and it is the subject of this post.

A browser agent acts with your cookies. Whatever can reach it can too.
04Real clicks, asked-for access

Input a page cannot tell apart.

The first extension clicked by running el.click() inside the page, which most sites accept and some guarded login forms and rich editors ignore. The last build of the evening switched to the Chrome DevTools Protocol, the interface DevTools itself uses. The extension attaches Chrome's debugger to the tab and sends browser-level input:

desktop/chrome-extension/background.jsclick, abridged
// click: move, press, release at the element's centre
await dbg('Input.dispatchMouseEvent', tabId,
  { type: 'mouseMoved', x, y });
await dbg('Input.dispatchMouseEvent', tabId,
  { type: 'mousePressed', x, y, button: 'left' });
await dbg('Input.dispatchMouseEvent', tabId,
  { type: 'mouseReleased', x, y, button: 'left' });
// typing: Input.insertText · Enter, Tab, Escape: Input.dispatchKeyEvent

There is a visible cost, and it is a good one. While the debugger is attached, Chrome shows a bar saying Luminair started debugging this browser. If you press Cancel on that bar, Chrome detaches, and the extension falls back to the in-page path. Pages Chrome will not let a debugger touch, such as chrome:// pages and the Web Store, use the fallback too.

An hour earlier the same evening, a change about install-time trust had landed. Access to all sites moved from a permission granted at install to an optional one, requested from the extension's popup the first time you use it, so installing shows no “read and change all your data” warning. The popup offers one button, Give Luminair access to your tabs. Until you press it, reading a page, its HTML or a screenshot fails with an error that tells you where that button is.

To be precise about the limits: that grant covers page reading. Clicks and keystrokes go through the debugger permission, which the extension declares at install, and which Chrome makes visible with its debugging bar whenever the extension is attached.

05The port anyone could knock on

Loopback keeps out machines, not programs.

The first transport between the extension and the app was a WebSocket server bound to 127.0.0.1, on port 8788 with four spares. The reasoning in the commit is careful. HTTP polling was out, because Chrome stops an idle extension service worker after about 30 seconds and a long poll would kill the bridge. WebSocket traffic resets that timer, so a ping every 20 seconds keeps the link alive.

The same commit also names the weakness, in its own words: “Loopback alone authorizes nothing”, because “any local process can open a socket.” Nothing off your Mac could reach the port. But any program on it, from a stray npm postinstall script to a tool an agent had just downloaded, could connect and say it was the extension. The defence was an approval step: the app refused every operation until you approved the extension's id once.

That is a thin wall for something that clicks in your signed-in browser. An id is a string. A process that can open the port can also send the right string.

06Let Chrome make the call

No port to scan.

Chrome has a built-in answer: native messaging. The extension calls chrome.runtime.connectNative with a host name. Chrome looks up a small manifest file for that name, checks that the calling extension is listed in its allowed_origins, and only then launches the program the manifest names, talking to it over standard input and output. Nothing listens on the network at all.

This is also how Anthropic wires its own extension to Claude Code. The Claude Code Chrome docs say that the first time you enable the integration, “Claude Code installs a native messaging host configuration file.”

The first commit had ruled native messaging out, because the manifest must name an exact extension id and ids change with how an extension is loaded. The fix was to pin a public key in the extension's manifest, which fixes the id wherever it is loaded from. That made batch B possible, at 22:18 the same night: Chrome launches a tiny relay, the relay pumps bytes to the app over a unix socket, and the socket is guarded by file permissions rather than by a port number.

For a few weeks the WebSocket stayed up as a fallback so an upgrade could never break a working bridge. On 31 August the fallback was retired. The bridge module still exports its old list of ports, now empty, with a comment saying why: so the retired transport “can never accidentally reopen a browser-reachable loopback listener.”

07Six checks before one message

A pipe with several locks.

Batch B still had soft spots, and the file header of today's version lists them by what it no longer has: “no user-writable JavaScript relay or deterministic /tmp socket.” The first relay was a script that ran through the app's own binary, and the socket lived at a path anyone could predict. On 1 September both went. The relay became a compiled, code-signed program, and the connection now has to pass a chain of checks before the app accepts a single message from the browser.

Fig 02  From connectNative to the first tool calldesktop/lib/native-messaging.js
WHO CHECKSWHAT MUST BE TRUE CHROMEThe calling extension is the pinned id in the host manifest OSSocket 0600 in a fresh 0700 folder, random name every launch APPKernel says the peer is the same user, and gives its pid APPThat pid runs the exact host: same SHA-256, valid signature APPIts parent is Chrome, Brave or Edge, signed by that vendor HOSTHMAC of generation, nonce and challenge, keyed by a rotated secret EXTEchoes a fresh 32-byte challenge with the pinned id YOUArm Chrome control for this launchonly then does the agent get the 18 chrome_ tools else Chrome never launches the host else no other account can reach the socket else the socket is closed else the socket is closed else the socket is closed 3 seconds to prove it, or closed else “rejected”, and closed One live connection at a time. When it closes, the secret and generation rotate.
The order of checks in the app's native-messaging module and bridge, as of 1 September 2026. Filled dots are automatic; the hollow dot is the one only you can pass. The team identifiers for each browser are hardcoded in the module.

The unusual part is the peer check. A unix socket can tell the listening side the user id and process id of whoever connected, straight from the kernel. The app asks the signed host to read those for the accepted socket, then looks up that process's executable, hashes it, checks its signature, walks to its parent and checks that it is a signed browser. A script that connects to the socket directly fails at the executable check, and there is a test that does exactly that.

The secret is the last lock. The app writes a fresh capability and generation to a private file when it starts listening and again each time a connection ends. The host must prove it knows the capability with an HMAC over a nonce it picked and a challenge the app picked, within three seconds, before any browser bytes are relayed.

Why so manyEach check covers a different impostor. The file permission stops other accounts. The kernel identity stops a lookalike path. The hash and signature stop a swapped helper. The parent check stops a helper launched by something other than a browser. The HMAC stops a replay. None of them is enough alone.
08You switch it on

Connected is not allowed.

Even a perfect link should not mean the agent can use it. A comment in the app says it plainly: “A connected extension is not authority.” Chrome control has to be armed from the Chrome row of the app's menu-bar panel. The grant lives in memory only. It is never saved, and a disconnect or a relaunch sets it back to off.

Menu-bar panel · Chrome row, four states
ChromeNot connectedInstall
ChromeAsking to connect, click to allow
ChromeConnected, click to turn on
ChromeOn for this launch
Tools handed to the agent only in the last stateRelaunch returns to off
Redrawn with the status text from the panel's source. Four rows show one row's four states, top to bottom. The dots and switches are a schematic of state, not the panel's exact styling.

Arming is also recorded. Each arm and disarm writes a security event, and if the event cannot be written the switch does not turn on. The Chrome tools are handed to a query only while the extension is connected, its pinned id is verified and the switch is on, so a model never sees tools it could only fail with, or use without you.

09What we checked

Checked, and not claimed.

3/3
Native-messaging tests pass: challenge-bound hello, a direct socket client rejected, HMAC and rotation
0
TCP ports the Chrome bridge listens on since 31 August 2026
18
Chrome tools the agent can call once armed, from listing tabs to screenshots
3 s
For the host to prove it holds the current secret before the socket is closed

What this post does not claim

  • That Luminair stops prompt injection. This work controls who can drive Chrome. It does not filter what a page says to the model, and we have not measured injection resistance.
  • That the bridge works on Windows. The authenticated host is macOS-only today, and the code keeps Chrome control off there until an equivalent host exists.
  • That a process running as you, with enough control of your account, could never interfere. The checks raise the bar for impostors; they do not replace keeping your machine clean.

Sources

Let an agent use your browser, on your terms

Connect the extension, then switch Chrome on for this launch only.

Download Luminair →