开源项目 OpenWork 怎么保质量:一条评测正路加一道只问安全的闸门
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
OpenWork 这套自查机制真正值钱的地方,不是它测得多全、审得多细,而是它在两个方向上都做了「收窄」:端到端覆盖只允许写在一个地方,安全评审只允许回答一个问题。 这里说的 OpenWork 指 GitHub 上的 different-ai/openwork —— 一个把技能、MCP 连接与外部服务打包成可共享「能力」的开源桌面应用,跟中文语境里泛指的「开放工作」无关,也跟同名的职场点评网站没有任何关系。它的 AGENTS.md 里对自己的定义是「一个用于代理式工作的实用控制面」,运行本地与远程的代理工作流,底层由 OpenCode 驱动。
站内已经有几篇挨着的文章:Superpowers 的代码评审流程讲的是一整套通用评审技能怎么组织,AI 代码安全审计怎么做讲的是安全审计这件事本身的方法论,Agent 评测方法讲的是评测体系该怎么设计;这一篇不重复它们,只钉住 OpenWork 这一个真实仓库,看它把上面那些东西落成了哪几个具体文件、哪几行强制约束。
先把底盘说清楚,方便你判断这套做法的适用规模。全仓 3490 个受版本控制的文件,apps/ 下 4 个应用、packages/ 下 12 个包,企业目录 ee/apps/ 10 个、ee/packages/ 3 个;packages/docs/ 有 57 份 mdx(其中 model-context-protocol/ 占 10 份),架构文档 docs/ 20 份 md,evals/ 顶层还有 26 份流程 md;服务端 apps/server/src/ 顶层就有 138 个 .ts 文件;packaging/ 提供三种分发方式。这不是玩具仓库,所以它的流程约束才有参考价值。
许可证要说准,别被「开源」两个字带偏。根目录 LICENSE 写的是分层授权:/ee 目录下的全部内容按 ee/LICENSE 的条款授权(根 LICENSE 里称其为 Fair Source License,而 ee/LICENSE 自身的抬头是 Functional Source License, Version 1.1, MIT Future License,缩写 FSL-1.1-MIT);所有并入的第三方组件各随其原始许可证;以上之外的部分才是 MIT(Copyright 2026 Different AI)。所以「OpenWork 是 MIT 开源项目」这句话是不准确的。能不能商用、怎么用,一律以许可证原文为准,本文不提供法律意见。
一、第一刀:端到端覆盖只留一条路
evals/README.md 的第一句就把话说死了:所有新的可执行端到端覆盖都放在 specs/**/*.test.ts,并且从 @openwork/testkit 导入 test;驱动 Electron、Den 或其它应用界面的规格文件用 .slow.test.ts 命名。紧接着一句更关键——flows/ 下的旧语料是「冻结的兼容性覆盖,不是一条写作路径」。
仓库里同时存在两代东西。老的一代是 evals/flows/ 下的大量 .flow.mjs 文件,配 evals/runner/ 里的自研运行器;新的一代是 evals/specs/ 下的 vitest 规格文件,配 evals/packages/ 下九个可独立消费的包。绝大多数项目走到这一步的做法是:写一段「今后请用新写法」的文档,然后两代长期并存下去。
OpenWork 把冻结写成三条可判定的规则:删除一个过时 flow 是允许的;新增、修改、复制、重命名、脚手架生成一个 flow 是禁止的;用户提出新覆盖需求时一律去 evals/specs 和 @openwork/testkit。旧 flow 坏了或过时了,「报告这个限制,而不是去改它」。
关键在于规则接上了 CI。.github/workflows/ci-no-new-eval-flows.yml 这个叫「No New Legacy Eval Flows」的 workflow 会对 PR 跑一次 git diff --name-status,凡是落在 evals/flows/** 且状态不是 D(删除)的改动,一律收集成 violations 然后 exit 1;重命名和复制被 --find-renames --find-copies-harder 一并抓住,两侧路径都检查。报错信息兼作路标:
The legacy eval flow corpus is frozen; only deletions under evals/flows/** are allowed.
Put new executable end-to-end coverage in evals/specs/**/*.test.ts and import test from @openwork/testkit.
为什么这一刀值得学:人和代理写测试时的默认行为是「找一个最近的例子照着抄」。只要旧路径还能提交,它就会被抄。文档没有约束力,红叉才有。而且这条 CI 规则的判定成本极低——它不理解测试内容,只看文件状态和路径前缀,几乎不可能误伤。
二、验收契约:什么才算「过了」
收窄了写作路径之后,第二件事是把「通过」的定义写死。evals/README.md 的 authoring contract 一节列了几条硬约束:资源按依赖顺序获取,needs() → server() → app();每一个等待都必须有上界;所有外部依赖都要在 needs() 里声明,缺依赖时以一个具名理由跳过,而不是超时或悄悄降级;断言落在用户可见的行为和可观察的结果上,后端、文件、进程层面的检查可以佐证副作用,但不能替代那条用户路径;涉及身份或权限边界时,正反两面都要断言。
最后一条在 evals/specs/skill-grant-access.test.ts 里落得很实:先验证创建者能搜索到并执行自己创建的技能能力;再验证一个未被授权的成员既搜不到、执行也拿到 isError 与 forbidden 错误体;然后创建者把访问权授予该成员,重新验证搜索与执行都通了;最后验证被授权者无权把它二次分享给第三个人,期望是 HTTP 403。一条用例把「有权限」「没权限」「授权后」「越权转授」四个方向全钉住了。
判定结果只有三种写法:只有当每一条声明都在证据带(tape)里有一条可观察断言时,才报 Passed;断言失败是 Failed;缺依赖、工具故障、缺证据是 Incomplete 或一个具名跳过。后面跟着一句最有价值的话——一个包含跳过的绿色套件不是证明。.opencode/skills/run-tests/SKILL.md 把它变成了更硬的输出格式要求:报告必须精确写成 passed、failed,或者 skipped — needs: X,跳过必须点名缺了哪个环境依赖。
跑道也是分开的,evals/vitest.config.ts 里定义了两个 project:
{
test: {
...common,
name: "pr",
// Naming convention: *.slow.test.ts drives Electron/Den (the stack lane, run on demand); every other spec must be app-less.
include: ["specs/**/*.test.ts"],
exclude: ["**/*.slow.test.ts"],
},
},
pr 跑道排除掉所有 .slow.test.ts,保证 PR 上跑的那一批不依赖应用进程;stack 跑道则全量包含,测试与钩子超时都放宽到 600 秒,按需触发。evals/ 自己还是一个独立的 pnpm 工作区,README 给的理由是「它的工具链不能影响产品安装或镜像构建」。
三、这套东西由哪些零件组成
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 规格目录 | 新增端到端覆盖的唯一落点 | evals/specs/ | 每次给可运行行为加验证 |
| 测试套件包 | 提供 test 夹具与 needs()/server()/app() 资源 | evals/packages/testkit/ | 写任何一条新规格时 |
| 双跑道配置 | 分开 PR 轻跑道与应用重跑道 | evals/vitest.config.ts | 判断某条用例该在哪条跑道跑 |
| 冻结语料 | 只读的旧世代覆盖,仅允许删除 | evals/flows/、evals/runner/ | 想改旧用例、结果被 CI 拦下时 |
| 冻结守卫 | 对 PR 检查 evals/flows/** 的非删除改动 | .github/workflows/ci-no-new-eval-flows.yml | 误在旧目录动手时 |
| 安全评审提示 | 定义「只报新引入问题」的判据 | .warden/skills/diff-security-review/SKILL.md | 想调评审松紧度时 |
| 评审配置 | 报告阈值、忽略路径、触发条件 | warden.toml | 想加忽略路径或本地先跑一遍时 |
| 分析工作流 | 跑分析并产出结论摘要 artifact | .github/workflows/warden.yml | 看 PR 上的分析结果时 |
| 放行工作流 | 依据摘要授予或撤销安全放行 | .github/workflows/warden-clearance.yml | PR 被自动批准或被撤销批准时 |
四、第二刀:安全评审只问一个问题
warden.toml 开头三行注释把这件事的定位讲完了:安全放行只押在一个问题上——这个 diff 有没有引入新的安全问题;判据见 .warden/skills/diff-security-review,放行动作本身由 .github/workflows/warden-clearance.yml 授予。
.warden/skills/diff-security-review/SKILL.md 里的提示词是核心,写法很克制。它给评审只留了 Read、Grep、Glob 三个工具,然后要求一个问题必须同时满足三个条件才准上报:一是由改动行引入或明显加重,而不是周边代码里本来就有的老问题;二是有具体的安全影响,后面跟着一份明确清单(命令/SQL/代码注入、XSS、SSRF、路径穿越、认证授权绕过、密钥或凭据泄露、不安全反序列化、原型污染、弱加密或弱随机、PII 泄漏、供应链风险,以及一组 Electron 特有的危险写法——开启 nodeIntegration、关掉 contextIsolation 或 sandbox、IPC 处理器信任渲染进程输入去做文件或 shell 操作、把不可信输入喂给 shell.openExternal、在特权窗口里加载远程内容);三是存在一条说得通的攻击路径,攻击者可控的输入真的到得了那个汇聚点,或者密钥真的暴露给了不可信方。
同样长的还有「不要报什么」:风格、性能、正确性、可维护性问题不报;改动之外的老问题即使看见了也不报;没有攻击者可控输入路径的理论弱点不报;本来就缺的加固不报;测试夹具、mock、以及从来不授予真实访问权的假凭据不报。
每条发现要给四样东西:引入问题的确切文件与改动行、攻击路径(谁控制输入、拿到什么)、严重级别(critical 对应 RCE、认证绕过、真实密钥泄露,low 对应这次 diff 引入的纵深防御退化),以及一个落在改动代码上的具体修法。最后一句:如果这个 diff 没有引入新的安全问题,就什么都别报;对干净的 diff 来说,沉默才是正确输出,不要制造发现。
配置层面,warden.toml 把 reportOn 设在 low,也就是低危也进报告;ignorePaths 排掉了 node_modules、pnpm-lock.yaml、dist 和 *.min.js。触发器有两个:一个是 pull_request 上的 opened / synchronize / reopened 且非草稿,一个是 local ——注释说明这是为了让开发者推送前能在未提交的改动上先本地跑一遍。
还有一个设计意图值得单独拎出来。这套评审不是一遍过,defaults.auxiliary 负责核验与合并发现、决定哪些能活到最终报告;配置注释明确说明要让它保持和主评审同一档强模型,因为弱核验会导致错误放行。模型标识本身是会过时的配置值,这里不抄,但「复核环节不能降配」这个意图是可以直接搬的。关于人审与机审各自的位置,站内AI 评审与人工评审的分工那篇讲得更细。
接下来是这套机制里比提示词本身更有工程含量的部分:闸门怎么防止自己被绕过。放行意味着一个自动身份可以在 PR 上盖 APPROVE,那就必须假设有人会想办法骗它。
分析和放行被拆成了两个 workflow。warden.yml 负责跑分析,触发条件是目标分支为 dev 的 PR 的 opened / synchronize / reopened;草稿 PR 和来自 fork 的 PR 直接跳过(注释给的理由是 fork 拿不到 secrets,分析根本跑不起来)。跑完之后它用 jq 写一个 warden-summary.json,只装三个字段:head_sha、findings_count、high_count,作为 artifact 上传。
warden.yml 顶部这条注释,是整个仓库里最值得抄走的一句风险提示:
# Trigger types must stay aligned with the warden.toml trigger actions:
# an event that runs this workflow but matches no warden trigger would
# produce findings_count=0 without analysis and wrongly grant clearance.
翻译过来:workflow 的触发类型必须和 warden.toml 里的触发动作保持一致;一个能启动这个 workflow、却匹配不上任何评审触发器的事件,会在完全没有分析的情况下产出 findings_count=0,从而错误地授予放行。这是典型的「静默假绿」路径:没跑等于零发现,零发现等于放行。注释还补了一句,ready_for_review 是被故意排除的,草稿转正式的 PR 会在下一次推送时被分析。
warden-clearance.yml 用 workflow_run 触发,理由写在注释里:这个文件永远从默认分支执行,所以一个 PR 改不动放行逻辑本身。它在授予放行之前串了四道检查:摘要里的 head_sha 必须等于被分析的 SHA;这个 SHA 必须能关联到一个 PR;PR 当前的 head 必须还是这个 SHA,head 一旦移动就当作过期运行跳过;最后,凡是碰到审查机制本身的 PR 一律不自动放行——
guarded="$(gh api --paginate "repos/$REPO/pulls/$pr/files" --jq '.[].filename' \
| grep -E '^(\.github/|warden\.toml$|\.warden/|\.agents/skills/|\.claude/skills/)' || true)"
命中就判 guarded,交回人审。这道守卫同时让上一步那个 artifact 变得难以伪造:想改分析产物就得改 .github/ 下的文件,而改了就自动失去自动放行资格。
身份这一层也隔开了。放行用的是一个专用 GitHub App,凭据放在名为 warden-clearance 的 environment 里;注释写明这个 App 从不推送提交,所以「最后一次推送需要他人批准」这类规则始终成立——放行身份既不是推送者也不是作者。App 凭据没配置时,整个 workflow 直接空转。
结论只有两种动作:判定为 clear 时,用 App 令牌提交一条 APPROVE 评审,正文里明写「自动放行只满足必需评审门槛,人仍然要看、要合」;判定为 flagged 时,反过来找到这个 App 之前留下的 APPROVED 评审并 dismiss 掉,附言说明最新 diff 上发现了新问题。放行是跟着 commit 走的,不是跟着 PR 走的。
五、边界与代价:这套东西解决不了什么
第一,它明确不是安全审计。提示词里白纸黑字写了「改动之外的老问题即使看见了也不报」,历史债务永远不会被这道闸门捡起来——一个从第一天就存在的注入点,可以在这套流程下活到项目结束。想覆盖存量得另开一次全量审计,那是另一件事。
第二,它只看 diff。跨文件、跨模块组合出来的问题,在改动行的上下文里往往看不出来:你新加的函数本身完全安全,危险的是它和三个文件之外某个既有调用点的组合。这类问题结构上就在视野之外。
第三,模型评审本身带不确定性。同一个 diff 跑两次未必得到同一组结论;「沉默是干净 diff 的正确输出」这条在压低误报的同时,必然把漏报概率抬高。findings_count = 0 的准确含义是「这一次没有找到」,不是「不存在」。通过闸门不等于没有漏洞,这一点不能含糊。
第四,机制层面有几处会静默失灵:触发条件对不齐会产生「没分析却零发现」的假绿(维护者自己在注释里点了名);fork PR 拿不到 secrets 就根本不分析;App 凭据没配置时整个放行 workflow 空转——只是这种失灵是保守方向的,比反过来安全。
第五,评测侧的代价是跳过。应用类规格需要 Electron、Den、本地 MySQL 甚至沙箱,环境凑不齐就会跳过;这也是它反复强调「绿里带跳过不算证明」的原因。这个约束不能靠自觉,得靠输出格式硬性要求,相关取舍在Agent 回归测试那篇里也有讨论。
第六,冻结旧语料有长期成本。旧 flow 坏了只报告不修,等于长期背着一片不可信区域;只是维护者显然算过账,认为两代写法并存的成本更高。
六、可以搬走什么
把上面两套机制的设计意图抽出来,下面几条不依赖 OpenWork 的技术栈,你在自己的项目里今天就能做。
给「新覆盖写在哪」只留一条路,用 CI 把旧路变成红叉。 为什么:写作路径的多样性会直接变成验收标准的多样性,而文档几乎没有约束力——人和代理都倾向照着最近的例子抄。判定规则要做得足够笨,只看路径前缀和文件状态、不理解内容,才不会误伤、才敢一直开着。
把跳过做成一等公民,并强制具名。 为什么:假绿的最大来源不是断言写错,是用例根本没跑。要求输出精确写成 skipped — needs: X,等于把「缺什么」变成必填字段,缺依赖就会可见地暴露,而不是混在一片绿里。
权限和身份相关的验证,正反两面一起断言。 为什么:只验证「有权限的人能做」这一半,等于只测了功能没测边界;真正会出事的是「没权限的人也能做」和「被授权者能不能继续转授」。
让评审只回答一个问题,并把「不该报什么」写得和「该报什么」一样长。 为什么:范围越宽,模型越倾向凑数,人越倾向整体忽略。一份混着风格、性能、安全的报告,实际阅读率会迅速掉到零。
让「什么都不报」成为合法且被鼓励的输出。 为什么:如果流程隐含「你得找出点什么」,模型就会去找。代价是漏报上升,所以这条必须和「通过不等于安全」一起说。
核验环节不许降配。 为什么:如果由一个更弱的环节决定哪些发现能活到最终报告,整条链的实际强度就等于这个最弱环节,省下的是准确率。
改到审查机制本身的变更,一律不给自动放行。 为什么:能改闸门的 PR 就是绕过闸门的最短路径。把 CI 配置、评审配置、评审提示词列进守卫清单,命中就转人审,成本极低。
把放行凭据和被分析的那个 commit 绑死,head 一动就作废。 为什么:否则「先提交干净版本拿到批准,再推一版脏的」就是一条现成的绕法。发现新问题时要主动撤销旧批准,而不是只追加一条评论。
让自动化的身份和推送者分开。 为什么:如果自动批准用的是作者或推送者的身份,它就会顶掉「最后一次推送需要他人批准」这类保护规则,等于用一个自动化把人工门槛拆了。
收尾
OpenWork 这两套机制的共同点很清楚:它们都不试图变得更全面,而是想办法让「过了」这两个字更值钱。评测那边,靠的是砍掉第二条写作路径,再把跳过从沉默变成必须报出来的事实;安全那边,靠的是把评审范围压到一个问题,再花大力气保证这个问题的答案不会被绕过、不会被伪造、不会被过期数据顶替。
值得留意的是投入的分布——安全这块,写提示词的篇幅远不如写防绕过逻辑的篇幅长。判据只是一份 SKILL.md,而围绕它的 SHA 校验、守卫路径、身份隔离和撤销逻辑铺满了两个 workflow。这大概是自动化质量门里最容易被低估的一件事:判断准不准是模型的问题,判断会不会被绕过是你的问题。要看具体实现,直接读 https://github.com/different-ai/openwork 仓库里的 evals/README.md、warden.toml 和 .github/workflows/ 三处,比任何转述都快。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 OpenWork 开源桌面应用怎么在没有外网的环境活下来:三份文档合起来才是一套离网方案 和 OpenWork 桌面应用与 OpenCode 内核:能力分发层的边界。