Skip to content

🔍 AI 深度代码审查 2026-08-12 #5

Description

@devcxl

🔍 AI 深度代码审查日报 2026-08-12

仓库: devcxl/opencode-spec
新发现: 8 个

由 AI 全面阅读代码后整理(架构 + 安全),已报告过的问题不会重复出现。

🏛️ 架构评估

该项目是一个 OpenCode 插件,把 OpenSpec 风格的 spec-driven 开发工作流(propose → apply → archive)以纯运行时注入方式接入 OpenCode:通过 config hook 注册 12 个 slash command 和 12 个 skill,通过 experimental.chat.messages.transform 在首条用户消息前注入 bootstrap 提示。核心逻辑全部放在 assets/skills/ 下的纯 Node 参考脚本中(无任何外部 CLI 依赖,仅用 node:fs + 自研 YAML 子集解析器),插件本体 src/plugin/ 只负责目录复制、路径占位符替换(.opencode/skills/ → 临时目录)与命令/技能注册。技术栈为 TypeScript (ESM) + @opencode-ai/plugin,无运行时依赖(仅 yaml 包,实际代码中未使用,主要靠自研 parseSimpleDocument)。注意:插件没有任何 GitHub 集成(仅有仓库自身的发布 CI),也没有文件监听式的"变更检测"——变更状态全部在调用时按文件存在性即时计算。

模块划分总体清晰:server.ts(钩子编排)、commands.ts(frontmatter 解析+注册)、skills.ts(临时目录复制+清理)、prompts.ts(bootstrap 加载)、util/paths.ts(目录常量)、assets/skills/_shared/references/openspec.js(领域逻辑单一实现)。值得肯定的是:所有变更名在落盘前都经过 slugify[^a-z0-9]+ 替换为 -),从根源上堵死了通过变更名做路径穿越的入口;错误处理采用 runJsonCli 统一 try/catch + JSON 错误输出 + exit 1,模式一致;CI(npm-publish/build-verify/create-release-tag)权限收敛为最小化、发布前校验 tag 与 package.json 版本一致且提交在默认分支链上、npm publish --ignore-scripts,供应链卫生良好。

主要架构问题是状态管理_openspecDir_bootstrapCache_commandsCache_packageRoot/_projectDir 全部是模块级单例,且插件会反向写入全局 process.env.OPENSPEC_DIR(供子进程脚本读取)。一旦 OpenCode 在单进程内承载多个会话/worktree(代码中已使用 ctx.worktree,暗示支持多工作区),后初始化的实例会覆盖前者状态,process.env 的副作用还会泄漏到所有子进程。其次是双份领域逻辑src/util/paths.tsassets/skills/_shared/references/openspec.js 重复实现了 slugify/validateSlug/openspecRoot/changeDir/datedArchiveChangeDir 等一整套函数,而 grep 证实 src 版本除 setOpenspecDir/getOpenspecDir 外全是死代码——仓库自带的 issue-2 brief 也印证了这类重复导致的"硬编码路径"漂移 bug。

安全面最值得关注的是信任边界:插件把仓库内(可能是克隆来的不可信)文件直接注入模型上下文且赋予较高权威——<project>/.opencode/opencode-spec/prompts/bootstrap.md 可覆盖内置 bootstrap 并注入每条会话首条消息(带 EXTREMELY_IMPORTANT 标记);<project>/.opencode/opencode-spec/templates/*.md 可覆盖 artifact 模板并被 skill 指示为"输出文件必须采用的结构";spec 内容经 getArtifactInstructions 全文注入提示词。SKILL.md 中针对 context/operationGuidance 写了较完善的防注入护栏(冲突报告、保留控制值),但对 bootstrap/模板这两条路径没有同等防护。文件系统层面,OPENSPEC_DIR/directory 配置无路径包含校验(../ 可逃逸项目根),spec 发现

发现

[MEDIUM][安全] OPENSPEC_DIR / directory 配置无路径包含校验,可把写入导向项目根之外

说明: 位置: src/plugin/server.ts:16-23, 38-44; assets/skills/_shared/references/openspec.js:518
说明: applyDirectoryCandidate 和 env 分支把用户配置(opencode.jsonplugin[1].directoryopenspec.directory,均常随仓库提交)原样写入 process.env.OPENSPEC_DIR;参考脚本 openspecRoot() 直接 path.join(projectDir, process.env.OPENSPEC_DIR || "openspec")。若配置为 ../../.. 等含 .. 的路径,createChangeScaffold/syncChangeSpecs/archiveChange 的全部写操作(proposal.md、specs、归档目录)都会逃逸到项目根之外。由于 opencode.json 是仓库的一部分,克隆不可信仓库即可能触发。
建议: 在 setOpenspecDir/applyDirectoryCandidate 处校验:拒绝绝对路径与含 .. 段(或 resolve 后断言仍在 projectDir 内),并对 env 值做同样的包含性校验后再写入。

[MEDIUM][安全] spec 发现/同步逻辑跟随符号链接,恶意仓库可把任意文件内容读入上下文并复制进仓库

说明: 位置: assets/skills/_shared/references/openspec.js:479-497, 698-709, 1091-1114
说明: listFilesRecursive 用 dirent 类型判断(symlink 的 isDirectory 为 false,会被当作普通文件返回),matchOutputPath/syncChangeSpecs 随后 readFile 跟随链接。git 可跟踪指向仓库外文件的 symlink(如 specs/x.md -> ~/.ssh/id_rsa/etc/hosts),sync 时其内容会被写入 openspec/specs/<slug>/ 并全文进入模型上下文。SKILL.md 对 retire 场景已写明"不跟随 symlink",但代码层未做任何校验。
建议: 遍历与读取前用 lstat 检查,拒绝/跳过符号链接;或 realpath 后断言目标仍在 change 目录内。

[MEDIUM][安全] 仓库可控文件可覆盖 bootstrap 与模板,以高权威注入提示词

说明: 位置: src/plugin/prompts.ts:19-31; src/plugin/server.ts:91-101; assets/skills/_shared/references/openspec.js:668-685, 849-894
说明: loadPrompt("bootstrap") 优先读 <project>/.opencode/opencode-spec/prompts/bootstrap.md,该内容被包在 EXTREMELY_IMPORTANT 标记中注入每条会话的首条用户消息;getTemplate 优先读仓库内 .opencode/opencode-spec/templates/*.md,并被 propose/ff-change 等 skill 指示为"输出文件必须采用的结构"。这些文件随仓库分发,克隆不可信仓库即可让攻击者控制高优先级指令;且 server.ts:98 的去重检查仅靠 includes("EXTREMELY_IMPORTANT"),任何文本部分含该字符串即整体跳过注入(用户粘贴仓库文件内容即可无意识触发)。
建议: 将覆盖来源限定为显式用户配置而非仓库文件(或对覆盖文件内容做"不可信数据"包裹与降权声明),并改用结构化标记(如 <bootstrap> 独立 part 类型)做去重,而不是在文本中搜关键字。

[MEDIUM][架构] 模块级全局状态 + process.env 副作用,多会话/多工作区下状态互相覆盖

说明: 位置: src/plugin/server.ts:38-44; src/util/paths.ts:7-14; src/plugin/prompts.ts:6-12; src/plugin/commands.ts:41; src/plugin/skills.ts:5-11
说明: _openspecDir/_bootstrapCache/_commandsCache/_packageRoot/_projectDir 均为模块单例,插件工厂返回后没有任何实例级状态;第二个会话(或同一进程内的另一次插件初始化)会静默覆盖第一个会话的目录、提示词与命令缓存。process.env.OPENSPEC_DIR 的全局写入还会影响同进程其他插件与所有子进程,且无恢复机制。这与代码中 ctx.worktree 所暗示的多工作区支持相矛盾。
建议: 把状态收敛到工厂闭包/类实例中,按 worktree 隔离;子进程脚本改为显式传参(如 --openspec-dir)而非读共享 env。

[MEDIUM][架构] 领域逻辑双份实现:src/util/paths.ts 与 assets openspec.js 重复且 src 侧基本为死代码

说明: 位置: src/util/paths.ts:32-166; assets/skills/_shared/references/openspec.js:280-572
说明: slugify/validateSlug/toRelativePath/openspecRoot/changesRoot/archiveRoot/specsRoot/changeDir/proposalPath/datedArchiveChangeDir/ensureOpenSpecStructure 等整套函数在 TS src 与 JS assets 中各实现一份;grep 证实运行时 src 侧仅 setOpenspecDir/getOpenspecDir 被引用,其余全是死代码。两处逻辑一旦漂移(仓库自身 docs/2026-06-13-issue-2 即记录了历史漂移 bug),行为将不一致且无编译期保障。
建议: 以 assets/skills/_shared/references/openspec.js 为唯一实现(或反向用 TS 生成),src 侧删除重复定义,并为 slugify/validateSlug 补充跨实现一致性测试。

[LOW][架构] 临时目录清理不可靠:exit 处理器中的异步 rm 不会完成,且重复初始化导致 /tmp 泄漏

说明: 位置: src/plugin/skills.ts:7-11
说明: process.once("exit", ...) 内调用异步 rm() 但既不 await 也不阻塞退出——Node 的 'exit' 事件触发后事件循环不再处理 pending promise,清理实际从不执行;同时每次插件初始化(热重载、多实例)都会新建一个 mkdtemp 并加入 _tempDirs,只增不减。
建议: 改用 rmSync(在 'exit' 中同步清理),或注册 SIGINT/SIGTERM/beforeExit 处理器并记录上次启动遗留目录做启动时兜底清理。

[LOW][架构] 依赖实验性 hook 与消息结构内部细节,OpenCode 版本升级易静默失效

说明: 位置: src/plugin/server.ts:91-101; src/plugin.ts:10-12
说明: 提示注入依赖 experimental.chat.messages.transformoutput.messages[].info.role/parts[].type 的具体结构;该 API 尚在 experimental 阶段,且插件无任何运行时版本探测或降级路径。若 OpenCode 调整消息结构(如 role 字段位置变化),插件会在无报错的情况下停止注入,用户无法感知。
建议: 为注入路径增加可观测性(如注入成功时打 debug 日志/计数),并在 package.json 的 peer 范围约束支持的 OpenCode 版本。

[LOW][架构] config hook 对 skills.paths 的形状假设无防御,用户配置可致插件初始化崩溃

说明: 位置: src/plugin/server.ts:67-71
说明: config.skills.paths = config.skills.paths || [] 后直接调用 config.skills.paths.includes(...),未校验其为数组。若用户 opencode.json 中 skills.paths 被定义为字符串或对象(配置 schema 允许的形态),会在插件配置阶段抛 TypeError,进而影响整个 OpenCode 配置加载。
建议: 增加 Array.isArray 守卫(非数组时重置为 [] 或跳过注入),并容忍 config.skills/config.command 的非对象值。

(未报告项说明:变更名路径穿越——已由 slugify 全链路防护,无真实漏洞;CI 工作流——权限收敛、tag 校验、--ignore-scripts 均到位,无实质问题;src 侧无任何 child_process/eval 调用,不存在命令注入点。)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions