{"uuid": "86bb38bc-c808-4203-9ec8-bf922b09c573", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2026-22812", "type": "seen", "source": "https://gist.github.com/cmdcolin/d99da97b752ad55b536f8d2fe63d0415", "content": "# A response to \"Stop Using OpenCode\"\n\nHuman note: I used Claude Code opus to generate this\n\nPlease 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\n\nIt generated the following:\n\n\nEvery claim below was checked against the opencode source tree at `849c2598` (2026-07-20).\nFile and line references point at that commit.\n\n---\n\n## TL;DR\n\nThe post makes one structural argument and about a dozen specific ones.\n\nThe structural argument \u2014 parsing shell command text can never be a security boundary \u2014 is\n**correct, and equally true of every coding agent that runs shell commands.** It's a\nproperty of the category. It cannot support a conclusion of the form \"stop using *this\none*.\"\n\nThe specific arguments are **mostly wrong.** Four of the post's central claims are\ncontradicted by the very files it cites. One is contradicted by a two-second grep.\n\nThe post did find one real, unfixed bug. It's in Part 3, explained rather than asserted.\n\n---\n\n# Part 1 \u2014 The headline doesn't follow\n\nYou don't need to read any code to see the problem.\n\nThe post's core technical case is that opencode's command analysis can be defeated:\n`echo '...' | bash`, base64 pipes, `python3 -c`, heredocs. Every one of those works against\nevery agent that executes shell commands. None of it is specific to opencode.\n\n**Nor is it fixable at the parse layer.** Ask what a filter would have to do with\n`python3 -c ''`. To decide whether that's safe you must decide what an\narbitrary program does. That's Rice's theorem \u2014 not a matter of trying harder, and not a\nmatter of adding `sed` and `awk` to a list. Every blocklist entry is a hint toward the next\nbypass. Everyone who has seriously attempted this \u2014 Windows AppLocker, PHP `safe_mode`,\nrestricted shells \u2014 reached the same conclusion and moved enforcement below the\ninterpreter.\n\nStated honestly, the argument is: *agents that run shell commands need OS-level isolation.*\nTrue, important, and an indictment of the entire category. Aiming it at one member is the\nsleight of hand at the center of this post.\n\n### Two threat models, routinely conflated\n\n**The model makes a mistake.** It runs `rm -rf` on the wrong path, force-pushes, drops a\ntable. Parsing genuinely helps here, because a mistaken model isn't evading you \u2014 it writes\n`git push --force` in plain text. This is what opencode's classifier is built for.\n\n**Prompt injection.** Attacker-controlled text in the context window \u2014 a README, an issue\nbody, a fetched page, MCP output \u2014 steers the agent. Parsing is worth exactly zero here,\nbecause the attacker picks the encoding. The post's bypass list is a demonstration of this\ngeneral fact, mislabeled as an opencode defect.\n\nConflating the two is what makes a UX classifier look like a broken security control.\n\n### What would actually help\n\nNone of it lives in the agent:\n\n- **Network egress control.** The underrated one. For injection, exfiltration is usually\n  the objective; an agent that can't reach the network is far less useful to an attacker\n  no matter what shell it runs.\n- **Filesystem scoping**, so \"outside the project\" is enforced rather than asked about.\n- **Process isolation** \u2014 containers, VMs, devcontainers, or OS primitives (seatbelt,\n  landlock, bubblewrap).\n- **Recoverable blast radius** \u2014 checkpoints, disposable worktrees, scoped credentials\n  instead of ambient `~/.aws`.\n\nNone of these care what the command *text* looks like. That's the tell for where the\nboundary belongs.\n\n### The fair version of this criticism\n\nopencode ships no OS-level sandbox integration. Grepping for `seatbelt`, `sandbox-exec`,\n`bubblewrap`, `landlock`, `firejail`, and `nsjail` across the tree returns nothing. The only\n`sandbox` identifiers in the source refer to git worktrees, and the Dockerfile ships the\nserver rather than isolating tool execution.\n\nThat's a real gap, with two honest asks \u2014 neither of which is \"write a better parser\":\n\n1. **Ship a first-class sandbox mode.** seatbelt on macOS, landlock or bubblewrap on Linux,\n   or a blessed devcontainer path, recommended for untrusted repositories.\n2. **Say plainly in the docs** that the permission system is accident-prevention and\n   intent-summarization, not containment. The docs don't claim otherwise today, but they\n   don't disclaim it either, and that ambiguity is what lets a post like this land.\n\nGood issue. Not \"stop using opencode,\" and not \"opencode is uniquely unsafe.\"\n\n---\n\n# Part 2 \u2014 The four factual errors\n\nThe load-bearing specifics. Each takes about thirty seconds to check.\n\n### 1. Approved permissions are NOT written to disk\n\n&gt; Claim: answering \"Always\" persists on disk and grants that command in all future sessions.\n\nThe post's scariest claim, and it's false.\n\n`permission/index.ts:24` declares `approved` inside `State`. `State` is built by\n`InstanceState.make` (line 47). `InstanceState` (`effect/instance-state.ts:29`) is a\n`ScopedCache` keyed by working directory \u2014 in-memory, torn down when the process exits.\n\n```\n$ grep -rn \"approved\" packages/opencode/src/permission/\n```\n\nSix hits. All reads and in-memory pushes. Zero writes to disk.\n\n**\"Always\" lasts until you quit.**\n\n### 2. Unrecognized commands don't bypass anything \u2014 they prompt you\n\nThe post lists commands it says defeat the filter (`echo '...' | bash`, `env git status`,\n`$(which git) status`, base64 pipes). Here is what happens when the parser doesn't\nrecognize something \u2014 `permission/index.ts:29-37`:\n\n```ts\nreturn rulesets.flat().findLast((rule) =&gt; \u2026) ?? {\n  action: \"ask\",          // \u2190 the fallback\n  permission,\n  pattern: \"*\",\n}\n```\n\n**Unrecognized means ask.** You get an interactive prompt showing the literal command text.\nNothing runs silently.\n\nWhat these strings actually do is fail to match a *user-authored deny rule*, after which\nyou get asked. \"My deny rule for `git *` doesn't catch base64-encoded git\" is a real\nlimitation and a very different sentence from \"the security filter is useless.\"\n\n### 3. The default config doesn't gate bash at all \u2014 by design\n\n`agent/agent.ts:119-136`:\n\n```ts\nconst defaults = Permission.fromConfig({\n  \"*\": \"allow\",\n  doom_loop: \"ask\",\n  external_directory: { \"*\": \"ask\", \u2026 },\n  question: \"deny\",\n  plan_enter: \"deny\",\n  plan_exit: \"deny\",\n  read: { \"*\": \"allow\", \"*.env\": \"ask\", \"*.env.*\": \"ask\", \u2026 },\n})\n```\n\nOut of the box, bash is `allow`. There is nothing to bypass, because nothing is gated.\n\nThe tree-sitter machinery exists to make two *ergonomic* features work: matching\nuser-written rules in `opencode.json`, and computing a sensible prefix for the \"Always\"\nbutton. Judging it as a failed sandbox is like judging `.gitignore` as failed access\ncontrol.\n\nNote what the defaults *do* gate: external directories, `.env` files, doom loops. A\ncoherent \"catch accidents and limit injection reach\" posture \u2014 the first threat model from\nPart 1 \u2014 and not a claim to contain an adversarial model.\n\n### 4. Two of the \"AST bypasses\" are already handled in the cited file\n\n**Redirection.** The post correctly notes that `&gt;` is a sibling of the command in the\ntree-sitter AST, then concludes `echo 21 &gt; /sys/class/gpio/export` escapes validation. But\n`tool/shell.ts:119` exists for exactly this:\n\n```ts\nfunction source(node: Node) {\n  return (node.parent?.type === \"redirected_statement\" ? node.parent.text : node.text).trim()\n}\n```\n\nThe permission pattern is the **full redirected statement**, target included.\n\n**`cd` and friends.** The post says the `CWD` set bypasses all permission checks.\n`shell.ts:28-30`:\n\n```ts\nconst CWD = new Set([\"cd\", \"chdir\", \"popd\", \"pushd\", \"push-location\", \"set-location\"])\nconst FILES = new Set([\n  ...CWD,        // \u2190 CWD is a subset of FILES\n  \"rm\", \"cp\", \"mv\", \u2026\n])\n```\n\n`cd`'s path arguments *are* resolved and *do* raise an `external_directory` prompt when they\nleave the project (`shell.ts:397-405`). The only thing skipped for a bare `cd` is adding a\nbash pattern \u2014 because `cd` executes nothing. And `cd /etc &amp;&amp; rm -rf .` still produces a\npattern for the `rm`: `commands()` at line 123 is `descendantsOfType(\"command\")`, a full\nrecursive walk.\n\n---\n\n# Part 3 \u2014 What the post gets right\n\n## The `opencode.ai` CORS exception\n\nThe post's best finding, and the one worth acting on. It needs more explanation than the\npost gives it, because \"CORS bug in a CLI tool\" sounds like a category error until you see\nthe architecture.\n\n**opencode is not just a CLI.** The TUI is a client of a local HTTP server that opencode\nstarts, and that server exposes APIs which run shell commands and read files. Browsers can\nreach `127.0.0.1`. The same-origin policy normally lets a web page *send* such a request\nbut not *read* the response \u2014 CORS is the mechanism that lifts that restriction. So the\nallowlist decides who gets to read.\n\n`packages/server/src/cors.ts:3`:\n\n```ts\nconst opencodeOrigin = /^https:\\/\\/([a-z0-9-]+\\.)*opencode\\.ai$/\n```\n\nEvery opencode.ai subdomain, permanently, with no way to opt out.\n\n**And there is no auth behind it.** `server/auth.ts:24` \u2014 `required()` is true only when\n`OPENCODE_SERVER_PASSWORD` is set, and the middleware short-circuits to a no-op when it\nisn't (`authorization.ts:103`). Unset by default. For a matching origin, CORS *is* the\nentire access control story.\n\n**Calibration.** This is a chain, not a one-shot. An attacker needs XSS on some opencode.ai\nsubdomain, and has to locate the port, which is ephemeral (`network.ts`, `port: default 0`).\nBrowser-based localhost port scanning is well-known and workable, but it's a speed bump the\npost doesn't mention. The share feature \u2014 which renders session content, including model\noutput and file contents, on that domain \u2014 is the surface to worry about.\n\nReal bug, worth fixing, not a drive-by RCE. The fix is cheap: make the exception opt-in, or\nscope it to a single known origin instead of a subdomain wildcard.\n\n## Everything else the post is right about\n\n**\"Always\" over-grants.** `python3` isn't in the `ARITY` table (`permission/arity.ts`), so\n`prefix()` falls back to `tokens.slice(0, 1)` \u2192 `python3 *`. Approving\n`python3 -c 'print(1)'` does grant all `python3` \u2014 for the session (Part 2 \u00a71), not forever.\n\n**Rejecting one prompt kills its siblings.** `permission/index.ts:126-135` cascades `reject`\nto every pending request in the session. This explains the subagent behavior the post\ncomplains about.\n\n**No persistent \"Never.\"** Replies are `once | always | reject`. Making a denial stick means\nhand-editing `opencode.json`.\n\n**Date in the system prompt.** `session/system.ts:74` \u2014 one cache miss per session per\nmidnight crossing.\n\n**No OS-level sandboxing.** Part 1. The correct version of the post's thesis.\n\n---\n---\n\n# Part 4 \u2014 Detailed responses\n\n## Security claims\n\n### models.dev \"downloads the default model URL\"\n\nThree separate errors:\n\n1. **The catalog ships in the binary.** `script/build.ts:195` bakes it in as\n   `OPENCODE_MODELS_DEV`; `core/src/models-dev.ts:192` loads that snapshot.\n2. **It's overridable and disableable.** `OPENCODE_MODELS_URL` (`models-dev.ts:154`) and\n   `OPENCODE_DISABLE_MODELS_FETCH` (line 217).\n3. **Your config always wins.** At the exact line the post cites in `provider.ts`:\n\n   ```ts\n   let url = typeof options[\"baseURL\"] === \"string\" &amp;&amp; options[\"baseURL\"] !== \"\"\n     ? options[\"baseURL\"]\n     : model.api.url\n   ```\n\nSeparately: models.dev is a *metadata* catalog \u2014 pricing, context limits, model IDs.\n\"Where opencode learns which server to talk to\" overstates what it is.\n\n### CVE-2026-22812 and the exposed HTTP server\n\nReal history; the description no longer matches the code. `cli/network.ts:8-18`:\n\n```ts\nport:     { default: 0 },            // ephemeral, not \"well-known\"\nhostname: { default: \"127.0.0.1\" },  // loopback\ncors:     { default: [] },           // no user-supplied origins\n```\n\n\"Well-known default port\" is stale. The `opencode.ai` exception (Part 3) is the part that\nstill stands \u2014 and it's strong enough to argue on its own rather than bundled with fixed\nissues, which is the choice that undermines it.\n\n### Self-upgrade / curlbash\n\n`cli/upgrade.ts:10` gates on `config.autoupdate !== false` **and**\n`!OPENCODE_DISABLE_AUTOUPDATE`. Line 30 bails to notify-only for anything that isn't a\n**patch** bump:\n\n```ts\nif (config.autoupdate === \"notify\" || kind !== \"patch\") { /* notify, return */ }\n```\n\nAuto-applied updates are patch-level only, via the install method you already chose, with\ntwo independent off switches.\n\n### \"Remote-first architecture\"\n\nLocal providers are first-class and documented: `docs/providers.mdx:1422` (LM Studio),\n`:1594` (Ollama, with upstream integration docs). A cloud default is a product decision, not\nan architectural commitment to remote inference.\n\n### The `FILES` list\n\nThe post describes `FILES` as \"commands assumed not to access files.\" It's the inverse \u2014\n`FILES` (`shell.ts:29`) is the allowlist of commands whose arguments get *path-resolved* for\nthe `external_directory` check. Commands outside it aren't waved through; they don't\ncontribute directory prompts, and still contribute bash permission patterns.\n\nThat `sed`, `awk`, and `tee` are missing is a fair catch and a good PR \u2014 with the Part 1\ncaveat that completing the list doesn't make it a boundary.\n\n## Performance and cache claims\n\n### `PRUNE_PROTECT = 40_000`\n\nThe post quotes one of three constants governing pruning. `compaction.ts:28-31`:\n\n```ts\nexport const PRUNE_MINIMUM = 20_000\nexport const PRUNE_PROTECT = 40_000\nconst PRUNE_PROTECTED_TOOLS = [\"skill\"]\n```\n\n`PRUNE_MINIMUM` is the one that matters for the cache argument. `compaction.ts:278`:\n\n```ts\nif (pruned &gt; PRUNE_MINIMUM) { /* only now do we actually prune */ }\n```\n\nPruning is a **no-op unless it would reclaim more than 20k tokens** \u2014 precisely the\nanti-thrash mechanism the post says is absent. You never pay a cache invalidation to reclaim\n3k. Two more protections in the same loop: `compaction.ts:258` (`if (turns &lt; 2) continue`)\nskips the entire most-recent turn, and `config.ts:582` (`OPENCODE_DISABLE_PRUNE`) turns it\noff.\n\nThe surviving critique \u2014 40k is fixed rather than a fraction of the model's context window \u2014\nis a reasonable issue to file.\n\n### \"Re-reads AGENTS.md on every SSE turn\"\n\nReal, and understated. `instruction.system()` is called at `session/prompt.ts:1260`, inside\nthe **step** loop \u2014 every model request, including every tool-call round trip. `read()`\n(`instruction.ts:91`) is a plain `fs.readFileString` with no mtime check and no memoization.\n\nThe conclusion doesn't follow. **Re-reading a file does not invalidate a prompt cache;\nchanged content does.** If AGENTS.md hasn't been edited, `read()` returns the same string,\nthe system prompt is byte-identical, and the cache hits as before. The cost is one small\ndisk read per step, issued 8-way concurrent (`instruction.ts:162`).\n\nWhat it buys is a real feature: **edit AGENTS.md mid-session and the next tool call picks it\nup** \u2014 no restart, no reload. For a file whose whole purpose is steering the agent, noticing\nedits immediately is the behavior you want, and this is the most eager option available.\n\nA deliberate trade with negligible cost and an obvious benefit, filed as a performance\ndefect. The clearest instance of the post's pattern: identify a real mechanism, then assert\na consequence that doesn't follow from it.\n\n### Current date in the system prompt\n\n`session/system.ts:74` \u2014 accurate. Every major harness does this, because models reason\nbadly about \"now\" without it.\n\n### TUI complaints\n\nRAM usage, shift-enter, markdown re-render, autoscroll versus text selection. Subjective or\nenvironment-dependent; not reproduced here. They may be entirely correct \u2014 they're also\nordinary bug reports rather than evidence the project is unsound, and they're the claims\nbest filed as issues.\n\n## Prompt claims\n\n### Verbosity, `beast.txt`, \"ABSOLUTELY NO COMMENTS\"\n\nFair as taste. Reasonable people disagree about prompt length, and every harness has\nembarrassing lines in its prompts. Not evidence of anything structural.\n\n### The WebFetch instruction\n\n&gt; \"NEVER generate or guess URLs \u2026 unless you are confident that the URLs are for helping the\n&gt; user with programming.\"\n\nCalled contradictory. It's a carve-out: don't invent URLs generally, except where guessing a\ndocs URL is the obviously useful behavior. Clumsily worded, yes. Encouraging URL guessing,\nno.\n\n### Plan mode writing to `.opencode/plans`\n\nAccurate, and \"claims it can't write anywhere, actually writes here\" is fair criticism of the\nprompt's wording. The default ruleset does deny `plan_enter` and `plan_exit`\n(`agent.ts:127-128`), so mode transitions are themselves gated.\n\n---\n\n## Method note\n\nWhere the post cites a file, I read that file. Where it cites a constant, I read the\nsurrounding function. Four of its central claims are contradicted by the code it points at;\none by a grep.\n\nThe frustrating part is that the argument didn't need any of them. \"Shell-command\nclassifiers aren't sandboxes, agents should ship OS isolation, and here's a live CORS hole\non a server with no auth\" is a strong post \u2014 and one that applies to the whole category\nrather than a single project. Nine findings that don't survive opening the file don't\nstrengthen it. They give everyone a reason to stop reading before the good part.", "creation_timestamp": "2026-07-20T20:08:53.650595Z"}