A coding agent that can run bash
on your laptop is a remote shell on localhost. It has that threat model whether or not anyone designed for it β browser drive-bys, DNS rebinding, curl | bash
, the twenty years of localhost-daemon mistakes the web already made and wrote down.
Last week wren.wtf published a teardown of opencode: Stop using opencode. Read it in full. The headline was a real CVE β a default HTTP server with permissive CORS and an endpoint that ran arbitrary shell commands, so any website you visited could take the machine.
I maintain agentproto, an open runtime that does the same dangerous thing on purpose: a local daemon that lets agents run commands, read files, and open tunnels on the user's machine. So I read the teardown as a checklist and pointed it at my own code. Below is what I found, what I changed, and β the part that transfers β the methodology, so you can run it on whatever agent tool you use.
"Textual command filtering is thoughts and prayers." β the teardown
That line is the whole game. Most agent tools try to make bash
safe by parsing the command string and blocking the bad ones. It doesn't work, and the article lists why: pipe to bash
, base64-decode, env git
, a heredoc, python3 -c
, an absolute path. Every one walks past a string filter.
The fix is not a better regex. It's two boundaries a string never touches:
Everything that follows is one of those two.
You can answer them in five minutes:
127.0.0.1
, or 0.0.0.0
?fetch()
that port and read the response? Open devtools on any page and try.If you don't like your answers, you're where I was.
| Threat | The mistake | The fix |
|---|---|---|
| Browser drive-by RCE | loopback-bypassed auth + reflected CORS on a command endpoint | |
Origin gate, no bypass |
||
| Cross-origin read exfil | transcripts + a live event stream readable by any page | same gate on read routes |
| DNS rebinding | ||
Host header never checked |
||
reject non-loopback Host on the loopback path |
||
| Supply chain | `curl \ | bash` with no checksum |
| Interpreter escape | allowlisting bash /node = arbitrary read |
|
| opt-in OS sandbox | ||
| Phoning home | remote model by default | loopback bind, user-chosen model, gated tunnel |
The browser drive-by was the sharpest. The endpoint that runs commands was gated by an auth check with a loopback bypass β there to let local clients through without a token. But a browser's fetch()
to 127.0.0.1
is loopback. With reflected-origin CORS, any page could drive it.
The signal that separates a real local client from a drive-by is one header the page cannot forge:
// A browser always sends Origin on a cross-origin request.
// Native clients (the CLI, curl, an editor) send none.
if (origin && !allowlisted(origin)) return reject(403)
A page at evil.com
sends Origin: https://evil.com
, which isn't allowlisted, and gets a 403 β from loopback, in the default config. Native clients send no Origin
and keep working untouched. One header closed the class.
Reading is exfiltration too. The same daemon served conversation transcripts and a live event stream over GET
β readable cross-origin, no shell required. The Origin gate went on the read routes as well. If a route leaks state, gate it like it writes state.
DNS rebinding is the follow-up: a page rebinds evil.com
to 127.0.0.1
, and now Origin
is only half the story. So the loopback path also validates the Host
header β a rebinding request still carries its own hostname, and anything that isn't a loopback Host
is refused.
agentproto never parsed command strings. It spawns with shell: false
and a verbatim argv
, against a default-deny allowlist of binaries, not strings. That structurally kills every bypass in the teardown β there is no shell to inject into.
The residual is honest: if you allowlist an interpreter β bash
, node
, python3
β you have allowlisted arbitrary code, and a working-directory anchor won't stop it reading ~/.ssh/id_rsa
. The article's own recommendation is the right one: confine at the OS, not the string. So command execution gained an opt-in sandbox β macOS Seatbelt, Linux bubblewrap β that bounds what the process can read and write, and in strict mode whether it has a network at all.
This is the part I hold hardest. A sandbox you haven't watched deny something is a comment, not a control.
So before writing a line of the sandbox, I made it deny something in a real process:
cat ~/.ssh/id_rsa
returns Operation not permitted
.~/.ssh
is never mounted, so the read is ENOENT
; with --unshare-net
, a socket connect returns ENETUNREACH
.Only then did the code get written β with the flags I had watched work β and a test that runs the real sandbox where it exists. The confinement ships with evidence, not a hope.
The teardown's other thread: opencode connects to a remote model by default, pulls the default model's URL from a third party, and starts reading your files with a shell already open.
The posture I hold agentproto to is the opposite, and it's boring on purpose:
127.0.0.1
, not 0.0.0.0
.Local-first is not a feature you add later. It's a default you refuse to trade away.
Origin
, and can't forge a loopback Host
. Use that.Eight pull requests, all public. Every finding from the teardown is closed, and both sandbox backends were validated in real processes before they shipped:
/mcp
against the browser drive-bycurl | bash
installers in non-interactive contextsHost
guardThe one thing the runtime already did right β argv
and a binary allowlist instead of string filtering β is the reason the worst class never applied.
Read the original teardown. Then run the four questions against whatever agent runs commands on your machine. You'll find something.