Skip to content

Instantly share code, notes, and snippets.

@cmdcolin
Last active July 20, 2026 21:02
Show Gist options
  • Select an option

  • Save cmdcolin/d99da97b752ad55b536f8d2fe63d0415 to your computer and use it in GitHub Desktop.

Select an option

Save cmdcolin/d99da97b752ad55b536f8d2fe63d0415 to your computer and use it in GitHub Desktop.

Intro

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:

A response to "Stop Using OpenCode"

Checked against opencode at 849c2598 (2026-07-20). Every reference links to the exact lines.

Summary

  • The headline finding — shell-command filtering can be tricked — is true, and is the documented threat model. SECURITY.md says 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.ai subdomain.
  • That one deserves a report, not a blog post — and SECURITY.md bans 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.

Part 1: the headline is the threat model

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.

Two threat models behind one prompt

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.

What would help

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."

Part 2: three factual errors

1. Approvals are not written to disk

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.

2. The default config doesn't gate bash, by design

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:

  1. The parsed pattern is the echo … | bash text, not git clean -fdx.
  2. Your deny doesn't match it.
  3. "*": "allow" does — fromConfig expands it to {permission: "*", pattern: "*", action: "allow"} — so it's the only match.
  4. ask skips anything resolving to allow. 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.

3. Two "AST bypasses" are handled in the cited file

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 rmcommands() walks the tree recursively.

Part 3: what the post gets right

The opencode.ai CORS exception

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.

Who this affects

Not most users. Plain opencode opens no port (tui.ts:234):

const external = hasArg("--port") || hasArg("--hostname") || network.mdns === true

When 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.

Report it, don't blog it

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.

Everything else the post is right about

Ordinary issues, filable one at a time:

  • "Always" over-grants, worse than the post says. python3 isn't in ARITY, so prefix() falls back to the first token (arity.ts:7-8) — approving python3 -c 'print(1)' grants all python3 for the life of the process. A table entry wouldn't help: python has arity 2 (arity.ts:135), yielding python -c *, still arbitrary execution. For interpreters no prefix length separates safe from unsafe.
  • Rejecting one prompt kills its siblingsindex.ts:129-138 cascades reject to every pending request in the session. That's the subagent behavior.
  • No persistent "Never." Replies are once | always | reject; a lasting denial means hand-editing opencode.json.
  • The .env docs are wrong, which neither the post nor I caught first time. permissions.mdx:174-186 says .env files "are denied by default" and shows "*.env": "deny". The code ships "ask" (agent.ts:130-135). Reading .env is 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.

Part 4: the post's own sections

Prompt cache misses

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 at prompt.ts:1260 inside 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.

Pruning and compaction

Covered above. The design is defensible. The post quotes one of three constants (compaction.ts:28-31) and misses the one that matters.

System prompts, permission prompts, agent interaction, TUI, documentation

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.

Remote-first and YOLO internet access

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.

Bash and file permissions

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.

Curlbash

upgrade.ts:10 gates on config.autoupdate !== false and !OPENCODE_DISABLE_AUTOUPDATE; line 30 bails to notify-only for anything but a patch bump.

It's full of RCEs

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.

Just use Docker

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.

Postscript: local LLMs

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.

Method

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment