OpenCode的恼人与危险之处
如果你还不知道OpenCode是什么,不妨想象一只永远踩在人脸上的靴子——这只靴子由TypeScript铸成,而它脚下的脸,是自20世纪40年代电子计算机发明以来,我们在安全与系统软件领域积累的全部知识。它的开发者将其描述为一款AI编码代理。据我了解,它是目前最受欢迎的开源编码代理,在GitHub上已有16.1万颗星。
我曾搭配本地大语言模型(LLM)试用过OpenCode,结论是:它就是个漏洞百出的"小丑车",安全姿态低到离谱,简直像在说"快来拿捏我"。所有正在用它的人,都应该立刻停止。
本文分为两部分:糟心问题和惊悚隐患,第二部分篇幅更长。文中内容基于OpenCode git版本baef5cd4的源代码展开。
我不认为本文涉及任何安全漏洞披露。OpenCode本质上是个实现llm | bash管道功能的Web栈工具,我所描述的所有问题都出在"管道"部分。它的失败方式充满了层层叠叠的糟糕决策,堪称"分形级"离谱,但结局从一开始就注定了。
我刻意将LLM本身的使用问题,与"用户是否该因为用LLM就轻易遭遇机器被入侵或数据被清空"的问题分开讨论。文末附言部分会简要谈谈本地LLM的相关想法。
先暂时把安全问题放一边,看看就算没让你"翻车",OpenCode作为工具本身有多拉胯。它有种"贝塞斯达效应"——你根本分不清哪些是bug,哪些是设计如此,所以我姑且用"糟心"来形容这些问题。
大多数本地LLM服务器都采用OpenAI /v1/chat/completions API的某种变体,逻辑很简单:
- 你发送一个包含完整对话历史的JSON数据包;
- 服务器返回一串SSE事件,拼接起来就是完整回复。
这种模式下,会话过程中的上传成本是二次方级的,下载则因为每个微小增量都要套上重复元数据的JSON外壳而变得冗余。工具调用还用到了诡异的"双重JSON编码",这样就能把多个JSON编码的增量序列化为可重组为完整JSON的格式。
这套架构唯一的好处是服务器无状态。但要让无状态系统变快,常规做法是引入状态——也就是服务器缓存计算结果。当收到请求时,服务器会:
- 找到最长匹配的缓存前缀;
- 计算从该前缀末尾到最后一条消息末尾的内容(即"预填充");
- 生成新的token,直到遇到序列结束标记。
我在M4 Max芯片上使用Qwen3.6-27B密集模型,它的内存带宽不错(约0.5TB/s,在CPU芯片中算高,但远低于GPU)。token生成速度尚可,但预填充阶段完全受限于算力。如果对话进行到上下文窗口末尾时,服务器找不到匹配度高的缓存前缀,我可能得等10分钟(期间GPU满负荷运行)才能看到回复。理论上这种情况应该很少发生——应该。
但OpenCode显然完全没get到这一点,以下是它的离谱操作:
- 每次SSE轮询都会遍历你的文件系统,重新读取AGENTS.md(该文件会被注入初始系统提示词)。如果你在AGENTS.md里加了一条备注想让下一次会话读取,立刻就会触发全量预填充。
- 每次从代理切换到用户时,都会修剪工具调用的上下文,导致大部分缓存前缀失效。
- 修剪逻辑很简单:直接丢弃写入头后方超过固定阈值
const PRUNE_PROTECT = 40_000的工具调用结果。最好的情况也是丢失40k上下文,相当于每两三轮对话就要重新读取一整本长篇小说的内容。 - 代理到用户的切换包括中断操作,所以如果你想把跑偏的AI拉回正轨,OpenCode会立刻清空提示词缓存,让你重新等待回复。
- 我个人最无语的一点:它把当前日期加入初始系统提示词,而且每次SSE轮询都会重新计算。如果你在午夜使用OpenCode,直接触发全量缓存失效。
以上只是这一类别的缓存失效问题,后续还会提到更多。
我刚才提到了修剪功能,这种级别的缓存失效完全得不偿失,所以我直接关掉了它。另一个明显的问题是对早期读取内容缺乏保护。它的离谱程度可能不是一眼就能看出来的,举个例子:假设你开启新会话,先让AI读取一份规格文档或实现方案,再写代码:
- 规格文档被读入上下文;
- AI去读取相关代码,很容易超过40k的修剪阈值;
- AI准备开始写代码,但要么立刻跑偏到无关方向,要么对着简单明了的问题陷入无意义的思维链;
- 你中断AI,想把它拉回正轨;
- 中断操作导致整个规格文档被移出上下文窗口;
- AI在看不到规格文档的情况下开始写代码。
修剪规则适用于所有工具的结果,只有skill工具的内容永远不会被修剪。
谁也不想花10分钟等LLM服务器重新预填充整个会话,就为了生成5个要点放在新会话开头吧?反正我不想。我理解他们的初衷,但这套机制完全不好用。压缩和修剪功能都做得很差,还互相干扰。
如果想总结会话内容,应该在会话结束时注入总结提示词,避免从头预填充整个会话。我发现最有效的方法就是直接让AI写笔记——虽然不够优雅,但比OpenCode的压缩机制好用,还能生成一份可编辑、可跨会话复用的本地文件。
压缩功能是个蹩脚的抽象,试图把有限的上下文窗口伪装成无限的。不如直接接受上下文窗口和提示词缓存是AI交互的核心特性,提供更完善的管理原语。Pi在这方面的做法很有意思,它用会话树刻意利用提示词缓存。
OpenCode会在新的上下文窗口顶部粘贴系统提示词,这本身没问题,但:
- 默认系统提示词异常冗长,讽刺的是,大部分内容都在教LLM如何保持简洁。
- 默认提示词带有主观倾向(这没问题),但观点完全不靠谱(这就有问题了)。我花了很久才搞懂,为什么我的代理在分派子代理时总说"绝对不要加注释"。
- 从"规划模式"切换到"构建模式"的流程很笨拙,往往等所有内容都梳理清楚时,上下文已经快到窗口末尾了。我宁愿自己把讨论内容整理成笔记,编辑后再开启新会话——这就引出了下一个问题:
- 规划模式的系统提示词告诉AI不能写入任何目录,但实际上它被允许写入特定的
.opencode/plans目录。我见过两种失败情况:AI未经允许就写入该目录,或者明确指令它写入时却拒绝执行。 - 无法全局修改默认系统提示词,必须复制到每个项目中单独修改。
- 如果只在构建模式下覆盖默认提示词,切换到规划模式时会触发全量缓存失效。
- 针对不同模型的提示词内容和质量参差不齐,都值得吐槽一番,其中我最想吐槽的是野兽模式(适用于GPT-4、o1和o3)。引用一段:
你必须使用Google验证对第三方包和依赖的理解是否最新,否则绝不可能完成任务。 绝对不行,根本做不到。千万别去读包的源代码。
当AI试图访问项目目录外的文件时,如果OpenCode通过临时字符串解析识别到了这一操作(哦,这其实属于惊悚隐患部分),会弹出提示让你授权,执行会暂停直到你回复。
可选回复是Yes/No/Always,你发现少了什么选项吗?比如Never?
它与子代理的交互尤其糟糕。如果子代理试图访问/tmp下的脚本输出,我选No,它会直接终止子代理,导致其未完成工作的所有上下文丢失。所以我只能选Yes,任由它写入/tmp或做其他操作。
另一个问题是决策疲劳:如果我总被问"我能这么做吗",而唯一能推进工作的回复是"yes",最终我会麻木地点头同意一些危险操作。像"不要写入项目外目录"这种基础安全要求,不该依赖人类的判断力——毕竟人总会犯错。
这是编码代理的核心基础功能,而它完全失效了。
如果在SSE流传输过程中发送消息,消息会被排队,这本来是个不错的功能,但:
- OpenCode何时真正发送排队消息的逻辑不太清晰;代码显示是在工具调用轮次结束时发送,但我也见过从工具调用切换到思维链时,排队消息并未发送的情况。
- 如果我随后中断AI,想让它回复我的消息而非继续空想,消息会从"排队"状态转为进入消息日志——你中断是为了让AI回复消息,结果现在发不出去了,只能重新发一条消息开启新流。
- 撤销消息往往无法将其从消息日志中移除。
子代理(即主代理通过工具调用RPC生成的代理)的问题也不少:
- 我无法直接与子代理对话;如果它们跑偏了,我只能选择终止它们并丢失上下文,或者眼睁睁看着它们浪费token。
- 我查了一下,OpenCode以前似乎有这个功能,但现在……没了?
- 可以在主代理的聊天窗口
@提及子代理,但似乎没什么用,尤其是无法中断子代理。 - 如果子代理工具调用失败(比如Qwen把工具调用放在思维链里),会直接导致任务失败,之前的所有上下文都丢失。
- 重用子代理理论上听起来不错,但实际上子代理的主要作用是把任务拆分成更小的上下文窗口。如果主代理有时会决定用同一个子代理处理无关任务,就完全违背了这个初衷。
- 我从未见过重用子代理带来好处,反而经常因为在上下文庞大的主代理和子代理之间切换,导致提示词缓存失效(又是这个问题!)。
原则上:允许人类端有更丰富的交互模式没问题,但AI总会做出各种离谱操作,所以要尽量减少需要人类做选择的场景。
不过有个正面例子:OpenCode的子代理交互引发了一个GitHub议题,是我读过的最搞笑的议题之一。
OpenCode给LLM提供了一些RPC工具,用于访问文件和在你的机器上运行命令,他们的选择很有意思:
edit工具默认使用精确搜索替换,要求匹配内容唯一。 这其实很适合AI,因为它们能准确回忆文件内容,但往往不确定内容在文件中的具体位置(毕竟多次编辑后行号会变化)。 它也支持全局搜索替换,但我每次看到AI用这个功能,结果都一团糟,需要多次修改才能纠正。去掉这个选项,就和Pi的edit工具设计完全一样了。- 规划模式下的
question工具(多选),效果远不如直接在系统提示词里让LLM用自然语言提问。 grep和glob工具与bash功能重复。 它们可能用起来更顺手,但我经常看到AI直接在bash里运行grep或rg,所以我对此表示怀疑。 我猜测这么做是为了让Explore这类只读代理无法执行bash命令。 这本质上等于承认,不实际运行bash命令就无法判断其副作用。后面会详细讲这一点。todo工具可能还算有用,但AI经常忘记查看待办事项。太讽刺了……它能提醒别人别出错,却管不住自己。
文本UI(现在年轻人叫它GNU nano这类程序)最近很流行,OpenCode也有一个:
- 渲染文本要占用1GB内存。
- 无法在消息框中输入换行符。按Shift+Enter应该能实现,但对我来说完全没用。
- 有时输入多行消息(即太长导致自动换行),消息框会滚动,光标也跟着移动,但我输入的字符不会显示在新行上。
- 在流传输过程中尝试选中文本:视图会自动滚动,导致选中内容丢失。
- 按
^C会立即关闭会话。这不符合交互式shell的常规逻辑:^C应该中断当前运行的命令,^D才应该在没有命令运行时关闭会话。 - 文本框不支持常用快捷键(比如Mac上的Option+左右箭头跳转到单词首尾)。
- 当消息或思维链很长时,Markdown重新渲染(或是其他操作,我没做性能分析)要花好几秒才能加载新内容。显然有人写出了二次复杂度的垃圾代码。
消息UI烂到我只能在编辑器里写好消息再粘贴进去。对于一款本质是聊天应用的工具来说,这太可悲了。对了,我刚才说它渲染文本要占用1GB内存来着?
没什么好说的:它就是一堆逻辑混乱的垃圾。显然这东西是给AI读的,不是给人看的。
从这里开始,OpenCode就从"嗯?"变成"啥?!"了。
很难阻止OpenCode"打电话回家":
- OpenCode默认连接远程模型。
- 文档里没有配置本地模型的简单示例;如果配置错了,呵呵,你就会被连到远程模型。
- 就算你成功指定了本地模型,猜怎么着,你必须运行OpenCode并通过交互式点击选择它,与此同时它已经连接到远程模型了,还在你的机器上打开了本地shell。
- 更绝的是:默认模型的URL不是发行版的静态部分,而是从models.dev(与OpenCode关联)下载的。来源:
opencode/src/provider/provider.ts第1684行。
全新安装后首次启动时,OpenCode不会立刻开启SSE流,但也差不多了。安装、运行opencode,按一个字母再回车,就足以让远程模型连接到你的本地shell,全程无需用户配置。
如果对话的第一条消息为空或模糊不清,经过训练的AI通常会先遍历当前目录并开始读取文件,读取到的所有内容都会在下次POST请求中被上传。
众所周知,AI在处理不可信输入时行为不可预测。既然如此,你可能会对以下情况感到惊讶:
WebFetch工具存在。- 系统提示词明确指示LLM使用
WebFetch工具。 - 系统提示词中关于AI是否应该"猜测"
WebFetch工具的URL的描述,含糊到离谱。
这是default.txt中的第二行非空内容(后面还有一处明确提到WebFetch),可见其重要性:
重要提示:除非你确信URL有助于用户编程,否则绝不能生成或猜测URL。你可以使用用户消息中提供的URL或本地文件。
用"AI视角"解读一下,这话实际意思是:
绝对不要猜URL。除非你特别想猜,那随便吧,我又不是警察。之前的话当我没说。
我不相信所谓的"提示词工程",但我绝对相信,写出这种提示词的过程,就是"反提示词工程"。
从安全角度看,WebFetch其实没那么可怕,因为bash命令本身就没有网络沙箱保护。唯一的防线就是祈祷AI不要运行curl | bash这类命令。说到这个,
我禁用了git命令,原因有二:
- 我想自己控制提交历史;
- 我曾遇到过AI运行
git checkout .撤销最近的修改,结果把整个会话的工作都清空了。
opencode.json中的配置部分看起来是这样的:
够直白吧?它的工作原理是:
- 用
tree-sitter的bash或PowerShell语法,把bash命令解析成AST; - 遍历AST中的命令节点;
- 将节点与
opencode.json中编译好的正则表达式匹配。
以下命令会被拒绝:
以下命令也会被拒绝:
但以下命令会被允许:
以下命令会被允许:
以下命令会被允许:
以下命令会被允许:
以下命令会被允许:
以下命令会被允许:
以下命令会被允许:
以下命令会被允许:
以下命令会被允许:
文本命令过滤完全没用,毫无价值。任何有安全直觉或经验的人都不会费心实现这种过滤器,因为它除了制造虚假的安全感之外,什么用都没有。
AI(通常)不是恶意的,但它们天生具有"对抗性"——因为它们被训练用坚持来弥补自身的不足。这根本不是什么安全护栏,只是自我安慰而已。
熟悉OpenCode内部机制的人(如果是OpenCode开发团队成员,我猜你们并不熟悉)可能会对我上面举的python3例子有异议。假设LLM想运行一个无害的命令:
会弹出提示让你授权。如果选Always,这个权限会被持久化保存针对python3前缀。下次运行:
你已经批准了Python,所以这个无害命令也会被允许运行,给你"流畅的代理式编码体验"。这个权限还会被持久化到磁盘,供未来会话使用。你可能会说,对Python命令选Always很蠢,我同意,但如果是echo呢?记住这个问题,后面很重要。
以下bash和PowerShell命令被认为无副作用,永远不会触发权限提示:
这些命令会明确绕过bash命令权限检查,哪怕你在opencode.json中设置了"permissions": {"bash": {"*": "deny"}}。我不知道这能用来做什么,但确实很奇怪。
默认情况下,OpenCode会试图阻止AI访问opencode二进制文件启动时所在目录或git仓库之外的文件(取路径更短的那个)。
这个功能实现得太差了,我花了很久才搞清楚,它到底是在尝试过滤bash命令中的路径,还是只对read这类接受明确文件路径的工具生效。我经常收到提示,让我授权AI读取/tmp中的文件——而这个文件正是AI刚刚通过运行脚本生成的临时输出。
对于bash工具,OpenCode会遍历tree-sitter生成的AST(想到bash还有AST我就想笑),解析任何可能是路径的内容并验证。所以以下命令需要授权:
但这个不需要:
简直无懈可击、生产就绪,完美!✅🚀
类似地,AI可以自由运行cargo命令,这些命令会对全局~/.cargo目录进行读、写、执行操作。但如果AI想读取~/.cargo/registry/src中某个cargo包的源代码来查看API细节,我就会收到授权提示。
我之前提到OpenCode会解析bash AST中的路径并验证,但没说它什么时候做这件事。看看所有可能访问文件的bash(和PowerShell)命令列表:
不在这个列表中的命令被认为不会访问文件,传递给这些命令的路径不会被检查。
你应该还记得,一旦你批准了某个命令,它就会被永远允许。
比如:
显然你会选Always——这是个无害的命令。那这些也无害:
最有意思的是OpenCode处理shell重定向的路径验证方式。记得它用tree-sitter把bash解析成AST吗?举个bash例子:
解析后的AST:
command的子节点会被路径验证,但redirection是command的同级节点。哦豁,完犊子。
当然这其实无关紧要,因为echo不在FILES列表中,被认为不会修改文件。路径验证完全失效根本不重要,因为它根本不会运行。
OpenCode有很多自我升级的方式,真的,非常多;可以去看看opencode/src/installation/index.ts。我最喜欢的是这个:
如果你最初是通过curl bash脚本安装的,运行opencode upgrade时就会执行这段代码。这其实和一开始用curl bash脚本安装差不多(嘿,Rust也这么干),我只是觉得这是传说中"生产级curl bash"的一个特别突出的例子。
话虽如此,
之前有个非常显眼的CVE:OpenCode默认开启一个HTTP服务器,该服务器:
- 配置了完全宽松的CORS头;
- 刻意暴露了一个可执行任意shell命令的POST API;
- 刻意暴露了一个可读取任意文件的GET API。
这意味着你访问的任何网站都可以连接到OpenCode众所周知的默认端口,立即获得你的系统的完整用户级权限。
开发者决定默认关闭该服务器,解释说CORS头仍然需要保留例外,允许他们的网站opencode.ai远程执行代码(???),承诺未来会改进,然后就没下文了。过时机器人关闭了这个议题。
这是一种模式的体现。另一个议题报告说,某个认证命令会从你传入的任意URL获取并执行代码。同样被过时机器人关闭了。虽然URL是用户控制的,但……搞什么呢?我们到底在做什么?
每当讨论编码代理的安全问题时,总会有人说:"用Docker不就行了。"我从各个层面反驳这种说法:
- 我就是不想用Docker做开发——如果你的依赖复杂到在新机器上都不知道怎么安装,那你到底为什么要有这么多依赖?
- Docker本身就会带来安全漏洞:
- 它会创建一个以root身份运行的"上帝服务";
- 它会刻意在
ufw防火墙上开洞; - 如果你关心的所有内容都在容器里,而容器内的本地shell又刻意连接到互联网,那到底在保护什么?
- 如果用Docker只是为了"别让我的根文件系统被递归删除",有更简单的方法可以实现,比如Landlock、Seatbelt、受限令牌等。
把安全责任推给第三方根本行不通。安全应该是编码代理的头等大事。操作系统本身就有原生机制可以帮助实现安全防护;别再试图对bash命令做文本清洗了。在我那个git的例子中,正确的修复方法是阻止git可执行文件,并将.git设为只读。
别用OpenCode了。
这个话题值得单独写一篇文章——我的博客草稿里已经有好几版了,但这里需要简要提一下。我认为Qwen3.6-27B这类本地LLM,和前沿模型一样,会腐蚀代码库的稳定性和概念一致性,但有三个不同点:
- 不会出现"看似智能,实则犯蠢"的恐怖谷效应;它们的愚蠢一目了然,这有助于你调整与它们的交互方式。
- 参数数量太少,无法逐字重现训练集,这会改变对输出是否"受污染"的判断。这与更大的模型不同——大模型能逐字重现输入,但被训练成拒绝这么做。
- 无需依赖或支持云服务商。
我曾用本地LLM完成过一些有用的输入导向任务,比如:"我认为代码x有个bug,症状是y,我猜测原因是z。请阅读所有相关代码,返回调用链和代码引用。"把任务框定为搜索问题,可以约束AI胡编乱造的倾向。
用LLM生成代码感觉是条死胡同。无论你自认为对架构有多了解,你的规划总会被各种捷径打乱,比如"要是我把这个可变状态移到设计中间,让所有人都能共享呢?"这会严重损害你理解代码的能力,更不用说代码根本不是你写的。
直接从模型权重中提取知识会导致幻觉,哪怕是万亿参数的模型也不例外,那何必把模型做那么大?如果人们能实事求是地看待LLM的局限性,我们就不会为数据中心建新电站,也不会把它们硬塞进每个产品里。
围绕LLM的整个软件生态已经烂透了。如果它们真的想成为"普通工具",就需要人类对它们进行真正的系统工程改造,把它们从安全黑洞变成可用工具。这项工作必须由人类来完成。
⇥ 返回 wren.wtf