PR #102:给 eyrie 仓库装上一整套护栏
EyrieAI/eyrie · ci-constraints-hardening(origin/main...688dbda)· 2026-07-05 · 自包含,读完即弃
写法说明:这个 PR 几乎全是「规则」而不是「功能」,所以本文不走旅程式叙事,改为把每条约束逐条摆开——守什么、为什么、挂在哪一层、被拦了怎么办。代码片段取自分支真实文件(经裁剪),青色斜体注释为解读所加,灰色斜体是源码原注释。每张卡片右上角的彩色小标签标注该守卫挂在哪一层(第 2 节有分层地图)。
1TL;DR
起点是 6 月底一次对整个仓库的「该加哪些 CI 约束」审计:把所有现有门禁抓不住、但一旦发生就很疼的劣化路径列了一遍(每条都在当时的代码里找到了实证)。这个 PR 分两轮把其中高置信的条目全部做成机械守卫——不靠 review 时人眼盯、不靠自觉,靠脚本在 commit 和 CI 上直接拦。
两轮合计 18 条约束:Round 1 侧重「已经疼过或差点疼」的高危项(native 依赖构建、Electron 安全基线、死代码、主题 token 打错字),Round 2 补齐中低危的架构边界、供应链和跨平台项。每个守卫脚本都带自己的测试,且测试覆盖两条臂:违例真的被抓到 + 干净的树不误报。
顺手带出一个大件:zod 从 3.25 全仓统一升到 4.4.3,由此解开了 knip 版本被钉死的死结(第 5 节,写 schema 的同学必读)。
2门禁分层:什么时候被谁拦
先记住一件事:仓库的门禁分快慢两层,快层每次 commit 都跑,慢层攒到 verify / CI 再跑。这个 PR 往两层各塞了新东西,还把三个太慢的守卫从快层挪去了慢层——所以 pre-commit 的体感不会变慢。
| 层 | 什么时候跑 | 这个 PR 之后包含什么 |
|---|---|---|
| check 快层 | 每次 git commit(pre-commit)+ CI |
11 个脚本:包管理器锁、工具链版本钉死*、workspace 脚本*、.gitattributes*、英文-only(扩容*)、注释覆盖、包边界(扩容*)、密钥扫描、i18n、theme-tokens*、CSP*(* = 本 PR 新增/扩展) |
| check:ci 慢层 | bun run verify 开头 + CI |
trusted-deps、knip 死码门禁、boundaries 金丝雀(都是本 PR 新增,太慢所以不进 pre-commit) |
| lint / typecheck | verify + CI(pre-commit 跑 staged 文件的 lint + 全量 typecheck) | eslint 新规则(no-new-func*、幻影依赖*)、biome 新规则(禁 skip 测试*)、tsc 覆盖面扩大(config.ts*) |
| workflow 自身 | GitHub Actions 运行时 | 最小权限块*、action 大版本*、bun 精确 pin*、loopback fail-closed 环境变量* |
CI 跑 bun run check && bun run verify 两条命令,所以四层最终都会在 CI 上过一遍;pre-commit 是它的本地子集(check + staged lint + typecheck + 全量测试,不含 build 和慢层)。
3Round 1 守卫(7 条)
Round 1 挑的都是「已经真实疼过、或者审计时发现一改就炸」的高危项。
守什么:bun 有个安全设计——只有列在根 package.json 的 trustedDependencies 里的包,安装时才允许跑 install/postinstall 钩子。native 模块(node-pty、better-sqlite3 这种带 C++ 编译的)恰恰靠这个钩子编译出 .node 二进制。忘了上白名单 = 钩子被静默跳过 = 二进制缺失 = require() 时崩。最阴的是:你本机有预编译缓存所以全绿,只有需要从源码编译的 ubuntu CI 会红——node-pty 就这么炸过一次。
怎么守:脚本走一遍 node_modules(含嵌套和 @scope),识别所有 install 钩子会触发 native 编译的包(node-gyp / prebuild-install / cmake-js / napi 等模式),逐个核对是否都在白名单里,缺谁报谁。
package.json 的 trustedDependencies 数组,重新 bun install。守什么:Electron 窗口的 webPreferences 是渲染进程的「牢门」——contextIsolation: false、nodeIntegration: true、sandbox: false 任何一个被翻转,页面里的 XSS 就能直接摸到 Node API,升级成本机代码执行。以前只有一个「配置对象长这样」的测试,既抓不住值被翻转,也抓不住新加一个危险 key(webviewTag、enableRemoteModule 这类)。
怎么守:对 apps/desktop/src/main/ 下的源码做文本级匹配——翻转值(如 sandbox: false)和危险 key(无论什么值)出现即红。文本匹配的好处是不依赖配置对象怎么组装。
守什么:describe.skip / it.skip 是「暂时关掉回头再修」的标准起手式,然后就没有回头了——被 skip 的测试在报告里显示为跳过、不算失败,腐烂得无声无息。
怎么守:biome 的 noSkippedTests 升为 error。注意运行时条件跳过仍然合法:describe.skipIf(条件) 这种按环境降级的写法(比如绑不了 loopback 的机器跳过集成套件)不受影响,禁的只是写死的 .skip。
skipIf(环境条件)。真有必须永久 skip 的场景,用 biome-ignore 注释并写清理由。守什么:daemon 集成测试要在 127.0.0.1 上起真服务。测试 harness 的原设计是「绑不了 loopback 就跳过这批测试」——对开发机是贴心降级,对 CI 是灾难:一台网络配置有问题的 CI 机器会静默跳过 29 个集成测试然后报绿,没人知道少测了。
怎么守:CI 的 verify 步骤设 EYRIE_REQUIRE_LOOPBACK=1;这个变量存在时,绑不上 loopback 不再是「跳过」而是直接抛错红掉。本地不设这个变量,降级行为保持不变。
守什么:渲染层的分层规则(shared → entities → features → app 单向依赖)由 eslint-plugin-boundaries 强制。但这个插件有个致命特性:它得先把 import 语句解析成真实文件才能判层,解析器一坏(比如 @/ 别名配置失效),所有边界规则会静默全部通过——不是报错,是假绿。
怎么守:像煤矿里带金丝雀。脚本往真实的 entities/task 目录里丢一个故意违反分层的 fixture 文件(entity import feature),跑 eslint,断言它真的报错,然后删掉 fixture。金丝雀不叫 = 守卫已死 = CI 红。
// An entity importing a feature — the dependency direction the layer rule forbids.
const fixtureSource = `import { Board } from '${illegalImport}'\n…` // illegalImport = '@/features/tasks'
// 同时踩中 resolver 的两个易坏点:@/ 别名解析 + .tsx 解析——任何一个失效金丝雀都会“不叫”
eslint.config.js 里 boundaries 的 resolver 段。已知小坑:金丝雀运行中被强杀(超时/Ctrl-C)会留下孤儿 fixture boundaries-canary-*.tsx,之后 lint 突然报 entities/task 下有违例——删掉那个文件即可。守什么:knip 是死代码探测器——从每个 workspace 的入口出发做可达性分析,找出没人 import 的文件和 package.json 里声明了却没人用的依赖。此前它只是「可以手动跑跑看」的软信号,现在 knip --include files,dependencies 进了门禁:死文件 = 红,未使用依赖 = 红。落地当天就顺手删掉了一批死掉的终端/shell 模块和 7 个僵尸依赖。
怎么守:注意门禁只收了 files 和 dependencies 两类;「未使用的 export / 类型」不在门禁里——barrel 桶文件的 re-export 天然噪音太大,等清理完 barrel 再说。
knip.json 给对应 workspace 补 entry。别无脑加 ignore。守什么:渲染层颜色统一走 rgb(var(--color-x)),token 的真相源是 theme.ts。CSS 变量有个恶心的特性:引用一个不存在的变量不报任何错,颜色直接渲染成透明。这个守卫的起因就是真实事故——两处写成了 var(--ink-faint)(正确是 --color-ink-faint),UI 上那块颜色悄悄消失,build/lint/test 全绿。
怎么守:两条规则。renderer 里出现的每个 var(--…) 必须能在 theme.ts 的 tokenCssVars 或 tokens.css 里找到定义;.css 文件里不许出现字面色值(hex/rgb 裸写——eslint 的同类禁令只覆盖 .ts/.tsx,这里补上 .css 的洞)。那两处事故本体也在本 PR 里修掉了。
4Round 2 守卫(11 条)
Round 2 补齐审计里的中低危项:架构边界、依赖卫生、供应链和跨平台。前两条是分量最重的。
守什么:@eyrie/db 里是 Drizzle 表结构和从它推导的数据库行类型(ProjectRow 这类)。这些类型的形状跟着数据库 schema 走,一旦被 CLI、desktop 或 api/client 包直接 import,以后每次改表结构,整条调用链都会跟着抖。架构上早就规定「DB 类型只住 daemon,外面用 @eyrie/api 的 DTO」,但此前没有任何机械检查。
怎么守:boundaries 脚本加一条 import 规则(源码里 import @eyrie/db 即红)+ 一条 manifest 规则(非 daemon app 的 dependencies 里出现它也红)。同一个 commit 里还把「app 源码不许 import @eyrie/daemon」的老规则从写死的 cli/desktop 路径泛化成任意 apps/* 目录自动生效——将来加 apps/web 不需要回来改守卫:
// Matches source dirs of every app except the daemon, so boundary rules cover future apps
// (apps/web, apps/mobile, …) without editing this file again.
const isNonDaemonAppSource = (path) =>
path.startsWith('apps/') && !path.startsWith('apps/daemon/') && path.includes('/src/')
@eyrie/api 找对应 DTO;没有就加一个 DTO,而不是把 DB 类型漏出来。守什么:monorepo 有个隐形陷阱叫「幻影依赖」——你 import 一个自己 package.json 里没声明的包,恰好因为别的包依赖它、被 hoist(提升安装)到了根 node_modules,于是能跑。直到某天那个「别人」不再依赖它,你的代码原地爆炸,而且没人改过你一行代码。对将来要开源、包边界要自描述的仓库,这是必须堵的洞。
怎么守:每个 import 必须声明在自己 workspace 的 manifest 或根 manifest 里。两个精心设计的豁免(是正确语义,不是开后门):测试/config/scripts 文件可以用根上声明的共享 dev 工具(vitest 等 183 处测试 import 因此合法——它们永不进出厂产物);apps/desktop 整体放宽 devDependencies——electron-vite 把 app 全量打包,electron 本体从 src import devDep 是 Electron 生态的标准姿势。没声明在任何地方的包,在哪都会红。
// Discovered at config load so a new workspace inherits the phantom-import rule automatically.
const workspaceDirs = ['apps', 'packages'].flatMap((group) =>
readdirSync(resolve(import.meta.dirname, group), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => `${group}/${entry.name}`),
) // 每个 workspace 一个规则块:packageDir = [根, 本 workspace],新 workspace 自动继承
落地时全仓扫描零违例——manifests 比预想干净(比如 packages/db 对 drizzle-orm 用的是 peerDependencies,正是库的标准姿势,规则默认放行 peer)。
dependencies(运行时用)或根 devDependencies(纯 dev 工具)。别扩豁免。守什么:biome 早就禁了 eval(),但 new Function('x', 'return x') 是功能完全等价的绕路——同样把运行时字符串变成可执行代码,在 Electron 的 main/preload 信任边界里就是现成的远程代码执行入口。当前全仓零使用,规则是纯预防。
守什么:desktop 渲染页的 CSP(内容安全策略,写在 index.html 的 meta 里)是 XSS 和「XSS 升级成本机代码执行」之间的最后一道墙。已有测试只检查「CSP 这块还在」,往 script-src 里加 'unsafe-eval' 或 'unsafe-inline'(两大放水关键词)可以一路绿过。
怎么守:脚本解析 meta 里的策略,断言 script-src 存在、且不含任何 'unsafe-*'。注意只打 script-src——style-src 里的 'unsafe-inline' 是 Tailwind 注入样式需要的,合法保留。
守什么:审计抓到一个现行漂移:本地 packageManager 精确钉在 bun@1.3.14,CI 却写着 bun-version: 1.3.x 浮动——bun 出个有行为差异的 patch,就会出现「本地绿、CI 红」且极难排查的日子。同理,engines.node 以后升了大版本,没人会记得回来改 workflow 里的 node-version。
怎么守:ci.yml 的 bun 版本改成精确 1.3.14(漂移当场修掉);新守卫脚本断言所有 workflow 的 bun-version 恰好等于 packageManager 的值(不许范围),所有 node-version 的大版本等于 engines.node 的大版本。
package.json 的 packageManager 和 ci.yml 的 bun-version,两处不一致就过不了 commit。守什么:全仓 typecheck 是 bun --filter '*' typecheck 挨个 workspace 调用的。这个命令对没声明 typecheck 脚本的 workspace 静默跳过、照样 exit 0——新建一个 package 忘写这个脚本,它的代码就永远不过 tsc,而所有门禁常绿。
怎么守:凡是 git 里有 .ts/.tsx 源码的 workspace,package.json 必须声明 typecheck。没有源码的配置包(packages/tsconfig)自然豁免——判定标准是「有没有 tracked 源码」而不是写死的名单。(原计划连 test 脚本一起强制,落地时实证推翻:本仓测试由根 vitest 单配置全局 glob 发现,新 workspace 的测试自动被收集,不存在静默跳过的洞,强制反而会逼出三个没人调用的死脚本——所以只守 typecheck。)
"typecheck": "tsc --noEmit"(和一个 tsconfig)。守什么:tailwind.config.ts 和 drizzle.config.ts 此前不在任何 tsconfig 的 include 里——tsc 从来没看过它们,里面写出类型错误没有任何工具会发现(eslint 对 config 文件也关了类型感知)。
怎么守:各自加进邻近项目的 include。顺手拆了一颗小雷:packages/db 的 tsconfig 原来写着 rootDir: "src",在 noEmit 模式下它只剩「限制文件必须在 src 下」这一个作用,恰好挡住 config 进来,删掉了。
守什么:仓库将来开源,源码注释、字符串必须英文(i18n 语言包除外),有专门的 CJK 扫描闸。但闸的扫描范围按扩展名来,漏了 .sql(Drizzle 迁移文件)和 .example(env 示例)——中文注释可以从这两种文件溜进去。改动是正则里加两个扩展名。
守什么:* text=auto eol=lf 这一行是跨平台协作的地基:没有它,Windows checkout 会把文件换行改写成 CRLF,而 .husky/ 下的钩子是 sh 脚本——sh 遇到 \r 直接罢工,整条本地钩子链在 Windows 上静默失效(顺带所有文件的 diff 被换行符污染)。
怎么守:断言文件存在且全局 LF 规则完好(属性顺序、空白宽容;*.bat 保 CRLF 的窄规则不受影响)。
*.ps1 eol=crlf),别动全局行。守什么:GitHub Actions 的 workflow 不写 permissions: 块时,拿到的 GITHUB_TOKEN 是默认宽权限——CI 只做 checkout + 跑本地脚本,多出来的权限纯属攻击面(供应链攻击拿到 workflow 上下文时能干的事)。其他 workflow 早已有权限块,只剩 ci.yml 裸奔。
怎么守:顶层 permissions: contents: read,两个 job 都只需要读。
守什么:checkout@v4 / setup-node@v4 跑在 GitHub 正在弃用的 Node 20 action 运行时上,CI 日志一直刷弃用警告。升到 v5(迁到 Node 24 的最小大版本,刻意不冲最新的 v7/v6,减小行为变更面)。setup-bun@v2 已是最新不动。独立成一个 commit,靠 push 后 CI 全绿完成验证——已验证通过。
5zod 4 + knip 6:写 schema 的同学看这里
背景:knip 一直被钉死在 5.64.0——因为 5.65 起 knip 内部要用 zod 4,而产品全局 override 钉着 zod 3.25.76,两者在同一棵依赖树里打架。原方案是给 knip 子树做嵌套 override 的隔离实验;拍板改成更彻底的路线:全项目统一一个 zod 版本,直接升 4。于是 zod 3.25.76 → 4.4.3(根 override + api + daemon 三处),knip 顺势解钉到 6.24.0,长期债清偿。
产品代码只有 api 和 daemon 的 9 个文件用 zod,迁移量很小,但有几条 zod 4 的写法变化从此对所有人生效:
| 以前(zod 3) | 现在(zod 4) | 性质 |
|---|---|---|
z.record(值schema) | z.record(z.string(), 值schema) | 硬破坏,单参数直接编译不过 |
z.ZodIssueCode.custom | 字面量 'custom' | 硬破坏,ZodIssueCode 没了 |
z.string().uuid() | z.uuid() | 旧写法弃用仍能跑,新代码用新式 |
z.string().datetime() | z.iso.datetime() | 同上 |
z.nativeEnum(枚举对象) | z.enum(枚举对象) | 同上,z.enum 现在直接吃对象 |
issue code invalid_string | 改名 invalid_format | 校验错误码变了(见下) |
issue.path 是 (string|number)[] | 变成 PropertyKey[](可含 symbol) | 错误信封两处已做归一化,业务代码无感 |
validationCode(错误码),别断言 reason 的具体文案——本次迁移 1833 个测试只有 1 个挂,挂的就是把 code 写死成旧名的那个。
knip 5→6 的可见变化:分析更聪明,会把已经不需要的 ignore 配置和冗余 entry 报成 hints——本 PR 顺手清掉,当前 check:knip 是 0 findings 0 hints 的干净基线,请保持。
6「我被拦了」速查表
换位速查:按你看到的报错找行动。前四行是最可能日常撞上的。
| 你看到的 | 拦你的是 | 怎么办 |
|---|---|---|
eslint:'xxx' should be listed in the project's dependencies | 幻影依赖 R2-② | 在你所在 workspace 的 package.json 声明它;纯 dev 工具声明到根 |
boundaries:only the daemon may import @eyrie/db / must not import @eyrie/daemon | 包边界 R2-① | 改用 @eyrie/api 的 DTO / @eyrie/client;缺就去 api 包加 |
| knip:unused file / unused dependency | knip R1-⑥ | 真死就删;误报给 knip.json 补 entry |
| 测试断言 zod 报错文案失败 | zod 4 迁移 | 断言 validationCode,别断言文案字符串 |
Native-build dependencies missing from root trustedDependencies | trusted-deps R1-① | 包名加进根 package.json 的 trustedDependencies |
missing a "typecheck" script | workspace 脚本 R2-⑥ | 新 workspace 加 "typecheck": "tsc --noEmit" |
Toolchain pins out of sync | 版本钉死 R2-⑤ | workflow 里 bun/node 版本改到和 package.json 一致(升级要两处一起改) |
CJK characters found in non-i18n files(含 .sql/.example) | 英文-only R2-⑧ | 改英文;用户可见文案进 i18n locale |
CSP:script-src contains 'unsafe-…' | CSP R2-④ | 撤掉,找不依赖 eval/inline 的方案 |
eslint:The Function constructor is eval | no-new-func R2-③ | 重写,用查表/策略对象替代动态代码 |
theme-tokens:var(--xxx) 未定义 / .css 有字面色 | theme-tokens R1-⑦ | 对照 theme.ts 改 token 名;新颜色先加 token |
| biome:noSkippedTests | 禁 skip R1-③ | 修好 / 删掉 / 改 skipIf(环境) |
| webPreferences 基线报错 | Electron 基线 R1-② | 别翻转安全开关,先来讨论 |
| .gitattributes 报缺全局规则 | eol=lf R2-⑨ | 把 * text=auto eol=lf 加回去 |
| 本地 lint 突然报 entities/task 下 boundaries 违例 | 金丝雀孤儿 | 删掉残留的 boundaries-canary-*.tsx(上次运行被强杀留下的) |
| CI 里 boundaries-canary 步骤挂 | 金丝雀 R1-⑤ | 分层 lint 的 resolver 坏了,查 eslint.config.js 的 resolver 配置 |
7心智模型补丁
8没做的 & 可放心略过
刻意不做的(都有明确理由,别顺手补)
- 枚举三处手抄的 parity 检查——db 的 CHECK 约束、daemon 的常量、api 的 DTO 三处手抄同一批枚举。正解是重写成单一真相源(业务重构),做守卫反而固化坏形态。
- action 按 SHA 钉死——防 tag 劫持,但需要 dependabot 配套维护,单独一轮再决策(本次只升了版本号)。
- dependabot / CODEOWNERS——开源前不提前加 OSS 脚手架,单人阶段是纯噪音。
- knip 的 exports/types 升硬门禁——barrel re-export 噪音太大,先剪 barrel。
读 diff 时可放心略过
bun.lock(≈140 行)——zod/knip 升级的机械产物。apps/desktop的大块删除——knip 落地时清掉的死终端/shell 模块(其中 terminal 部分后来被 #101 复活,merge 时已撤回对应删除;最终留下的删除都是确认的死码)。- 三个
Merge origin/maincommit——跟 main 上 #101/#103/#104/#108/#111 的机械同步,唯一手工冲突决策是一个死文件(旧 shell/TabStrip)保留删除。 - 所有
*.test.mjs(566 行)——守卫自己的测试,模式统一:违例 fixture 被抓 + 真实仓库树不误报。
9覆盖声明
全量 diff(51 文件,+1700/−258)逐文件精读,无抽样:Round 2 的 12 个工作 commit 为本人实现;Round 1 的 7 个守卫逐个亲读了脚本源码与配置(trusted-deps / boundaries / canary / theme-tokens / biome / loopback harness / knip.json),并对照了两轮执行记录。文中每段代码引自分支真实文件。本 PR 已通过 ubuntu CI 全部检查(check 11 步 + verify 全链 + commit-messages),处于可合并状态。