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.
The one idea
“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:
- What the process is allowed to do — OS-level rights.
- Who is allowed to ask — the network posture.
Everything that follows is one of those two.
Run these four questions on your own tool first
You can answer them in five minutes:
- Does it bind a TCP port — to
127.0.0.1, or0.0.0.0? - Can a random website
fetch()that port and read the response? Open devtools on any page and try. - When it says “allow bash”, what does the allow check — the command string, or the binary?
- Does it reach a remote model before you chose one?
If you don’t like your answers, you’re where I was.
What I found, by threat class
Threat The mistake The fix Browser drive-by RCE loopback-bypassed auth + reflected CORS on a command endpointOrigin 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)
Enter fullscreen mode Exit fullscreen mode
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.
The command-filter myth, and what replaces it
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.
Prove the boundary; don’t ship a guessed profile
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:
-
macOS Seatbelt — a workspace read succeeds;
cat ~/.ssh/id_rsareturnsOperation not permitted. -
Linux bubblewrap, in a container — the workspace is bound and visible;
~/.sshis never mounted, so the read isENOENT; with--unshare-net, a socket connect returnsENETUNREACH.
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.
Not calling home
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:
- The daemon binds
127.0.0.1, not0.0.0.0. - It contacts no remote model on its own. The model is whatever adapter you point it at — local or hosted, your key, your call.
- Going public is one explicit verb, it mints a bearer token, and the tunnel is gated. A passthrough tunnel the daemon can’t gate now says so, in its own result, out loud.
Local-first is not a feature you add later. It’s a default you refuse to trade away.
The methodology, in five lines
- Treat an agent runtime as a remote shell on localhost. It is one.
- Deny by default. The allowlist gates which binary; the OS gates what it can touch.
- Split “who can ask” from “what runs” — a page can’t forge
Origin, and can’t forge a loopbackHost. Use that. - Reading is exfiltration. Gate reads like you gate writes.
- Prove the boundary in a real process before you trust it.
Where it landed
Eight pull requests, all public. Every finding from the teardown is closed, and both sandbox backends were validated in real processes before they shipped:
-
#570 — gate
/mcpagainst the browser drive-by - #571 — block cross-origin reads, tighten CORS, redact tokens from logs
-
#572 — refuse unverified
curl | bashinstallers in non-interactive contexts - #573 — harden the loopback signal, flag unauthenticated tunnels
- #578 + #581 + #585 — the interpreter footgun: warn, then confine (macOS Seatbelt, Linux bubblewrap)
-
#582 — the DNS-rebinding
Hostguard
The 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.
답글 남기기