I used Claude Code Opus 4.8 to generate a response to https://wren.wtf/shower-thoughts/stop-using-opencode/. My prompt: "Please read https://wren.wtf/shower-thoughts/stop-using-opencode/ it is a recent blog post trying to critique the opencode codebase. please try to debunk the post and show misunderstandings. you can cross reference ~/src/opencode source code if you want" and then a couple follow up things to clarify the cors issue, etc.
Note that I do not have a horse in the race, I do not use opencode.
This is v9. of this gist after cross checking it several times. Here is the response:
Checked against opencode at 849c2598 (2026-07-20). Every reference links to the
exact lines.
- The headline finding — shell-command filtering can be tricked — is true, and is the
documented threat model.
SECURITY.mdsays opencode does not sandbox the agent, calls the permission system "a UX feature," and puts sandbox escapes out of scope. - It's true of every agent that runs shell commands, and better filtering can't fix it. Mathematical, not effort.
- Three of the post's claims are contradicted by the files it cites.
- One finding is real and unfixed: an over-broad CORS exception. It needs an open port and
an XSS on an
opencode.aisubdomain. - That one deserves a report, not a blog post — and
SECURITY.mdbans AI-generated security reports on pain of a ban. - The performance half is sized to a setup the post only mentions in the postscript: a local model on an M4 Max, where a cache miss costs ~10 minutes of GPU instead of cents. Real complaints, but the severity doesn't carry over to anyone else.
opencode parses each shell command to decide whether to run it or ask first. You write
rules like "never run git push". The post shows the parsing can be fooled:
echo 'git clean' | bash, base64, heredocs.
It can't be fixed. Deciding whether python3 -c '<any program>' is safe means deciding
what an arbitrary program does before running it. Rice's theorem: impossible, not hard.
Every blocklist entry also teaches an attacker what to avoid next. AppLocker, PHP's
safe_mode, and restricted shells all ended up enforcing below the interpreter, where the
command text stops mattering.
The project says so already. From SECURITY.md, under No Sandbox:
OpenCode does not sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking … it is not designed to provide security isolation.
If you need true isolation, run OpenCode inside a Docker container or VM.
Sandbox escapes are then listed out of scope (SECURITY.md:30). The post's
central move is proving the permission system isn't a security boundary. That's a published
non-goal, in the file you'd check first.
A model making a mistake isn't hiding. It writes git push --force in plain text and
the parser catches it every time.
An attacker writing the command, via prompt injection: the agent reads repo files,
issues, fetched pages, and tool output, and can't reliably tell data from instructions.
Now the person choosing the wording is the one evading you, and they'll pick
base64 -d | sh precisely because your parser doesn't read it.
One prompt serves both, so people generalize from the case they see daily. The post's
argument runs: the prompt appears for rm -rf /, so it's a security control; it doesn't
appear for the base64, so the control is broken. Both observations are right; the first
step isn't. It was never a control.
Nothing in the agent: network egress control, filesystem scoping, process isolation (containers, VMs, seatbelt, landlock, bubblewrap), recoverable damage (checkpoints, disposable worktrees, scoped credentials). None care what the command text looks like.
opencode ships none of it — grepping seatbelt, sandbox-exec, bubblewrap, landlock,
firejail, nsjail returns nothing. (packages/codemode has a sandbox, but it's an
in-process JS boundary for tool calls and the shell tool doesn't use it.) So: ship a
sandbox mode, and put the disclaimer where people writing deny rules will see it. Neither
is "write a better parser."
The post claims "Always" persists on disk. False. permission/index.ts:25
declares approved inside State, built by InstanceState.make
(instance-state.ts:26-45), wrapping an in-memory ScopedCache torn down with
the process. grep -rn "approved" packages/opencode/src/permission/ returns six hits:
reads and in-memory pushes, zero writes.
Half a point to the post: the cache is keyed by directory on the instance, not the
session, so in a long-lived process (opencode serve, desktop, a TUI open all day) a grant
does carry into later sessions.
From agent/agent.ts:119-136:
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: { "*": "ask", … },
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
read: { "*": "allow", "*.env": "ask", "*.env.*": "ask", … },
})The first line is the story: "*": "allow". Every shell command runs, no prompt. There is
no gate to bypass. The parsing matches rules you write and computes a prefix for the
"Always" button. What the defaults gate — external directories, .env files, runaway
loops — is a posture against accidents.
"By design" isn't my inference. The docs say it: "OpenCode starts from permissive defaults.
Most permissions default to allow" (permissions.mdx:168-174). Gating is
opt-in, which follows from SECURITY.md treating the prompt as awareness rather than
containment — and --auto exists for people who want even less.
This refutes the post's claim without defending the posture, and those are different things. "opencode runs arbitrary shell commands with no prompt out of the box" is the strongest criticism available in this area, it's true, and the post buries it under bypass demos for a gate that isn't there. Whether permissive-by-default is right is a product argument — it's how the tool stays usable, and per part 1 a prompt was never going to stop an attacker anyway — but it's the argument worth having.
The question the post skips: what if you did write a deny rule?
Say your config has bash: {"git clean *": "deny"}. Your rules append after the defaults,
and evaluate takes the last matching rule — which is why your deny normally
beats the default allow. The agent runs echo 'git clean -fdx' | bash:
- The parsed pattern is the
echo … | bashtext, notgit clean -fdx. - Your deny doesn't match it.
"*": "allow"does —fromConfigexpands it to{permission: "*", pattern: "*", action: "allow"}— so it's the only match.askskips anything resolving toallow. No prompt. The command runs.
evaluate does have an ask fallback for commands matching no rule
(index.ts:28-37), and someone defending opencode could point at it as proof
nothing runs silently. It isn't: with the blanket allow present, everything matches
something, so the fallback never fires.
The post is right that the command runs unprompted. The diagnosis is wrong. This isn't
a filter defeated, it's a permissive default that never gated bash. Dropping the blanket
allow would turn the silent run into a prompt — a real improvement for deny-rule users, and
still not a boundary, since the next obfuscation just produces a prompt you approve because
it looks like a harmless echo.
The post notes a redirect sits next to the command in the parse tree, and concludes
echo 21 > /sys/class/gpio/export escapes checking. shell.ts:119 exists for this:
function source(node: Node) {
return (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim()
}The pattern is the full statement, redirect included.
It also says the cd family bypasses all checks. shell.ts:28-30:
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const FILES = new Set([
...CWD, // ← CWD is a subset of FILES
"rm", "cp", "mv", …
])cd's paths are resolved and do prompt when they leave the project
(shell.ts:397-405). Only the bash pattern is skipped for a bare cd, which
executes nothing. cd /etc && rm -rf . still yields a pattern for the rm — commands()
walks the tree recursively.
opencode can run a local server exposing APIs that run shell commands and read files — what
the web UI and opencode serve talk to. Any page you visit can send requests to
127.0.0.1; normally the browser blocks it from reading the reply. CORS is how a server
waives that.
From cors.ts:3:
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/Hardcoded, no opt-out. The same function allows http://localhost:, http://127.0.0.1:,
oc://renderer, and tauri://localhost — all gated on a port being open at all.
No password behind it either: auth.ts:40 requires a non-empty
OPENCODE_SERVER_PASSWORD, and the middleware is the identity function when that's unset
(authorization.ts:104), which is the default (web.ts:41 warns). For
a matching origin, CORS is the entire access control story.
The rule isn't careless, which the post misses: ui.ts:9 points UI_UPSTREAM at
https://app.opencode.ai, a hosted UI the local server proxies. It has to reach your
server.
The bug is scope, and it's a real bug. One origin needs this; the regex grants every
subdomain — docs, marketing, share pages, the console, anything added later. An XSS on a
docs page normally isn't worth much; here it reaches a shell. And opencode sends you to that domain:
account.ts:18 opens console.opencode.ai at login, providers.ts:467
prints an opencode.ai/auth link, retry.ts:11 shows opencode.ai/go links
exactly when opencode is running.
Not most users. Plain opencode opens no port (tui.ts:234):
const external = hasArg("--port") || hasArg("--hostname") || network.mdns === trueWhen false the TUI uses in-process worker RPC against http://opencode.internal — no
socket. Server.listen appears once in the worker (worker.ts:56), called only
by that branch (tui.ts:240).
You're exposed with opencode serve, --port, --hostname, mDNS, or the web UI. Add
opencode acp, which neither the post nor I flagged at first: the editor integration opens
a loopback port (acp.ts:25) with no password by default, and people run it from
Zed without thinking of themselves as running a server. That's the mode I'd most want
narrowed. The desktop app listens but is handled — its sidecar passes a generated password
and cors: ["oc://renderer"] (sidecar.ts:59-66).
Even then it's a chain: an attacker needs the XSS and has to find the port, which is random
(network.ts:7-11). Browser-side localhost scanning is workable, but it's a speed
bump. I didn't look for an XSS and don't claim one exists.
Deleting the rule would break the web UI. Narrow it to app.opencode.ai, or gate access on
a token the server hands the UI.
A post telling people to stop using the tool helps nobody; a report gets the regex
narrowed. Send this through the Security Advisory form (SECURITY.md:41),
not a public issue. Two caveats:
They may close it as out of scope — "Server access when opted-in" is expected behavior and
"any functionality it provides is not a vulnerability" (SECURITY.md:23-29).
That would be the wrong call, and it doesn't make the finding less real: opting into a
local server consents to local access, not to delegating it to every current and future
opencode.ai subdomain. Unlike the sandbox question, the fix costs no functionality. Say
that in the report.
And SECURITY.md:5-7 refuses AI-generated security reports, penalty a project
ban. Whatever you used to find this, the report must be your own words and verification,
short enough to confirm in a minute. Read the code yourself first. Fair ask from people
drowning in generated slop.
Ordinary issues, filable one at a time:
- "Always" over-grants, worse than the post says.
python3isn't inARITY, soprefix()falls back to the first token (arity.ts:7-8) — approvingpython3 -c 'print(1)'grants allpython3for the life of the process. A table entry wouldn't help:pythonhas arity 2 (arity.ts:135), yieldingpython -c *, still arbitrary execution. For interpreters no prefix length separates safe from unsafe. - Rejecting one prompt kills its siblings —
index.ts:129-138cascadesrejectto every pending request in the session. That's the subagent behavior. - No persistent "Never." Replies are
once | always | reject; a lasting denial means hand-editingopencode.json. - The
.envdocs are wrong, which neither the post nor I caught first time.permissions.mdx:174-186says.envfiles "are denied by default" and shows"*.env": "deny". The code ships"ask"(agent.ts:130-135). Reading.envis a prompt you can approve, not a block. Anyone relying on the documented behavior has a weaker default than they think — the smallest fix in this document, and the one most likely to bite someone. - No OS-level sandboxing, per part 1 — though the project says so itself.
The reject cascade and the missing "Never" are the two I'd file first: pure UX defects.
A prompt cache saves the server from re-reading the conversation every time. It stores what it already computed, matches as much of the new request as it can, and only computes the rest. Change one byte near the start and it has to redo everything after it.
On a hosted API a cache miss costs cents and a few seconds. The author runs Qwen3.6-27B on an M4 Max, where redoing the whole thing takes about ten minutes of full GPU. Same bug, very different day, and the post doesn't mention the difference until the postscript.
The gap comes from prefill, the pass over the prompt before any output appears. Generation emits one token at a time and is limited by memory bandwidth. Prefill processes every token at once, which is limited by raw compute, and compute is where consumer hardware trails a datacenter GPU by the widest margin. A cache miss means redoing prefill over the whole conversation, not just the new message.
The cost scales with context length: prefill runs about 2 × parameters × tokens FLOPs, so
27B over 100k tokens is ~5.4 PFLOPs, which at realistic M4 Max throughput lands near ten
minutes. That figure implies a very large context. The same miss in a 20k-token session
costs closer to two minutes, and on a hosted API a few seconds. Worth keeping in mind when
reading severity claims that don't name a context size.
The four claimed causes:
- AGENTS.md re-read. Real, and worse than stated —
instruction.system()runs atprompt.ts:1260inside the step loop, so once per model request, not per turn. But re-reading a file doesn't break the cache. Changed content does. If you haven't edited it, the text is identical and the cache still hits, so the cost is one disk read. It also buys something: edit AGENTS.md mid-session and the next call sees it. Wrong diagnosis. - Pruning on every agent→user transition. It doesn't. Nothing is pruned unless doing so
frees more than 20k tokens (
compaction.ts:278), which is exactly the don't-thrash-the-cache check the post says is missing. Line 261 also leaves the most recent turn alone. - Current date at midnight. True (
system.ts:74). One miss per session, once a day. Every harness does this, because models handle "now" badly without it. Worth being annoyed about; thin support for abandoning the tool. - Interruption wiping the cache. I couldn't verify this. Interrupting keeps the partial
reply and marks it aborted (
prompt.ts:1203-1211), so the prefix should survive. My guess is that cancelled tool calls leave results missing and fixing that up changes the end of the prompt, but I didn't test it. Leaving it open.
One wrong diagnosis, one that overstates how often it happens, one true but small, one
unresolved. And the fix the author needs already ships: OPENCODE_DISABLE_PRUNE
(config.ts:582) turns off the expensive one. The fair complaint is that
PRUNE_PROTECT's 40k is a fixed number rather than a share of the context window.
One thing to say plainly: everything here costs time and money, never correctness. The post calls these annoyances, then lets them share a headline with remote code execution.
Covered above. The design is defensible. The post quotes one of three constants
(compaction.ts:28-31) and misses the one that matters.
Prompt verbosity and beast.txt are fair as taste. Every harness has embarrassing lines in
its prompts. The "contradictory" WebFetch instruction is a carve-out — don't invent URLs,
except where guessing a docs URL helps — clumsy, not encouragement. Plan mode writing to
.opencode/plans is accurate, and the defaults do deny plan_enter and plan_exit
(agent.ts:127-128).
On permission prompts and agent interaction the post is right. Those are in part 3: the reject cascade, the missing "Never", and "Always" over-granting.
The TUI complaints (RAM, shift-enter, re-render, autoscroll) are subjective or depend on the
setup, and I didn't reproduce them. On documentation the post is right without noticing why:
see the .env mismatch in part 3.
LM Studio and Ollama have copy-paste config (providers.mdx, lines 1418 and 1589),
so a cloud default is a product decision, not an architecture. This one also reads
differently once you know the author has gone local on purpose. For them a cloud default is
friction on every install. That's a fair thing to dislike, not a design failure.
On models.dev, three errors. The catalog ships in the binary (build.ts:195 bakes
it in, models-dev.ts:193 reads it back); OPENCODE_MODELS_URL and
OPENCODE_DISABLE_MODELS_FETCH override and disable it (flag.ts:29,45); and your
config wins at the line the post cites (provider.ts:1695). It's a metadata
catalog anyway: pricing, limits, IDs.
Part 2. The bypasses work. The diagnosis doesn't, because there was no gate to defeat.
The post has the FILES list backwards. It's the set whose arguments get checked as paths.
Commands outside it aren't waved through, they just don't add directory prompts. Missing
sed, awk, and tee is a fair catch and a good PR, and completing it still doesn't make
a boundary.
upgrade.ts:10 gates on config.autoupdate !== false and
!OPENCODE_DISABLE_AUTOUPDATE; line 30 bails to notify-only for anything but a patch bump.
CVE-2026-22812 is real history with a stale description. Per network.ts, port
defaults to 0 (random), hostname to 127.0.0.1, cors to []. The part 3 CORS
exception is the one that still stands, and it's stronger on its own than bundled with fixed
issues.
We mostly agree here, which the framing hides.
The objections to Docker are sound. The daemon runs as root, and it does punch holes in
ufw by writing its own iptables rules. Both are good reasons to use something else. The
post then recommends Landlock, Seatbelt, and Restricted Tokens, which is what part 1
recommends. I'd add rootless podman and VMs, and the daemon objection doesn't apply to
either.
The overreach is calling it "passing the buck." Isolation has to live below the agent, not
because anyone is dodging the problem but because nothing above it can work: no in-process
filter can decide what python3 -c will do. Saying security should be an agent's top
concern and then rejecting the one mechanism that provides it leaves nothing.
The fair charge is that opencode ships none of these and tells you to bring your own container. That's a real gap, and it's in part 1.
This is where the post explains itself, and it should have come first.
The author runs Qwen3.6-27B on an M4 Max, likes that smaller models make their limits obvious, wants distance from cloud providers, and doubts LLM code generation in general. That's coherent, and it makes the rest of the post make sense. Cache misses cost ten minutes instead of cents. A cloud default is friction on every install. A permission prompt is the only safety net within reach when you've deliberately stepped outside the hosted ecosystem.
Reasonable priorities, and an unusual setup. The complaints are sized to that setup and the headline is addressed to everyone. "opencode is a poor fit for local-model workflows, and here's why" would be defensible, useful, and about a third of the same evidence.
Where the post cites a file, I read that file; where it cites a constant, I read the surrounding function. Three central claims are contradicted by the code they point at; several smaller ones describe a real mechanism and attach a consequence that doesn't follow.
Two places where reading further cost me a point are marked as such: "Always" outlives a session in a long-running process, and the permission fallback isn't the safety net it looks like under the defaults.
The argument didn't need the errors. "Shell-command classifiers aren't sandboxes, agents should ship OS isolation, and here's an over-broad CORS rule on a server with no password" is a strong post, and applies to the whole category. And it's worth more as two reports than one post: CORS to the advisory form, UX defects to the issue tracker, both in your own words. That path ends with the regex narrowed. "Stop using opencode" ends with nothing fixed.