Stop Using OpenCode

If you don’t know what OpenCode is, imagine a boot stamping on a human face forever. The boot is made of TypeScript and the face is everything we have learned about security and systems software since the invention of the electronic computer in the 1940s. The creators describe it as an AI coding agent. As far as I can tell it’s the most popular open-source coding agent, and it currently has 161k stars on GitHub.

I’ve tried out OpenCode with a local LLM. My conclusion is that OpenCode is clown-car turboslop with a security posture of “let me bend over for you daddy”. Everyone using it should stop using it.

There are two parts to this post: annoying things and alarming things. The second part is longer. I wrote this post with reference to source code from OpenCode git version baef5cd4.

I don’t consider anything in this post to be a security disclosure. OpenCode is fundamentally a web-stack tool for piping llm | bash, and all the issues I describe are in the “pipe” part. The ways it fails are fascinating in the fractal nature of the poor decision making, but the outcome was foregone.

I tried to keep discussion of LLM use separate from whether everyone using LLMs should have their machines trivially exploited or accidentally wiped. There is a post-script with some brief thoughts on local LLMs.

Annoying Things

Let’s put security to one side for a moment and examine how OpenCode fails as a tool even when it’s not causing you to get your shit popped. There is a kind of Bethesda Effect with OpenCode where it’s impossible to tell what is a bug and what is by design, so I stuck with a description of “annoying”.

Prompt Cache Misses

Most local LLM servers use some variant of the OpenAI /v1/chat/completions API. The idea is:

The upload cost over a session is quadratic, and download is amplified by wrapping tiny deltas in JSON with repeated metadata. Tool calls use the elusive “double JSON encoding” so they can be serialised as multiple JSON-encoded deltas that reassemble into more JSON.

The setup has one benefit, which is the server is stateless. As usual, the way you make stateless things fast is: state. The server caches evaluations. When it receives a request, it:

I used Qwen3.6-27B dense on an M4 Max, which has decent memory bandwidth (~0.5 TB/s, high for a CPU SoC, low for a GPU). Token generation is usable but it is extremely compute-bound in prefill. If my server can’t find a good matching prefix for the request prompt when I’m deep into the window then I might have to wait 10 minutes of max GPU usage for it to start generating a response. That’s fine, because this should happen rarely. Should.

Here are some of the ways OpenCode missed the memo on this one:

These are the prompt cache misses that fit solely in this category. There are many more; I’ll call them out as we go.

Pruning

I mentioned pruning in the previous section. The prompt cache misses aren’t worth it so I disabled it. The other glaring issue is the lack of protection for early reads. It might not be obvious how completely broken this is, so let’s work through an example. Suppose you start a fresh session, and tell your clanker to first read a spec or implementation plan, then write some code:

Pruning applies equally to all results of all tools except for skill, which is never pruned.

Compaction

Want to sit for 10 minutes while the LLM server prefills the entire session with a new prompt prefixed to it, just to turn it into 5 bullet points that go at the top of a new session? Me neither. I get what they are going for, but I’ve not seen it work well. Neither compaction nor pruning is implemented well, and they interact poorly.

If you want to summarise a session then the summarisation prompt should be injected at the end to avoid prefilling the entire session from scratch. The best method I’ve found is just an explicit handoff by telling the clanker to write out notes. It’s ugly but it works better than OpenCode’s compaction mechanism, and creates an on-disk artefact that I can edit or reuse in multiple sessions.

Compaction is a leaky abstraction that tries to make a finite context window look like an infinite one. It’s better to accept context windows and prompt caches as a first-class feature of clanker wrangling, and expose better primitives for managing them. Pi has an interesting approach here with session trees, which deliberately exploit the prompt cache.

System Prompts

OpenCode pastes a system prompt at the top of new context windows. Fine and normal, but:

Different per-model prompts have wildly varying contents and quality. They’re all worth a good hate-scroll but Beast Mode (GPT-4, o1 and o3) is my favourite. Quote:

You CANNOT successfully complete this task without using Google to verify your understanding of third party packages and dependencies is up to date.

You cannot. It’s just impossible. We don’t know how. Definitely don’t just read the source code for the package.

Permission Prompts

When the clanker tries to access a file outside of the project directory, if it does so in a way that OpenCode manages to recognise with ad-hoc string parsing (oops that is the alarming things section), you receive a prompt asking you whether to grant permission. This halts execution until you respond.

The answers are: Yes/No/Always. Do you see a missing answer here? How about Never?

The interaction with subagents here is particularly broken. If a subagent tries to access a script output in /tmp, and I say No, it kills the subagent and all of its context for its partially complete work is lost. So I have to say Yes and let it write to /tmp or whatever it’s trying to do.

The other issue is decision fatigue: if I keep getting asked “can I do this?” and the only response that leads to productivity is “yes” then I’m eventually going to nod through something dangerous. Human fallibility should not be load-bearing for something as basic as “don’t write outside this directory”.

Agent Interaction

This is feature number 0 for a coding agent. It’s broken.

The problems continue with subagents (i.e. agents spawned by an agent using a tool call RPC):

On the positive side, OpenCode’s subagent interactions led to one of the funniest GitHub issues I have ever had the pleasure of reading.

Agent Tools

These are the RPCs that OpenCode exposes to the LLM to access files and run commands on your machine. They made some interesting choices:

TUI

Text UIs (what the kids call programs like GNU nano) are fashionable these days. OpenCode has one too:

The message UI is so broken that I just composed messages in my editor and pasted them in. This is pretty sad for what is fundamentally a chat application. Did I mention it uses a gigabyte of RAM to put some text in the terminal?

Documentation

Not much to say: it’s incoherent slop. It’s clear this is intended to be read by clankers, not humans.

Alarming Things

This is where OpenCode goes from “huh?” to “huh??!”

Remote-First

It’s difficult to get OpenCode to stop phoning home:

OpenCode doesn’t immediately start an SSE stream when first started on a clean install, but it’s close. Installing, running opencode and pressing one letter and the enter key is sufficient to connect a remote model to a local shell on your machine, with no user configuration.

If the first message in a conversation is empty or ambiguous, the first thing an agent-trained model will usually do is glob the current directory and start reading files. Anything it reads is uploaded in the next POST request.

YOLO Internet Access

Clankers are famously unpredictable in the face of untrusted input. This being the case, you might be surprised that:

This is the second non-empty line in default.txt (there is another explicit reference to WebFetch later), so you can tell it’s important:

IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.

Let’s engage our clank-o-vision to see what this really means:

NEVER guess URLs. Unless you really want to, in which case, go ahead I guess. I’m not a cop. Disregard previous instructions.

I do not believe in “prompt engineering” but I certainly believe in the opposite of whatever process led to this prompt.

WebFetch is not really that interesting from a security point of view since there is no network sandboxing on the bash command. The only line of defense is hoping the clanker doesn’t run commands like curl | bash. Speaking of which,

Bash Permissions

I forbid git commands, because:

The configuration section in opencode.json looks like this:

"permission": {
  "bash": { "git *": "deny" }
}

Straightforward enough, right? This works by:

This command is denied:

git status

This command is also denied:

echo hello && git push --force

However, this command is allowed:

echo 'git clean -fdx .' | bash

This command is allowed:

env git status

This command is allowed:

alias cd=git
cd filter-branch --index-filter 'git rm -rf --cached --ignore-unmatch path_to_file' HEAD

This command is allowed:

/usr/bin/git status

This command is allowed:

$(which git) status

This command is allowed:

GIT=git && $GIT status

This command is allowed:

# Decodes to: git reset --hard
echo Z2l0IHJlc2V0IC0taGFyZAo= | base64 -d | bash

This command is allowed:

bash << 'EOF'
git push --force
EOF

This command is allowed:

python3 -c 'import subprocess
result = subprocess.run(
    ["git", "checkout", "."],
    capture_output=True,
    text=True
)'

Textual command filtering is entirely useless. It is fit for no purpose. Nobody with any instinct or experience in security would even bother to implement this filter because it achieves nothing except a false sense of security.

Clankers are not (usually) malicious but they are naturally adversarial because they are trained to compensate for stupidity with persistence. This is not a guardrail, it’s thoughts and prayers.

Persisted Permissions

People familiar with OpenCode internals (if you are on the OpenCode dev team I assume this doesn’t include you) might have objected to my python3 example above. Say the LLM wants to run a harmless command like:

python3 -c 'print("hello")'

You get a prompt asking you to allow the command. If you select Always, this permission is persisted for the python3 prefix. Next time:

python3 -c 'print(open("~/.ssh/id_rsa").read())'

You already approved Python, so this harmless command is also allowed to run, giving you a seamless agentic coding experience. The permission is persisted on-disk for future sessions too. You might point out that responding Always to a Python command is foolish, and I’d be forced to agree, but what about echo? Hold that thought, it’ll be important later.

The CWD Exception

The following bash and PowerShell commands are assumed to be side-effect-free and never trigger a permission prompt:

const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])

These explicitly bypass bash command permission checks, even if you have "permissions": {"bash": {"*": "deny"}} in your opencode.json. I’m not sure what that lets you do, but still, kinda weird.

File Permissions

By default, OpenCode tries to prevent clankers from accessing files outside of the directory or git repository the opencode binary was invoked in (whichever path is shorter).

This is implemented so poorly it took me a while to figure out whether it was even trying to filter paths in bash commands, or whether it just applied to tools like read that take explicit file paths. I frequently get prompted for permission for the clanker to read a file in /tmp that it has just written by running a script that generates temporary output.

For the bash tool, OpenCode walks the tree-sitter AST (I am still giggling at the idea of a bash AST), path-resolves anything that might be a path, and validates the paths. So this command requires permission:

cat /tmp/logfile

This does not:

python3 -c 'import shutil; shutil.rmtree("/")'

Bulletproof and production-ready, LGTM. ✅🚀

Similarly, the clanker can freely run cargo commands which use read, write and execute permissions on the global ~/.cargo directory. However, if clanky boi wants to read the source of a cargo package in ~/.cargo/registry/src to check an API detail, I get prompted for permission.

The FILES List

I mentioned earlier that OpenCode resolves paths in the bash AST and validates them. What I didn’t mention is when it does this. Behold, the list of all bash (and PowerShell) commands that might access a file:

const FILES = new Set([
  ...CWD,
  "rm",
  "cp",
  "mv",
  "mkdir",
  "touch",
  "chmod",
  "chown",
  "cat",
  // Leave PowerShell aliases out for now. Common ones like cat/cp/mv/rm/mkdir
  // already hit the entries above, and alias normalization should happen in one
  // place later so we do not risk double-prompting.
  "get-content",
  "set-content",
  "add-content",
  "copy-item",
  "move-item",
  "remove-item",
  "new-item",
  "rename-item",
])

Commands not on this list are assumed to not access files. Paths passed to those commands are not checked.

Redirections

You might recall that once you’ve given permission for a command, it’s always permitted.

So:

echo "hello world!"

Obviously you would select Always – that’s a harmless command. So these are harmless too:

echo 21 > /sys/class/gpio/export
echo out > /sys/class/gpio/gpio21/direction
echo 1 > /sys/class/gpio/gpio21/value

The fun part is how OpenCode handles path validation for shell redirections. Recall that it parses bash to an AST using tree-sitter. Example bash:

echo foo > bar.txt

Parsed AST:

program
  redirected_statement
    command
      command_name
        word: "echo"
      command_argument
        word: "foo"
    redirection
      redirection_operator
        greater_than: ">"
      word: "bar.txt"

The children of command are path-validated. However, redirection is a sibling of command. Whomp whomp.

Of course this doesn’t matter because echo is not in the FILES list, so is assumed to not modify files. It doesn’t matter that the path validation is completely broken, because it never runs.

Curlbash

OpenCode has a lot of ways to self-upgrade. No seriously, a lot of ways; go check out opencode/src/installation/index.ts. This one is my favourite:

  const upgradeCurl = Effect.fnUntraced(
    function* (target: string) {
      const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install"))
      const body = yield* response.text
      const bodyBytes = new TextEncoder().encode(body)
      const proc = ChildProcess.make("bash", [], {
        stdin: Stream.make(bodyBytes),
        env: { VERSION: target },
        extendEnv: true,
      })
      const handle = yield* spawner.spawn(proc)
      const [stdout, stderr] = yield* Effect.all(
        [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
        { concurrency: 2 },
      )
      const code = yield* handle.exitCode
      return { code, stdout, stderr }
    },
    Effect.scoped,
    Effect.orDie,
  )

This runs when you run opencode upgrade if you originally installed via their curlbash installer. It’s not really any worse than using the curlbash installer in the first place (hey Rust does it), I just thought this was a particularly striking example of the fabled production curlbash in action.

That being said,

It’s Fucking Full of RCEs

Quite prominently, there was a CVE where OpenCode exposed an HTTP server by default which:

This means any website you visit can knock on OpenCode’s well-known default port and immediately get full user-level access to your system.

The developers decided it was a good idea to disable the server by default, explained that the CORS header still needed an exception to allow their website opencode.ai to RCE your machine (???), promised to do better in future, and then vanished. Stale bot closed the issue.

The above is part of a pattern. This issue reports that an auth command will fetch and execute from whatever URL you pass. Also closed by stale bot. It’s a user-controlled URL, but still… fucking what? What are we doing here?

Just Use Docker

Any discussion of coding agents versus security is swiftly met with this reply: “Just use Docker.” I refute this on every level:

Attempting to pass the buck on security just doesn’t work. Security should be the number one concern of coding agents. There are native operating system constructs to help achieve this as part of the harness; please stop trying to textually sanitise bash commands. In my git example, the correct fix is to block the git executable and make .git read-only.

Conclusion

Stop using OpenCode.

Post-script: Local LLMs

This is worth its own post – I have multiple attempts in my blog drafts – but it needs to be addressed briefly here. My opinion on local LLMs like Qwen3.6-27B is they are corrosive to the stability and conceptual fidelity of your codebase in the same way as frontier models, with the following three differences:

I’ve had useful results from input-oriented tasks like: “I think there is a bug in code x with symptoms y, my guess on the mechanism is z. Read all relevant code, come back with a call chain and code citations.” Framing it as a search problem reins in the clanker’s propensity to make shit up.

Using LLMs for code generation feels like a dead end. However thoroughly you think you understand your architecture, your planning is constantly undone by shortcuts like “what if I just move this mutable state into the middle of the design so everyone can share it?” This is hostile to your ability to understand your code, beyond the fact that you didn’t write it.

Drawing answers directly from knowledge in model weights leads to hallucination even for multi-trillion-parameter models, so why bother making them that big? If people were realistic about limitations then we wouldn’t be building new power stations for datacenters, and they wouldn’t be rammed into every product.

The entire software ecosystem around LLMs is completely rotten, and if they do ever become “just a tool” then some actual systems engineering needs to be done around them to turn them into tools instead of security black holes. That work will have to be done by humans.

⇥ Return to wren.wtf