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.

creators

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:

You POST a JSON blob with the entire conversation so

far.

You POST a JSON blob with the entire conversation so

far.

You get back a stream of SSE events which add up to the

response.

You get back a stream of SSE events which add up to the

response.

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:

Finds the longest matching cached prefix.

Finds the longest matching cached prefix.

Evaluates from end of prefix to end of the last posted message

(“prefill”).

Evaluates from end of prefix to end of the last posted message

(“prefill”).

Generates new tokens until it encounters an end-of-sequence

token.

Generates new tokens until it encounters an end-of-sequence

token.

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:

It globs your filesystem and re-reads AGENTS.md (injected in

turn-0 system prompt) on every SSE turn. If you put a quick note in

AGENTS.md to be read in the next session, you immediately force a full

re-evaluation.

It globs your filesystem and re-reads AGENTS.md (injected in

turn-0 system prompt) on every SSE turn. If you put a quick note in

AGENTS.md to be read in the next session, you immediately force a full

re-evaluation.

It prunes context from tool calls on every agent → user transition, invalidating a large part

of the prefix.

Pruning just discards tool call results more than a fixed

distance const PRUNE_PROTECT = 40_000 behind the write

head. In the best case you’re taking a 40k context miss, which is

equivalent to reading a full-length novel per two or three

turns.

Agent → user transition includes

interruption, so if you need to pull the clanker out of a rabbit hole

and re-steer it, OpenCode immediately trashes the prompt cache and makes

you wait for a response.

It prunes context from tool calls on every agent → user transition, invalidating a large part

of the prefix.

Pruning just discards tool call results more than a fixed

distance const PRUNE_PROTECT = 40_000 behind the write

head. In the best case you’re taking a 40k context miss, which is

equivalent to reading a full-length novel per two or three

turns.

Pruning just discards tool call results more than a fixed

distance const PRUNE_PROTECT = 40_000 behind the write

head. In the best case you’re taking a 40k context miss, which is

equivalent to reading a full-length novel per two or three

turns.

Agent → user transition includes

interruption, so if you need to pull the clanker out of a rabbit hole

and re-steer it, OpenCode immediately trashes the prompt cache and makes

you wait for a response.

Agent → user transition includes

interruption, so if you need to pull the clanker out of a rabbit hole

and re-steer it, OpenCode immediately trashes the prompt cache and makes

you wait for a response.

Personal favourite: it puts the current date in

the turn-0 system prompt and re-evaluates every SSE turn. If you’re

using OpenCode at midnight you get a full prompt cache miss.

I think this tries to remediate the clanker tendency to believe the

current date is the date of its training cutoff, and refuse to believe

newer things exist. It’s still funny.

Personal favourite: it puts the current date in

the turn-0 system prompt and re-evaluates every SSE turn. If you’re

using OpenCode at midnight you get a full prompt cache miss.

I think this tries to remediate the clanker tendency to believe the

current date is the date of its training cutoff, and refuse to believe

newer things exist. It’s still funny.

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:

The spec is read into context.

The spec is read into context.

The clanker goes and reads related code, very likely putting it

over the fixed 40k pruning threshold.

The clanker goes and reads related code, very likely putting it

over the fixed 40k pruning threshold.

The clanker is ready to implement, but either immediately dives

down a dumb rabbit hole, or sits in chain-of-thought dithering about

something that is actually very simple or already

well-specified.

The clanker is ready to implement, but either immediately dives

down a dumb rabbit hole, or sits in chain-of-thought dithering about

something that is actually very simple or already

well-specified.

You interrupt the clanker to re-steer it.

You interrupt the clanker to re-steer it.

The interruption causes the entire spec to be deleted from the

context window.

The interruption causes the entire spec to be deleted from the

context window.

The clanker writes code without being able to refer to the

spec.

The clanker writes code without being able to refer to the

spec.

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:

The default system prompt is incredibly verbose. Ironically most

of the word count is explaining to the LLM how to be concise.

The default system prompt is incredibly verbose. Ironically most

of the word count is explaining to the LLM how to be concise.

The default system prompt is opinionated (fine) but it has shit

opinions (not fine). It took me a while to figure out why my agent kept

saying “Use ABSOLUTELY NO COMMENTS” when dispatching

subagents.

The default system prompt is opinionated (fine) but it has shit

opinions (not fine). It took me a while to figure out why my agent kept

saying “Use ABSOLUTELY NO COMMENTS” when dispatching

subagents.

The Plan-to-Build handover is clumsy and often leaves me towards

the end of a context window by the time everything is sufficiently

elucidated. I’d rather write out notes with everything discussed, so I

can edit it and then hand off to a fresh session. To which, see next

point:

The Plan-to-Build handover is clumsy and often leaves me towards

the end of a context window by the time everything is sufficiently

elucidated. I’d rather write out notes with everything discussed, so I

can edit it and then hand off to a fresh session. To which, see next

point:

The system reminder for Plan mode tells the clanker it can’t

write to any directories, but it’s actually allowed to write to a

specific .opencode/plans directory. I have seen this fail

both ways: writing to this directory unprompted, and refusing to write

when explicitly instructed.

The system reminder for Plan mode tells the clanker it can’t

write to any directories, but it’s actually allowed to write to a

specific .opencode/plans directory. I have seen this fail

both ways: writing to this directory unprompted, and refusing to write

when explicitly instructed.

There is no way to modify the default system prompt globally; you

have to copy it into every project.

There is no way to modify the default system prompt globally; you

have to copy it into every project.

If you only override the default prompt in Build mode, switching

to Plan mode is a full prompt cache miss.

If you only override the default prompt in Build mode, switching

to Plan mode is a full prompt cache miss.

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:

[Beast

Mode](https://github.com/anomalyco/opencode/blob/c5db39f6268a36194a7fe5f833ae3197dfe250b6/packages/opencode/src/session/prompt/beast.txt)

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.

If I send a message while SSE streaming is ongoing, it gets

queued. Nice feature. However:

The semantics of when OpenCode decides to actually send the

message are a little unclear; the code suggests it’s at the end of a

tool call turn but I have also seen tool → CoT transitions without sending my queued

message.

If I subsequently interrupt because I want the clanker to

actually answer my question instead of navel gazing, the message is

removed from the “queued” state and just goes into the message log. You

interrupted to get the clanker to answer your message, but now you

can’t send the message. You have to send a second message to start

a new stream.

If I send a message while SSE streaming is ongoing, it gets

queued. Nice feature. However:

The semantics of when OpenCode decides to actually send the

message are a little unclear; the code suggests it’s at the end of a

tool call turn but I have also seen tool → CoT transitions without sending my queued

message.

The semantics of when OpenCode decides to actually send the

message are a little unclear; the code suggests it’s at the end of a

tool call turn but I have also seen tool → CoT transitions without sending my queued

message.

If I subsequently interrupt because I want the clanker to

actually answer my question instead of navel gazing, the message is

removed from the “queued” state and just goes into the message log. You

interrupted to get the clanker to answer your message, but now you

can’t send the message. You have to send a second message to start

a new stream.

If I subsequently interrupt because I want the clanker to

actually answer my question instead of navel gazing, the message is

removed from the “queued” state and just goes into the message log. You

interrupted to get the clanker to answer your message, but now you

can’t send the message. You have to send a second message to start

a new stream.

Undoing a message often fails to remove it from the message

log.

Undoing a message often fails to remove it from the message

log.

The problems continue with subagents (i.e. agents spawned by an agent

using a tool call RPC):

I can’t talk to subagents; if they go down a dumb rabbit hole I

have a choice of killing them and losing their context, or helplessly

watching them burn tokens.

I looked into this and apparently OpenCode used to have this

feature but it’s just… gone?

You can @mention a subagent from the main agent’s

chat window but this doesn’t seem to do anything useful. In particular

it doesn’t interrupt.

I can’t talk to subagents; if they go down a dumb rabbit hole I

have a choice of killing them and losing their context, or helplessly

watching them burn tokens.

I looked into this and apparently OpenCode used to have this

feature but it’s just… gone?

I looked into this and apparently OpenCode used to have this

feature but it’s just… gone?

You can @mention a subagent from the main agent’s

chat window but this doesn’t seem to do anything useful. In particular

it doesn’t interrupt.

You can @mention a subagent from the main agent’s

chat window but this doesn’t seem to do anything useful. In particular

it doesn’t interrupt.

If a subagent fails a tool call (e.g. Qwen putting tool calls in

CoT) then it’s fatal and all the context up to that point is

lost.

If a subagent fails a tool call (e.g. Qwen putting tool calls in

CoT) then it’s fatal and all the context up to that point is

lost.

The ability to reuse subagents seems nice in theory but in

practice the main use of subagents is to break tasks into smaller

context windows. Having the agent sometimes decide to reuse the same

subagent for an unrelated task defeats this.

I have never seen this be beneficial and often seen it cause

prompt cache misses (there it is again!) due to switching between agents

and subagents that both have huge contexts.

As a principle: it’s ok to have a richer interaction model from

the human side, but clankers will do every possible dumb thing, so

choices need to be minimised.

The ability to reuse subagents seems nice in theory but in

practice the main use of subagents is to break tasks into smaller

context windows. Having the agent sometimes decide to reuse the same

subagent for an unrelated task defeats this.

I have never seen this be beneficial and often seen it cause

prompt cache misses (there it is again!) due to switching between agents

and subagents that both have huge contexts.

I have never seen this be beneficial and often seen it cause

prompt cache misses (there it is again!) due to switching between agents

and subagents that both have huge contexts.

As a principle: it’s ok to have a richer interaction model from

the human side, but clankers will do every possible dumb thing, so

choices need to be minimised.

As a principle: it’s ok to have a richer interaction model from

the human side, but clankers will do every possible dumb thing, so

choices need to be minimised.

On the positive side, OpenCode’s subagent interactions led to one of

the funniest GitHub

issues I have ever had the pleasure of reading.

[GitHub

issues](https://github.com/anomalyco/opencode/issues/18100)

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:

The edit tool uses exact search-replace, requiring

unique match by default.

This is a good fit for clankers, as they can recall file contents

precisely but aren’t always sure exactly where in the file it is as line

numbers drift over multiple edits.

There is an option to do global search; every time I have seen a

clanker use this it has been an absolute shitshow that required multiple

edits to correct. Removing this option would give you exactly the Pi

edit tool design.

The edit tool uses exact search-replace, requiring

unique match by default.

This is a good fit for clankers, as they can recall file contents

precisely but aren’t always sure exactly where in the file it is as line

numbers drift over multiple edits.

This is a good fit for clankers, as they can recall file contents

precisely but aren’t always sure exactly where in the file it is as line

numbers drift over multiple edits.

There is an option to do global search; every time I have seen a

clanker use this it has been an absolute shitshow that required multiple

edits to correct. Removing this option would give you exactly the Pi

edit tool design.

There is an option to do global search; every time I have seen a

clanker use this it has been an absolute shitshow that required multiple

edits to correct. Removing this option would give you exactly the Pi

edit tool design.

The question tool in Plan mode (multi-choice) is

strictly worse than just telling the LLM to ask me questions in natural

language in the system prompt.

The question tool in Plan mode (multi-choice) is

strictly worse than just telling the LLM to ask me questions in natural

language in the system prompt.

The grep and glob tools are redundant

with bash.

They may be more ergonomic to use, but given I regularly see the

clanker just running grep or rg in

bash, I doubt it.

I suspect the reason is to allow read-only agent types like

Explore to be restricted from bash

commands.

This is essentially a self-admission that it’s impossible to

reason about side effects of bash commands without running

them. More on this later.

The grep and glob tools are redundant

with bash.

They may be more ergonomic to use, but given I regularly see the

clanker just running grep or rg in

bash, I doubt it.

They may be more ergonomic to use, but given I regularly see the

clanker just running grep or rg in

bash, I doubt it.

I suspect the reason is to allow read-only agent types like

Explore to be restricted from bash

commands.

I suspect the reason is to allow read-only agent types like

Explore to be restricted from bash

commands.

This is essentially a self-admission that it’s impossible to

reason about side effects of bash commands without running

them. More on this later.

This is essentially a self-admission that it’s impossible to

reason about side effects of bash commands without running

them. More on this later.

The todo tool is probably net-useful but the clanker

forgets to check the TODOs. Ironic… he could save others from death but

not himself.

The todo tool is probably net-useful but the clanker

forgets to check the TODOs. Ironic… he could save others from death but

not himself.

TUI

Text UIs (what the kids call programs like GNU nano) are

fashionable these days. OpenCode has one too:

It uses a gigabyte of RAM to render text.

It uses a gigabyte of RAM to render text.

It’s impossible to type a newline in the message box. Shift-enter

is supposed to do this, it’s just broken for me.

I found a pre-existing issue; the developer literally said “works on

my machine.” It’s closed.

It’s impossible to type a newline in the message box. Shift-enter

is supposed to do this, it’s just broken for me.

I found a pre-existing issue; the developer literally said “works on

my machine.” It’s closed.

Sometimes when typing a multiline message (i.e. too long for a

single line, so gets softwrapped), the message box scrolls and the

cursor continues to move, but the characters I’m typing don’t appear on

the new line.

Sometimes when typing a multiline message (i.e. too long for a

single line, so gets softwrapped), the message box scrolls and the

cursor continues to move, but the characters I’m typing don’t appear on

the new line.

Trying to select text while streaming: the view autoscrolls and

you lose the selection.

Trying to select text while streaming: the view autoscrolls and

you lose the selection.

^C closes the session instantly. This isn’t how

interactive shells are supposed to work. ^C should

interrupt a currently running command, and ^D should close

the session if a command is not currently running.

^C closes the session instantly. This isn’t how

interactive shells are supposed to work. ^C should

interrupt a currently running command, and ^D should close

the session if a command is not currently running.

Text box does not respond to normal shortcuts

(e.g. Option+right/left to go forward/back word on Mac).

Text box does not respond to normal shortcuts

(e.g. Option+right/left to go forward/back word on Mac).

Markdown re-render (or something else, I didn’t profile it) takes

multiple seconds to stream new chunks once a message or CoT is long.

Someone did a quadratic fucky-wucky.

Markdown re-render (or something else, I didn’t profile it) takes

multiple seconds to stream new chunks once a message or CoT is long.

Someone did a quadratic fucky-wucky.

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 connects to a remote model by default.

OpenCode connects to a remote model by default.

The documentation lacks a simple example of configuration for a

local model; if you mess it up, guess what, you get connected to a

remote model.

The documentation lacks a simple example of configuration for a

local model; if you mess it up, guess what, you get connected to a

remote model.

If you did successfully specify a local model, guess

what, you have to run OpenCode and interactively click through

to select it, meanwhile it’s already connected to a remote model, with a

local shell open on your machine.

If you did successfully specify a local model, guess

what, you have to run OpenCode and interactively click through

to select it, meanwhile it’s already connected to a remote model, with a

local shell open on your machine.

Bonus fun round: the URL for the default model is not a static

part of the distribution, it’s downloaded from models.dev (affiliated with OpenCode).

Source: opencode/src/provider/provider.ts line

1684.

Bonus fun round: the URL for the default model is not a static

part of the distribution, it’s downloaded from models.dev (affiliated with OpenCode).

Source: opencode/src/provider/provider.ts line

1684.

models.dev

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:

The WebFetch tool exists.

The WebFetch tool exists.

The LLM is explicitly instructed to use the WebFetch

tool in the system prompt.

The LLM is explicitly instructed to use the WebFetch

tool in the system prompt.

The language in the system prompt regarding whether or not the

clanker should “guess” the URL for the web fetch tool is bizarrely

vague.

The language in the system prompt regarding whether or not the

clanker should “guess” the URL for the web fetch tool is bizarrely

vague.

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:

I want to control the commit history.

I want to control the commit history.

I have had clankers erase an entire session’s work by running

git checkout . to revert their most recent change.

I have had clankers erase an entire session’s work by running

git checkout . to revert their most recent change.

The configuration section in opencode.json looks like

this:

Straightforward enough, right? This works by:

Parsing the bash command to AST using tree-sitter

bash or PowerShell grammars

Parsing the bash command to AST using tree-sitter

bash or PowerShell grammars

Walking the command nodes of the AST

Walking the command nodes of the AST

Matching the nodes against regexes compiled from your

opencode.json

Matching the nodes against regexes compiled from your

opencode.json

This command is denied:

This command is also denied:

However, this command is allowed:

This command is allowed:

This command is allowed:

This command is allowed:

This command is allowed:

This command is allowed:

This command is allowed:

This command is allowed:

This command is allowed:

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:

You get a prompt asking you to allow the command. If you select

Always, this permission is persisted for the

python3 prefix. Next time:

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:

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:

This does not:

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:

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:

Obviously you would select Always – that’s a harmless

command. So these are harmless too:

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:

Parsed AST:

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:

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:

CVE

Had fully permissive CORS headers.

Had fully permissive CORS headers.

Deliberately exposed a POST API for arbitrary shell

commands.

Deliberately exposed a POST API for arbitrary shell

commands.

Deliberately exposed a GET API for arbitrary file read.

Deliberately exposed a GET API for arbitrary file read.

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.

[Stale bot

closed the issue.](https://github.com/anomalyco/opencode/issues/6355)

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?

This issue

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:

I just don’t want to use Docker for development – if your

dependencies are so sprawling that you’ve lost track of how to install

them on a new machine, why do you have so many?

I just don’t want to use Docker for development – if your

dependencies are so sprawling that you’ve lost track of how to install

them on a new machine, why do you have so many?

Docker causes security holes:

It creates a god-service that runs as root.

It deliberately punches a hole in ufw

firewalls.

Docker causes security holes:

It creates a god-service that runs as root.

It creates a god-service that runs as root.

It deliberately punches a hole in ufw

firewalls.

It deliberately punches a hole in ufw

firewalls.

If everything you care about is inside the container, and a local

shell inside the container is deliberately connected to the internet,

what is being protected?

If everything you care about is inside the container, and a local

shell inside the container is deliberately connected to the internet,

what is being protected?

If the feature you are getting from Docker is “please don’t

recursively delete my root filesystem” then there are easier ways to

achieve that, like Landlock, Seatbelt, Restricted Tokens etc.

If the feature you are getting from Docker is “please don’t

recursively delete my root filesystem” then there are easier ways to

achieve that, like Landlock, Seatbelt, Restricted Tokens etc.

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:

You avoid the uncanny valley where the model appears to be

intelligent before doing something stupid; the stupidity is self-evident

and this helps calibrate your interactions.

You avoid the uncanny valley where the model appears to be

intelligent before doing something stupid; the stupidity is self-evident

and this helps calibrate your interactions.

The weight count is too low to reproduce the training set

verbatim, which nudges the calculus on whether the output should be

considered tainted. This is distinct from larger models which

can reproduce inputs verbatim, but are trained to refuse

to.

The weight count is too low to reproduce the training set

verbatim, which nudges the calculus on whether the output should be

considered tainted. This is distinct from larger models which

can reproduce inputs verbatim, but are trained to refuse

to.

You avoid supporting or relying upon cloud providers.

You avoid supporting or relying upon cloud providers.

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

wren.wtf