停止使用 OpenCode
原文:Stop Using OpenCode 作者:wren 日期:2025 年 7 月
如果你不知道 OpenCode 是什么——想象一只靴子永远踩在人脸上。靴子是 TypeScript 做的,人脸是自 20 世纪 40 年代电子计算机发明以来我们在安全与系统软件领域学到的一切。它的创造者称其为 AI 编程智能体。据我所知,它是最流行的开源编程智能体,目前在 GitHub 上拥有 161k 星标。
我用本地 LLM 试了 OpenCode。我的结论是:OpenCode 是一辆小丑车的涡轮垃圾,安全姿态是”爸爸我好啦”。所有在用的人都应该停用。
这篇文章分两部分:恼人的事和令人警觉的事。第二部分更长。本文参考 OpenCode git 版本 baef5cd4 的源代码撰写。
我不认为文中任何内容属于安全披露。OpenCode 本质是一个用于管道 llm | bash 的 Web 技术栈工具,我描述的所有问题都在”管道”部分。它失败的方式在层层嵌套的糟糕决策中显得格外迷人,但结局早已注定。
我尽量将 LLM 使用的讨论与”是否使用 LLM 的人应该让自己的机器被轻易攻击或意外清空”分开。文末有关于本地 LLM 的简短附言。
恼人的事
先把安全问题放一边,看看 OpenCode 即使在不会让你翻车的情况下,作为工具是如何失败的。OpenCode 有种贝塞斯达效应——你分不清什么是 Bug、什么是有意为之,所以我统一用”恼人”来描述。
Prompt 缓存失效
大多数本地 LLM 服务器使用 OpenAI /v1/chat/completions API 的某种变体。基本思路:
-
POST 一个包含整个对话历史的 JSON blob。
-
收到一个 SSE 事件流,拼起来就是响应。
一个会话的上传开销是二次方的,下载也被放大——把小增量包装在带重复元数据的 JSON 里。工具调用使用了难以捉摸的”双重 JSON 编码”,序列化为多个 JSON 编码的增量片段再重新组装成更多 JSON。
这种设计有一个好处:服务器是无状态的。但让无状态的东西变快的惯用做法是:加状态。服务器缓存评估结果。收到请求时:
-
找到最长的匹配缓存前缀。
-
从前缀末尾评估到最后一个已发布消息的末尾(“预填充”)。
-
生成新 token 直到遇到结束序列 token。
我在 M4 Max 上使用 Qwen3.6-27B dense,内存带宽尚可(~0.5 TB/s,对 CPU SoC 算高的,对 GPU 算低的)。Token 生成可用,但预填充极度计算密集。如果我在深入上下文窗口时服务器找不到好的前缀匹配,可能要等 10 分钟满 GPU 使用才能开始生成响应。这没关系,因为这种情况不应该经常发生。应该。
以下是 OpenCode 在这个环节的各种翻车:
-
它 glob 你的文件系统并且在每一次 SSE turn 重新读取 AGENTS.md(注入到 turn-0 系统提示词)。如果你在 AGENTS.md 里写了一条快注让下次会话读,你立刻强制触发一次完整重评估。
-
每次 agent → user 转换时,它从工具调用中裁剪上下文,使大部分前缀失效。
-
裁剪只是丢弃距离写入头超过固定距离
const PRUNE_PROTECT = 40_000的工具调用结果。最好的情况下你承受的是 40k 上下文的缓存失效——相当于每两三个 turn 读一本长篇小说。 -
agent → user 转换包含中断,所以当你需要把蠢货从死胡同里拉回来重新引导时,OpenCode 立刻毁掉提示缓存让你干等。
-
-
个人最爱:它把当前日期放在 turn-0 系统提示词里,然后每个 SSE turn 重新评估。如果你在午夜使用 OpenCode,你会得到一个完整的提示缓存失效。
- 我猜这是试图纠正智能体认为当前日期是训练截止日期的倾向,拒绝相信新事物的存在。但还是很好笑。
以上是单纯属于缓存失效类别的问题。还有很多,我们边走边看。
上下文裁剪
我在前一节提到了裁剪。缓存失效不值得,所以我禁用了它。另一个突出问题是对早期读取内容缺乏保护。它的破坏性可能不太直观,我们来走一个例子。假设你开启一个全新会话,让智能体先读一份规格说明或实现计划,然后写代码:
-
规格说明被读入上下文。
-
智能体去读相关代码,很可能超过 40k 的固定裁剪阈值。
-
智能体准备好实现了,但要么立刻钻进一个愚蠢的死胡同,要么在思维链中无意义纠结一个其实非常简单或已经在规格中明确定义的东西。
-
你中断智能体重新引导。
-
中断导致整个规格说明被从上下文窗口中删除。
-
智能体写代码时无法参考规格说明。
裁剪对所有工具的所有结果一视同仁,除了 skill,它从不被裁剪。
上下文压缩
想干坐 10 分钟等 LLM 服务器把整个会话预填充加上一个新的提示词前缀,就为了生成 5 条要点放在新会话顶部?我也不想。我理解他们想达成什么,但我没见过它工作良好。压缩和裁剪都没实现好,而且它们交互很糟糕。
如果你想总结一个会话,总结提示词应该注入到末尾,避免从头预填充整个会话。我发现的最佳方法是让智能体明确写下笔记做交接。虽然丑,但比 OpenCode 的压缩机制好用,而且会生成一个磁盘文件,我可以编辑或在多个会话中复用。
上下文压缩是一个有漏洞的抽象,试图让有限的上下文窗口看起来像无限的。更好的做法是接受上下文窗口和提示缓存作为智能体驾驭的一级特性,并暴露更好的原语来管理它们。Pi 在会话树方面的做法很有趣,它有意利用提示缓存。
系统提示词
OpenCode 在新上下文窗口顶部粘贴一个系统提示词。正常且没问题,但是:
-
默认系统提示词极其冗长。讽刺的是大部分篇幅都在教 LLM 如何简洁。
-
默认系统提示词有自己的观点(没问题)但它观点很差(有问题)。我花了好久才搞清楚为什么我的智能体在派发子智能体时一直说”绝对不要写注释”。
-
Plan-to-Build 的交接很笨拙,往往在所有东西被充分阐明时我已经接近上下文窗口末尾了。我宁愿写下所有讨论的笔记,这样我可以编辑然后交给全新会话。对此,见下一条:
-
Plan 模式的系统提醒告诉智能体它不能写入任何目录,但实际允许写入特定的
.opencode/plans目录。我见过两种方向都出错:没被要求就写入该目录,以及被明确要求写入时拒绝。 -
没有全局修改默认系统提示词的方法;你必须把它复制到每个项目里。
-
如果你只在 Build 模式覆盖默认提示词,切换到 Plan 模式是一次完整的提示缓存失效。
不同模型的专属提示词质量和内容天差地别。它们都值得一个好骂,但 Beast Mode(GPT-4、o1 和 o3)是我的最爱。引用:
你无法在不使用 Google 验证你对第三方包和依赖项理解是否最新的情况下成功完成此任务。
你做不到。绝对不可能。我们不知道怎么做到。千万不要直接去读包的源代码。
权限提示
当智能体尝试访问项目目录外的文件时,如果它以 OpenCode 能用临时字符串解析识别到的方式这么做(哎呀这是令人警觉的事那部分),你会收到一个提示问你是否授权。这会暂停执行直到你响应。
答案是:Yes / No / Always。看到缺了什么吗?Never 呢?
与子智能体的交互尤其崩坏。如果一个子智能体尝试访问 /tmp 中的脚本输出,我说 No,它会杀掉子智能体,该子智能体部分完成工作的所有上下文全部丢失。所以我只能说 Yes 让它写入 /tmp 或它想做什么就做什么。
另一个问题是决策疲劳:如果我不断被问”我能做这个吗?“,而唯一通向生产力的回答是”好”,我最终会对某些危险的东西习惯性点头。人类易错性不应该成为”不要写出此目录”这种基础功能的承重墙。
智能体交互
这是一个编程智能体的第 0 号功能。它坏了。
-
如果我在 SSE 流式传输进行中发送消息,它会被排队。不错的特性。然而:
-
OpenCode 实际发送消息的时机语义不太清晰;代码暗示是工具调用 turn 结束时,但我也见过工具→CoT 转换时不发我排队的消息。
-
如果我随后中断,想让智能体回答我的问题而不是在那自恋思考,消息会从”排队”状态中被移除,直接进入消息日志。你中断是为了让智能体回答你的消息,但现在你没法发送那条消息了。你必须发第二条消息来开始新的流。
-
-
撤销消息经常无法从消息日志中删除。
子智能体(即智能体通过工具调用 RPC 生成的智能体)的问题继续:
-
我无法与子智能体对话;如果它们钻进一个愚蠢的死胡同,我要么杀掉它们失去上下文,要么无助地看它们燃烧 Token。
-
我查了一下,显然 OpenCode 以前有这个功能但现在……没了?
-
你可以从主智能体的聊天窗口
@mention一个子智能体,但这似乎没有任何有用的效果。特别是它不会中断。
-
-
如果一个子智能体的工具调用失败(比如 Qwen 把工具调用放到了 CoT 里),这是致命的,此前的所有上下文全部丢失。
-
复用子智能体的能力理论上不错,但实践中子智能体的主要用途是将任务拆成更小的上下文窗口。智能体有时决定为不相关的任务复用同一个子智能体,这会破坏这一点。
-
我从未见过这个有益,反而经常因为两个都有巨大上下文的智能体和子智能体之间切换导致提示缓存失效(又来了!)。
-
作为原则:从人类这边有更丰富的交互模型是可以的,但智能体会做尽一切可能的蠢事,所以选择必须最小化。
-
积极方面,OpenCode 的子智能体交互催生了我有幸读过的最好笑的 GitHub Issue 之一。
智能体工具
这些是 OpenCode 暴露给 LLM 的 RPC,用于访问文件和在你的机器上运行命令。他们做了些有趣的选择:
-
edit工具使用精确搜索替换,默认要求唯一匹配。-
这对智能体很友好,因为它们能精确回忆文件内容,但行号会因多次编辑而漂移,不一定知道内容在哪里。
-
有一个选项可以全局搜索;每次我看到智能体用这个,都是绝对灾难,需要多次编辑来纠正。移除这个选项就是你得到的 Pi
edit工具设计。
-
-
Plan 模式中的
question工具(多选)严格劣于直接告诉 LLM 在系统提示词中用自然语言问我问题。 -
grep和glob工具与bash冗余。-
它们可能更符合人体工程学,但鉴于我经常看到智能体直接在
bash中运行grep或rg,我表示怀疑。 -
我怀疑原因是允许只读智能体类型(如
Explore)受限于禁止bash命令。 -
这本质上是自我承认:不实际执行就无法推理
bash命令的副作用。后文详述。
-
-
todo工具可能是净收益,但智能体忘记检查 TODO。讽刺……他能拯救别人于死亡,却不能拯救自己。
TUI
文本用户界面(孩子们管 GNU nano 这类程序叫这个)最近很时髦。OpenCode 也有一个:
-
用 1GB 内存来渲染文本。
-
在消息框里无法输入换行。Shift-enter 应该做这件事,但在我这儿就是坏的。
- 我找到一个已有的 issue;开发者直接说”我机器上没问题”。已关闭。
-
有时输入多行消息(即单行放不下,被软换行),消息框滚动,光标继续移动,但我输入的字符不出现在新行上。
-
尝试在流式传输中选中文本:视图自动滚动,选区丢失。
-
^C立刻关闭会话。交互式 shell 不该这样工作。^C应该中断当前运行的命令,^D在没有命令运行时关闭会话。 -
文本框不响应正常快捷键(例如 Mac 上 Option+右/左 前进/后退一个词)。
-
一旦消息或 CoT 变长,Markdown 重渲染(或者其他东西,我没做性能分析)需要好几秒才能流式传输新片段。某人搞了个二次方的蠢活。
消息 UI 烂到我在编辑器里写好消息再粘贴进来。对于本质上是一个聊天应用的东西来说,这挺可悲的。我说过它用 1GB 内存来在终端里放点文字了吗?
文档
没什么好说的:语无伦次的垃圾。很明显这是写给智能体读的,不是人。
令人警觉的事
这里 OpenCode 从”嗯?“升级到”嗯??!“
远程优先
很难让 OpenCode 停止给家里打电话:
-
OpenCode 默认连接到远程模型。
-
文档缺少配置本地模型的简单示例;如果你搞错了,猜猜怎么着,你连上了远程模型。
-
如果你成功指定了本地模型,猜猜怎么着,你必须运行 OpenCode 并交互式点击选中它,与此同时它已经连上了远程模型,你的机器上开了一个本地 shell。
-
奖励轮:默认模型的 URL 不是发行版中的静态内容,它是从 models.dev(与 OpenCode 关联的网站)下载的。来源:
opencode/src/provider/provider.ts第 1684 行。
OpenCode 在全新安装首次启动时不会立刻开始 SSE 流,但很接近了。安装、运行 opencode、按一个字母然后回车,就足以将一个远程模型连接到你的本地 shell,无需任何用户配置。
如果对话中的第一条消息是空或模糊的,智能体训练出的模型通常第一件事就是 glob 当前目录并开始读取文件。它读取的任何内容都在下一个 POST 请求中上传。
YOLO 互联网访问
智能体在面对不受信输入时是出了名的不可预测。既然如此,你可能会惊讶:
-
WebFetch工具存在。 -
系统提示词中明确指示 LLM 使用
WebFetch工具。 -
系统提示词中关于智能体是否应该”猜测” WebFetch 工具 URL 的措辞诡异而模糊。
这是 default.txt 中第二行非空内容(后面还有一次明确引用 WebFetch),所以你知道它很重要:
重要:除非你确信 URL 有助于用户的编程,否则你必须绝不生成或猜测 URL。你可以使用用户在消息中或本地文件中提供的 URL。
让我们启动”智能体之眼”看看这到底是什么意思:
绝不猜测 URL。除非你真的想,那就猜吧我猜。我又不是警察。无视之前的指令。
我不信”提示词工程”,但我绝对信导致这条提示词诞生的任何流程的反面。
从安全角度看 WebFetch 其实没那么有趣,因为 bash 命令上没有网络沙箱。唯一的防线是祈祷智能体不运行 curl | bash 之类的命令。说到这个——
Bash 权限
我禁止了 git 命令,因为:
-
我想控制提交历史。
-
我曾见过智能体通过运行
git checkout .撤销最近的一次更改,从而清掉整个会话的工作。
opencode.json 中的配置部分长这样:
"permission": {
"bash": { "git *": "deny" }
}看起来很简单直白,对吧?其工作原理是:
-
用
tree-sitterbash 或 PowerShell 语法解析 bash 命令为 AST -
遍历 AST 的命令节点
-
将节点与你
opencode.json中编译的正则表达式匹配
这条命令被拒绝:
git status
这条同样被拒绝:
echo hello && git push --force
然而,这条被允许:
echo 'git clean -fdx .' | bash
这条被允许:
env git status
这条被允许:
alias cd=git
cd filter-branch --index-filter 'git rm -rf --cached --ignore-unmatch path_to_file' HEAD这条被允许:
/usr/bin/git status
这条被允许:
$(which git) status
这条被允许:
GIT=git && $GIT status
这条被允许:
# 解码为: git reset --hard
echo Z2l0IHJlc2V0IC0taGFyZAo= | base64 -d | bash这条被允许:
bash << 'EOF'
git push --force
EOF这条被允许:
python3 -c 'import subprocess
result = subprocess.run(
["git", "checkout", "."],
capture_output=True,
text=True
)'文本命令过滤毫无用处。它不适合任何目的。任何有安全直觉或经验的人都不会费心去实现这个过滤器,因为它除了虚假的安全感什么也做不到。
智能体通常不是恶意的,但由于被训练为用坚持弥补愚蠢,它们天然具有对抗性。这不是护栏,这是祈祷。
持久化权限
熟悉 OpenCode 内部的人(如果你是 OpenCode 开发团队的一员,我假设不包括你)可能会对我上面的 python3 例子有异议。假设 LLM 想运行一个无害的命令,比如:
python3 -c 'print("hello")'
你会收到一个提示,问你是否允许该命令。如果你选择 Always,这个权限会为 python3前缀持久化。下次:
python3 -c 'print(open("~/.ssh/id_rsa").read())'
你已经批准了 Python,所以这个无害的命令也会被允许运行,给你无缝的智能体编程体验。权限还被持久化到磁盘上供将来的会话使用。你可能会说回复 Always 给一个 Python 命令很蠢,我被迫同意,但 echo 呢?记住这个,后面会很重要。
CWD 例外
以下 bash 和 PowerShell 命令被假定没有副作用,永不触发权限提示:
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
它们明确绕过 bash 命令权限检查,即使你在 opencode.json 中设了 "permissions": {"bash": {"*": "deny"}}。我不确定这让人能搞什么,但还是,怪怪的。
文件权限
默认情况下,OpenCode 尝试阻止智能体访问 opencode 二进制被调用的目录或 git 仓库(取路径较短者)之外的文件。
这个实现烂到花了我好一会儿才搞清楚它到底是在尝试过滤 bash 命令中的路径,还是只对接收显式文件路径的 read 等工具有效。我经常被提示授权智能体读取 /tmp 中的文件——而那个文件是它自己刚刚运行生成临时输出的脚本写入的。
对于 bash 工具,OpenCode 遍历 tree-sitter AST(一想到 bash AST 这概念我还是想笑),路径解析任何可能为路径的东西,并验证这些路径。所以这条命令需要权限:
cat /tmp/logfile
这条则不需要:
python3 -c 'import shutil; shutil.rmtree("/")'
固若金汤,生产就绪,LGTM。✅🚀
类似地,智能体可以自由运行 cargo 命令,这些命令对全局 ~/.cargo 目录使用读、写和执行权限。然而,如果小智想读取 ~/.cargo/registry/src 中某个 cargo 包的源代码来查看 API 细节,我就会被提示授权。
FILES 列表
我之前提到 OpenCode 解析 bash AST 中的路径并验证它们。我没提到的是它什么时候做这个。看好了,所有可能访问文件的 bash(和 PowerShell)命令列表:
const FILES = new Set([
...CWD,
"rm",
"cp",
"mv",
"mkdir",
"touch",
"chmod",
"chown",
"cat",
"get-content",
"set-content",
"add-content",
"copy-item",
"move-item",
"remove-item",
"new-item",
"rename-item",
])不在这个列表上的命令被假定为不访问文件。传给这些命令的路径不予检查。
重定向
你可能记得,一旦你给某个命令授予了权限,它就永远被允许了。
所以:
echo "hello world!"
显然你会选 Always——那是无害的命令。那么这些也是无害的:
echo 21 > /sys/class/gpio/export
echo out > /sys/class/gpio/gpio21/direction
echo 1 > /sys/class/gpio/gpio21/value有趣的地方是 OpenCode 如何处理 shell 重定向的路径验证。还记得它用 tree-sitter 把 bash 解析到 AST。示例 bash:
echo foo > bar.txt
解析后的 AST:
program
redirected_statement
command
command_name
word: "echo"
command_argument
word: "foo"
redirection
redirection_operator
greater_than: ">"
word: "bar.txt"
command 的子节点被进行路径验证。然而,redirection 是 command 的兄弟节点。完蛋。
当然这其实没关系,因为 echo 不在 FILES 列表中,所以被假定为不修改文件。路径验证完全失败没关系,因为它从未运行。
Curlbash
OpenCode 有很多自升级的方式。不,认真的,很多方式;去看看 opencode/src/installation/index.ts。这个是我最喜欢的:
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,
)当你运行 opencode upgrade 时,如果你原来是通过他们的 curl-bash 安装器安装的,这段代码就会跑。其实并不比用 curl-bash 安装器本身更糟(嘿,Rust 也这么干),我只是觉得这是传说中的”生产级 curlbash”的一个特别震撼的实例。
话虽如此——
它他妈全是 RCE
非常显眼地,曾有一个 CVE,OpenCode 默认暴露了一个 HTTP 服务器,它:
-
配置了完全宽松的 CORS 头。
-
故意暴露了一个接受任意 shell 命令的 POST API。
-
故意暴露了一个读取任意文件的 GET API。
这意味着你访问的任何网站都可以敲 OpenCode 众所周知的默认端口,立刻获得对你系统的完整用户级访问权限。
开发者认为默认禁用该服务器是个好主意,解释说 CORS 头还需要一个例外以允许他们的网站 opencode.ai 对你的机器执行 RCE(???),承诺以后会做得更好,然后消失了。Stale bot 关闭了 issue。
以上是一个模式的一部分。这个 issue 报告说一个 auth 命令会从你传入的任何 URL 下载并执行。也被 stale bot 关闭了。这是一个用户可控 URL,但是……他妈什么?我们到底在干什么?
用 Docker 就好了
任何关于编程智能体与安全的讨论都会迅速收到这个回复:“用 Docker 就好了。“我从每个层面反驳:
-
我就是不想在开发中用 Docker——如果你的依赖如此庞杂以至于在全新机器上都无法安装,那你为什么要这么多依赖?
-
Docker 本身制造安全漏洞:
-
它创建了一个以 root 运行的”神级服务”。
-
它故意在
ufw防火墙中开洞。
-
-
如果你关心的所有东西都在容器里,而容器内的本地 shell 被故意连接到互联网,那到底在保护什么?
-
如果你从 Docker 获取的功能是”请不要递归删除我的根文件系统”,那有更简单的方法,比如 Landlock、Seatbelt、Restricted Tokens 等。
把安全责任推出去根本不奏效。安全应该是编程智能体的第一关注点。操作系统原生提供了帮助实现这一点的构造作为沙箱基础;请停止试图对 bash 命令做文本净化。在我的 git 例子中,正确的修复是阻止 git 可执行文件并使 .git 为只读。
结论
停止使用 OpenCode。
附言:本地 LLM
这值得单独写一篇文章——我的博客草稿里有多个版本——但在此需要简要说明。我对 Qwen3.6-27B 这类本地 LLM 的看法是:它们对你的代码库稳定性和概念保真度的腐蚀性与前沿模型相同,只是有以下三点区别:
-
你避开了”模型看似聪明然后做蠢事”的恐怖谷效应;其愚蠢是一目了然的,这有助于校准你的交互方式。
-
权重数太低,不至于逐字复现训练集,这改变了关于输出是否应被视为受污染的判断。这与更大模型的情况不同——更大模型能够逐字复现输入,但被训练为拒绝这样做。
-
你避免支持或依赖云服务商。
我从输入导向的任务中获得了有用的结果,比如:“我觉得代码 x 中有个 bug,症状是 y,我猜机制是 z。阅读所有相关代码,回来给我调用链和代码引用。“把问题框定为搜索,能约束智能体瞎编的倾向。
用 LLM 生成代码感觉是一条死路。不管你多深入理解你的架构,你的规划不断被”不如我把这段可变状态挪到设计中间让所有人都能分享?“这类捷径破坏。这对于你理解自己代码的能力是敌对的,除了你没有写它这个事实之外。
直接从模型权重中的知识提取答案,即便对万亿参数模型也会导致幻觉,那为什么要做这么大?如果人们对局限性有现实认识,我们就不会为数据中心建新发电站,它们也不会被塞进每个产品。
围绕 LLM 的整个软件生态完全腐烂了。如果它们有朝一日真的成为”只是工具”,那需要有人围绕它们做一些真正的系统工程,把它们变成工具而不是安全黑洞。那项工作将必须由人类完成。
⇥ 返回 wren.wtf
译者注:本文是一篇来自独立博客的口吻犀利的工程批判,作者 wren 以源码级细节拆解了 OpenCode 在设计、安全、工程实践上的诸多问题。值得注意的是本文撰写时 OpenCode 正处于快速迭代期,部分问题可能已在后续版本中修复。读者宜批判性地看待,区分已修复的 Bug 与架构层面固有的设计缺陷。写作风格辛辣幽默,翻译中尽量保留了原文的语气。