开源终端编码 Agent 项目 opencode 的安全边界:它明说不做沙箱,你该在外面补什么

2026-08-04

本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目代码与文档仍在变动,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。

opencode 在自己的 SECURITY.md 里把话说死了:它不给 agent 做沙箱,权限系统是一个帮你保持知情的 UX 功能,不是为安全隔离设计的。 这句话不是免责套话,它决定了你在这台机器上跑它时,安全责任落在谁头上——落在你,以及你在它外面套的那一层上。

这篇只谈 opencode 这一个开源终端编码 Agent 项目自己划出来的线:它承认管什么、明说不管什么、三条暴露面各自长什么样。站内另外几篇是别的分工——pi 的安全边界拆的是另一个项目的隔离取向,提示注入防御讲的是输入侧的对抗手法,API Key 安全管理讲密钥本身的存放与轮换。本篇不重复这些,只做一件事:把 opencode 的边界读准,然后告诉你缺口在哪。


一、先读它承认不管的那部分

SECURITY.md 的威胁模型一共不长,但每一段都有信息量。

关于沙箱,它写得非常直白:

OpenCode does **not** sandbox the agent. The permission system exists as a UX
feature to help users stay aware of what actions the agent is taking - it
prompts for confirmation before executing commands, writing files, etc.
However, it is not designed to provide security isolation.

If you need true isolation, run OpenCode inside a Docker container or VM.

这段话的工程含义是:权限弹窗是”提醒你在发生什么”,不是”阻止恶意行为发生”。你不能拿它当纵深防御的一层来算。想要真隔离,它把方案直接指给你了——容器或者虚拟机。

紧接着是一张明确的范围外清单,五类问题它不当漏洞收:开启了服务端模式之后的 API 访问、沙箱逃逸、模型服务商对数据的处理、外部 MCP 服务端的行为、以及被篡改的配置文件。前两项是设计取向的直接后果,第三项把数据合规甩给了服务商各自的政策,第四项把 MCP 明确划在信任边界之外,第五项则意味着:配置文件本身不是攻击面,因为它假设配置由你掌握——反过来说,谁能改你仓库里的 opencode.json,谁就能改你的权限规则。

服务端模式那一段也值得一并读:它是选择性开启的,开启后需要设置 OPENCODE_SERVER_PASSWORD 才有 HTTP Basic Auth,不设就是无认证运行(带一条警告),并且明说保护服务端是终端用户的责任。

还有一条运营层面的硬规定:这个项目不接受 AI 生成的安全报告,提交会被直接封禁。真发现问题走 GitHub Security Advisory 的 Report a Vulnerability,六个工作日没有回应可以发邮件到 security@anoma.ly 升级。你要是打算给它交报告,先把这条读进去。


二、权限到底怎么判:最后一条匹配的规则赢

搞清楚它管的那部分怎么运作,比记住它不管什么更实用。

packages/opencode/src/permission/evaluate.ts 只有一行,是从同目录的 index.ts 再导出。真正的求值函数在 index.ts 里:

export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule {
  return (
    rulesets
      .flat()
      .findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
      action: "ask",
      permission,
      pattern: "*",
    }
  )
}

三个点要抓住。第一,用的是 findLast,所以文档里那句”最后匹配的规则胜出”是字面意义上的实现,把 "*" 兜底规则写在前面、具体规则写在后面才符合它的语义;顺序写反,你以为的收紧其实没生效。第二,匹配是两个维度同时做的——权限名和输入模式都要匹配上。第三,一条都没匹配上时,兜底动作是 ask 而不是 allow

但这个 ask 兜底在实际运行里几乎轮不到。packages/opencode/src/agent/agent.ts 里构造的默认规则集第一条就是 "*": "allow",其余只有 doom_loopexternal_directoryaskquestion 与计划模式进出走 denyread*.env*.env.* 单独收紧、对 *.env.example 放开。也就是说:你什么都不配,绝大多数工具调用是直接放行的,弹窗只出现在三类情况上——跳出工作区、同一个工具调用带着完全相同的输入被原样重复三次(这个次数在会话处理器里是写死的常量)、以及读取匹配 *.env*.env.* 的文件。除此之外,工作区内的读、写、改、跑命令,默认都不会拦你一下。

这里有一处文档与代码不一致,读的时候别被带偏:packages/web/src/content/docs/permissions.mdx 的默认值一节写的是 read.env 默认 deny,而 agent.ts 里默认集给的是 ask。差别是实打实的——deny 是拦死,ask 是你一路回车就放过去了。真要拦,自己在配置里写死 deny,别指望默认值。

deny 还有一个容易被忽略的副作用。index.ts 里的 disabled()visibleTools() 会检查某个权限的最后匹配规则是不是 pattern 为 "*"deny,是的话直接把对应工具从给模型的工具表里摘掉。所以整刀砍掉一个工具,模型是看不见它的;而带具体 pattern 的 deny 只是运行时拦截,工具照样在列表里,模型仍会尝试调用然后吃拒绝。这两种写法在 token 消耗和模型行为上不一样。

至于弹窗的三个选项,reply 的实现值得看一眼:选 reject 时,它不只拒绝当前这一条,还会把同一会话里其它挂起的请求一并拒掉;选 always 时,被批准的模式会被追加进内存里的 approved 数组,然后回头把同会话中已经能被放行的挂起请求一起放过。approved 是进程内状态,不落盘,所以 always 的作用域就是当前这次会话——这点和写进配置文件的规则完全不同。


三、shell 执行线:解析、提取、再询问

这是三条线里最危险的一条,也是它做工最细的一条。实现在 packages/opencode/src/tool/shell.ts

流程是这样的:拿到命令字符串后,先用 tree-sitter 解析成语法树(bash 和 PowerShell 两套 wasm 语法,按你实际使用的 shell 选),然后 collect 遍历树里所有 command 节点,做两件事——把可能碰到文件的参数解析成绝对路径、把命令原文和一个”命令前缀通配”收集起来。前者用来判断是否跳出了工作区,跳出了就发 external_directory 询问;后者用来发工具本身的权限询问。

有三处细节直接决定了暴露面的大小。

路径扫描是按命令名白名单做的。 代码里维护了 FILESCMD_FILES 两个集合,装的是 rmcpmvmkdirtouchchmodchowncatcd 这类命令,以及 PowerShell 与 cmd 下的对应写法。只有命令名落在集合里,它才会去解析这条命令的路径参数。换句话说,用一个不在集合里的命令去碰工作区外的路径,走不到 external_directory 那道询问上。

动态拼出来的路径直接跳过。 dynamic() 判定:参数以左括号形式开头,或者含 $(${、反引号,就认为它是运行时才能确定的;bash 侧只要出现 $ 就算,PowerShell 侧则是除 $env: 之外的任何 $ 都算。命中之后,这条参数的路径解析直接放弃。这是个合理的工程取舍——静态解析确实算不出变量的值——但它同时意味着,一条把路径藏在变量里的命令,路径检查形同虚设。

点一次 always,批准的范围比你想的大。 收集时写的是 BashArity.prefix(tokens).join(" ") + " *",其中 prefix 来自 packages/opencode/src/permission/arity.ts,那是一张命令前缀词典:rm 的 arity 是 1,git 是 2,npm run 是 3。所以对一条 rm 命令点 always,进入 approved 的模式是 rm *——本次会话里所有 rm 全部放行;对 git checkout main 点 always,放行的是 git checkout *。这个粒度设计得挺清楚,但你得知道自己按下去的是什么。

执行环节还有两点会影响你。子进程的环境是 { ...process.env, ...extra.env },也就是把你当前 shell 的全部环境变量原样传下去,插件通过 shell.env 钩子还能再往里加。这意味着一条 env 或者 printenv 就能把你环境里的所有密钥读进模型上下文。另外 stdin 是 ignore,交互式命令拿不到输入,只会一路挂到默认两分钟超时被杀掉,然后给模型一条提示让它加大 timeout 重试。

自动批准这一侧,--auto 会把不是显式 deny 的请求全部自动通过,CLI 里对这个选项的描述原文带着 (dangerous!),还有两个隐藏别名效果相同。反过来,非交互的 opencode run 如果--auto,遇到权限请求是自动拒绝的,终端会打印一行 auto-rejecting。在 CI 里跑却什么都没发生,多半就是撞上了这个分支。


四、出网线与凭据线

出网这块,opencode 没有做统一的出站白名单,能出去的口子分散在几处,你得一处处认。

最大的一处是模型服务商本身。代码内容、文件内容、shell 输出都会作为上下文发出去,而 SECURITY.md 明确把”发给你所配置的 LLM 服务商的数据”划到范围外,交由对方的政策管辖。它给的可控手段是策略:experimental.policies 支持 provider.use 这个动作,按 provider ID 或通配符 allow/deny。这套策略有一条设计得很实在的规则——全局配置里的策略优先于项目配置,防止一个仓库把你全局禁掉的服务商重新打开。想只留一家,就先 deny * 再 allow 那一家。各家服务商的数据处理规则不同且会调整,以官方最新说明为准。

webfetch 工具只接受 http://https:// 开头的地址,取内容前会按 webfetch 权限询问。但注意它发起询问时带的参数:patterns 是具体那个 URL,always 却是 ["*"]。所以对某一个网址点了 always,本次会话里任何 URL 都不再问了。websearch 走外部搜索服务,具体走哪家由环境变量或会话 ID 决定,其中一家的凭据从 PARALLEL_API_KEY 环境变量读。

会话分享是另一条常被忽略的出网口。文档写得很清楚:分享会把会话历史同步到他们的服务器,生成 opncd.ai/s/<share-id> 形式的公开链接,任何拿到链接的人都能看,直到你显式取消分享。默认是手动模式,但配置里可以设成 auto。团队场景下最稳的做法是在项目的 opencode.json 里把 share 设成 "disabled" 并提交进 Git,这样整个仓库的人都关掉。

MCP 是第三条。SECURITY.md 把外部 MCP 服务端的行为整体划在信任边界之外,而 MCP 工具一旦加载就和内置工具并列暴露给模型。你启用一个 MCP,就是在自己的信任域里接了一段别人的代码。

企业网络那边它支持标准代理环境变量和自定义 CA:HTTPS_PROXYHTTP_PROXYNO_PROXYNODE_EXTRA_CA_CERTS。有个必须做的动作——TUI 是通过本地 HTTP 服务端和后端通信的,NO_PROXY 必须放行 localhost,127.0.0.1,否则会绕成路由环。

凭据这条线相对集中。packages/opencode/src/auth/index.ts 把 provider 凭据统一放在数据目录下的 auth.json 里,写入时带 0o600 权限位;结构区分 OAuth(含 refresh、access、过期时间)、API key、以及 wellknown 三类。另外有一个 OPENCODE_AUTH_CONTENT 环境变量,设了就直接从环境里读 JSON,绕过文件——在容器和 CI 里很方便,同时也意味着这个变量的值和进程环境一样敏感,而前面说过,shell 工具会把整个进程环境交给子命令。

服务端模式的凭据在 OPENCODE_SERVER_PASSWORD,用户名默认是 opencode,可用 OPENCODE_SERVER_USERNAME 覆盖。opencode serve 默认监听 127.0.0.1,端口 4096,--cors 可以追加允许的浏览器来源。把 hostname 改成对外可达却不设密码,等于把一个能在你机器上执行命令的接口挂到网上。

零件位置对照表:

组成部分它负责什么仓库位置你什么时候会碰到它
权限求值函数双维通配匹配、最后一条匹配的规则胜出、无匹配兜底 askpackages/opencode/src/permission/index.tsevaluate.ts 只是再导出)permission 配置、排查规则为什么没生效时
默认规则集* 放行,doom_loopexternal_directory 询问,read.env 收紧packages/opencode/src/agent/agent.ts你一条配置都没写的时候
shell 工具语法树解析命令、提取路径与命令前缀、发起询问、spawn 子进程packages/opencode/src/tool/shell.ts每一次让它跑命令
命令前缀词典决定点 always 之后被白名单化的命令前缀有多宽packages/opencode/src/permission/arity.ts你按下 always 那一刻
外部目录守卫判断目标路径是否在工作区外,是则按 external_directory 询问packages/opencode/src/tool/external-directory.ts让它读写工作区之外的文件
凭据存储provider 凭据落 auth.json,0600 权限位,可被环境变量整体覆盖packages/opencode/src/auth/index.ts登录服务商之后、在 CI 里注入凭据时
webfetch限定 http/https,取内容前按 URL 询问packages/opencode/src/tool/webfetch.ts让它去读一个网页
provider 策略用 allow/deny 控制哪些模型服务商可用,全局优先于项目packages/web/src/content/docs/policies.mdx要把代码限制在指定服务商时

packages/opencode/src/tool/ 下一共 25 个 .ts,但文件名和权限键不是一一对应的,这是配置时最容易写空的地方。有两处必须记住:一是执行命令的工具文件叫 shell.ts,它向权限系统申报的键却是 bash,你在配置里写 shell 这个键,一条规则都不会生效;二是 editwriteapply_patch 三个文件共用 edit 一个键,想一刀砍掉写入能力,只写 editdeny 就够,不必分别去堵。剩下的 readglobgreptaskskilllspwebfetchwebsearch 才是文件名与键同名的。权限文档里那份键清单是唯一准的口径,配置之前对着它核一遍,比对着目录猜靠谱。


五、边界与代价:这个设计放弃了什么

不做沙箱换来的是顺手。一个跑在你终端里、直接用你的用户身份、继承你全部环境变量、读写你真实工作区的 Agent,装起来没有任何前置条件,第一分钟就能干活。代价是三件很具体的事。

误删误改没有回滚兜底。 权限系统能在 rm 执行前问你一句,但你点了 always 之后整个会话的 rm 都不再问;而且它删的是你的真实文件,不是副本里的。工作区之外的路径有 external_directory 那道询问,工作区之内的破坏是默认放行的。

私有代码的外泄面等于上下文面。 它读进上下文的东西都会发给模型服务商,而这一层被明确划在项目责任之外。分享功能再叠一层——同步到第三方服务器、公开链接、不主动取消就一直在。

权限系统不抗对抗。 它的定位是提醒,不是防御。路径扫描按命令名白名单做、动态拼接的路径跳过检查,这些在”帮你看清正常操作”的场景下够用,但都不是为了扛住刻意规避设计的。仓库里的表述一直是一致的:需要真隔离就上容器或虚拟机。

什么场景下不该只靠它:机器上存着与当前任务无关的生产凭据、要在能触达生产环境的跳板机上用、要让它处理来源不可信的仓库或网页内容、要在共享账号的机器上跑。这几种情况下,缺的那一层不在它的代码里,在你的编排里——具体做法可以参考工作区隔离最小权限设计这两篇的思路。


六、上手与避坑清单

别把默认配置当安全配置。 会踩是因为文档里”权限系统”四个字容易让人以为默认是收着的,实际默认集第一条就是 * 放行。怎么避:装完先写一份显式的 permission 配置,把 "*": "ask" 放第一行,再按需往后加 allow,顺序反了等于没写。

别指望默认拦住 .env 会踩是因为文档默认值一节写的是 deny,代码里的默认集给的是 ask,你一路回车就过去了。怎么避:自己在配置里对 read 显式写 .env 相关的 deny,改完随手起一个会话让它读一次,确认真的被拦。

点 always 之前先看它要白名单化什么。 会踩是因为弹窗上批准的不是这一条命令,而是按 arity 词典算出来的命令前缀加通配。怎么避:涉及 rmgit push、任何写操作时一律点 once;确实高频的只读命令(比如 git status)才用 always,而且要意识到 always 只在本次会话有效,真想长期放行就写进配置。

webfetch 的 always 是全通配。 会踩是因为它发起询问时 patterns 是具体 URL,always 却是 *,看着像”以后这个站不问了”,实际是”以后所有站都不问了”。怎么避:webfetch 一律 once;需要长期放开就在配置里对 webfetch 写具体的 URL 模式。

在 CI 里 opencode run 静默失败。 会踩是因为不带 --auto 的非交互运行遇到权限请求会自动拒绝,任务看起来跑了但什么都没改。怎么避:CI 场景要么显式加 --auto 并且同时deny 规则把危险动作钉死,要么把权限收敛到全 allow 的只读子集,两种都行,模糊状态最坑。

跑在代理后面忘了放行本地回环。 会踩是因为 TUI 和本地 HTTP 服务端之间也走 HTTP,代理会把它绕回去。怎么避:NO_PROXY 里带上 localhost,127.0.0.1,自定义 CA 走 NODE_EXTRA_CA_CERTS

服务端模式对外暴露却没设密码。 会踩是因为它默认只听本地,一旦你为了远程访问改了 hostname,很容易忘掉认证那一步,而不设密码时它只给一条警告照样启动。怎么避:改 hostname 和设 OPENCODE_SERVER_PASSWORD 当成一件事做;这一层的安全责任文档里已经明确写归你。

团队里没有统一关掉分享。 会踩是因为分享是每个人各自的配置项,一个人设成 auto,整个项目的会话就在往外走。怎么避:在项目根的 opencode.json 里把 share 设成 "disabled" 并提交进版本库。

接 MCP 等于扩信任域。 会踩是因为 MCP 工具加载后和内置工具混在一起,权限键也只有一个笼统的入口,而它的行为被明确划在项目信任边界之外。怎么避:只接自己能读源码或自己部署的 server,接之前先在一个空仓库里跑一遍看它到底调了什么。


收束:三个问题问自己

opencode 的边界画得算诚实——不做沙箱这句话写在威胁模型第一段,范围外清单一条条列出来,没有含糊其辞。麻烦的地方从来不是它藏了什么,而是很多人只看了权限弹窗就以为安全这件事已经有人替自己做了。

装之前问自己三个问题:这台机器上有没有与当前任务无关的凭据,有的话为什么不换个容器跑;我的 permission 配置里第一条是不是 * 放行;这个仓库的代码发给模型服务商,公司层面有没有人同意过。三个都答得上来,再打开它。

接下来该读哪个文件:先读 SECURITY.md(很短,五分钟),再读 packages/web/src/content/docs/permissions.mdx 的权限键清单和默认值两节,最后把 packages/opencode/src/agent/agent.ts 里构造默认规则集那几十行对着文档看一遍——文档和代码对不上的地方,以你实际跑的那个版本的代码为准。想进一步收紧权限模型的思路,可以接着看权限抬得太高之后怎么收

本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 把开源终端 Agent opencode 铺给团队:策略层锁得住什么、锁不住什么开源终端编码 Agent opencode:什么时候别用它干活

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。