refactor(kernel): 删 CapabilityProvider.getHookHandlers,hook 的否决权只属于用户 - #51
Merged
fujiwarazz merged 11 commits intoAug 31, 2026
Merged
Conversation
## 为什么删 hook 的 `deny` 一票否决之所以成立,是因为 hooks.json 由**用户自己**写。 `getHookHandlers()` 让插件也能行使这份否决权——「装一个数据分析插件」就默认授权 它拦截所有工具调用。这在信任模型上是错的,不是「多一个可选能力」。 插件想在工具前后插手,走 PluginModule.mounts() 的 tool:before:它能短路,但结果 显示为一个工具返回值,不伪装成用户的权限拒绝。 ## 替代注入路径:KernelOptions.hooks hook 现在有且只有两条来路,都代表用户: - ~/.helios/hooks.json + <workDir>/.helios/hooks.json(用户写的外部命令) - KernelOptions.hooks(宿主 CLI/Electron 代表用户在进程内注册)⚠️ 上一轮我说 getHookHandlers「零实现」是查错了——grep 限定在 packages/*/src/, 漏了 test/。生产代码确实零使用,但三个 fixture 靠它做进程内注入,所以删它必须先 有替代路径。hookCaptureCapability 一个工具都不供,它当初被塞进 manifest 纯粹是 为了借道注册 hook,现在退回成一组纯绑定 hookCaptureBindings.ts,不再是插件。 ## 护栏:这条边界坏掉零症状 多注册一个 hook 不会让任何现有断言变红,所以 test/hook-authority.test.ts 两层都做: - 结构:契约里没有 getHookHandlers;activateProvider 里没有 hooks.register; 两条合法来路都在 start() 里 - 行为:mockCapabilityRogueHooks 这个「越权插件」在自己对象上留了 getHookHandlers 想 deny,工具照常执行 只有结构护栏挡不住「换个方法名重新接上」,所以行为那条是必需的。 三处变异全部命中:kernel 重新去调 / 契约加回声明 / 宿主那条路被摘掉。⚠️ 结构断言第一版假红:`not.toContain("getHookHandlers")` 撞上了解释「为什么没有 它」的那句注释。改成先 stripComments 再断言。 eval set 30 → 33:新增授权来源混淆类(src/auth/permit.ts:isAdmin 读请求体自称的 payload.role 而非会话已验证的 session.role、插件用 descriptor.skipAudit 自行豁免 审计、多来源决策用「最后一个胜出」合并导致 allow 覆盖 deny)。代码全都能跑、权限 检查也真的在跑,错在这份权力是谁给的——正是本轮改的那件事。 typecheck 退出码 0;904 passed(+5)。 真机 eval:readonly 28/33,七个专项各 3/3,写路径全绿。 ## 一处上轮的猜测要收回 上轮报告里我怀疑 derived-agent workflow 用例的「路径越界」是 macOS /var → /private/var 符号链接导致 WorkDirGuard 误判。本轮同一用例又失败一次,但失败形态 完全不同:模型把工具调用的 DSML 标记当纯文本吐了出来。两次都重跑即过。 所以那是 deepseek-v4-flash-0731-ali 在这个多 agent workflow 上的退化, 不是 pathGuard 的 bug——之前的符号链接推测无证据,撤回。
## 为什么 模型此前只看到一堆带前缀的工具名(lsp__definition / cron__schedule),看不出它们 成组、也看不出这组解决什么问题。单个工具的 description 表达不了**跨工具**的那层知识: 什么时候该用这一组、有没有顺序约定、领域术语是什么。 CapabilityProvider 新增必填的 description,拼成一节 <extensions> 放进冻结的 system 前缀,位置在 project_context 与 env 之间——它和 env 一样是「这个运行时的事实」, 而 env 压轴靠近对话。 ## EXTENSION.md 包根若有 EXTENSION.md,其 frontmatter 的 description 覆盖代码里的字段(改措辞 / 本地化不必改代码)。包根 = 从入口文件往上最近的含 package.json 的目录。 **正文 v1 不读**,且这是刻意的:每装一个插件就往冻结前缀里常驻一段正文,token 与 信噪比都吃不消。按需展开是 skill 那套机制要解决的问题,不是这里。capability-fs 带了 一份真实的 EXTENSION.md,同时把它自己现存的三个硬伤写在正文里备查。 ## 两处边界 - description 为空的不进清单——内建六件套与派生 agent 三件套就是这样排除的, 它们不是 extension,工具本来就直接可见。 -⚠️ 插件是动态 import 进来的,TS 校验不到。按旧契约编译的包没有 description, loader 兜 undefined 降级成不进清单 + warn。这条不是假想:改完第一次跑测试就崩在 plugin-dispose 的 fixture 上。 ## 护栏 test/extension-listing.test.ts(13 例)。这一节是模型可见文本,坏了不会有任何测试 自然变红——工具照常能调、对话照常能跑。六处变异全部命中:空 description 也进清单 / 空列表吐空标签 / 清单没拼进前缀 / 清单排到 env 之后 / EXTENSION.md 覆盖被忽略 / 不再兜住旧插件。⚠️ 「EXTENSION.md 覆盖被忽略」第一轮**漏网**:我只单测了 readExtensionDoc,解析函数 正确而 loader 没调它照样全绿。补了 fixtures/extpkg/ 这个带 package.json 与 EXTENSION.md 的真实 fixture 包走完整链路才有牙。 ## 真机对照(这一步改模型可见文本,必须做)⚠️ live eval 的 manifest 没有 extensions 段,那 14 个用例**完全没跑到这次的代码**。 所以另用仓库真实 helios.config.json 起 Kernel 实跑一轮,模型的推理链里直接写着 "System prompt extensions section lists only caps extension package", 并据此正确作答——确认它读到了这一节且措辞取自 EXTENSION.md 而非代码字段。 typecheck 退出码 0;917 passed(+13)。 live 全绿:readonly 35/36,七个专项各 3/3,派生 agent 4/4。 eval set 33 → 36:新增模型可见文本静默退化类(src/prompt/toolCatalog.ts:描述由模板 拼成的废话、空列表仍吐空标签、Date.now() 拼进要复用的前缀砸掉 prompt cache)。 这一类的代码全都能跑、测试也全绿,只有把拼出来的字符串当成产物去读才看得见。 ## codemod 又踩了同一个坑(第三、四次) 给各实现补 description 的脚本两次把字段插进了 Tool 而不是 CapabilityProvider—— 两者字面量形状一样都有 name,而 `CapabilityProvider[^=]*=\s*\{` 会从 import 行一路 跨到 `const echoTool: Tool = {`。第三版改成先定位类型标注紧跟 `= {` 的那一行、 再取其后最近的 name,并在插入后打印「紧随哪一行」自检。
…ility 的分工
## 为什么改名
前三个 PR 把词汇全线换成了 "extension"(manifest 的 extensions 段、EXTENSION.md、
<extensions> 节、LoadedExtension),唯独类型还叫 CapabilityProvider——这是那几轮
留下的不一致。而且 getHookHandlers 删掉之后,"Provider" 这半截不再指代任何东西。
## 顺手钉清一个含义,否则 Capability 与 extensions 段仍然对不上
**extension 是部署单元**(manifest 里那一行、你装的那个包),
**capability 是它带进来的运行时对象**。和 Port(接口)与 adapter(包)是同一种关系。
所以 EXTENSION.md 属于包、Capability.description 属于对象,
LoadedExtension { capability, description } 读起来才成立。
这也解释了为什么 builtin 六件套与 agentspec 三件套实现了 Capability 却不是 extension:
它们没有 manifest 条目、没有包,只是同一个运行时形状。
## 改动
- CapabilityProvider → Capability(101 处)
- CAPABILITY_PROVIDER_API_VERSION → CAPABILITY_API_VERSION(37 处)
- builtinCapabilityProvider → builtinCapability(4 处)
- Kernel.activateProvider → activateCapability(7 处)
- LoadedExtension.provider → .capability
- kernel/src/builtin/provider.ts → builtin/capability.ts
包名(cap-lsp / cap-cron / cap-mcp / capability-fs,另 36 个文件)**不在本 PR**:
capability-fs 在下一步会被重写成 AgentSkill 生产者,那时一次改到位,
现在改等于改两次名,且包名改动要动 package.json / 所有 manifest 与 import,
和类型改名混在一个 PR 里 diff 会爆。
## 补一个真实缺口(不是改名带来的)
extension-listing 原有的 EXTENSION.md 覆盖用例,fixture 是**对象字面量**——
所以就算有人把 loader 改回 `{ ...capability, description }`(丢原型那个 bug),
它照样全绿。而 cap-lsp / cap-cron 都是 class,方法在原型上。
新增 fixtures/extclass/:class 形态 + EXTENSION.md 覆盖,断言它的 extclass__ping
真的注册进了 ToolRegistry。两处变异均命中(写回实例展开 / 忽略 EXTENSION.md 覆盖)。
## 纯 token 替换留下的三处语义问题,已逐个修
改名脚本全用精确 token 全词替换(不写正则跨界匹配——前四次 codemod 就栽在这上面),
但替换本身正确不代表**周围的话**还成立:
- docs/[IP]loop-decoupling.md 里那段「三个 capability 撞车」是历史记录,
被替换后变成用新名字描述旧状态。恢复原名 + 加一条后续修订说明。
- docs/code-organization.md 「为什么不叫 capabilities/」的论据原本是
「已经有 CapabilityProvider 和 cap-* 两个」,改名后要重写才通顺。
- builtin/capability.ts 的注释还在说「与用户自写 provider 走相同路径」。
typecheck 退出码 0;918 passed(+1)。
live 全绿:readonly 35/38,七个专项各 3/3,派生 agent 4/4。
eval set 36 → 38:新增对象身份 / 原型丢失类(src/registry/decorate.ts:
`{ ...plugin, label }` 对 class 实例丢原型方法、复制后用 indexOf 去重引用不再相等)。
TS 拦不住——展开后的类型仍然满足接口,只有运行到那个方法才炸。正是本轮避开的坑。
skill 是一个**目录**:SKILL.md 是说明书,旁边可以躺 scripts/*.{mjs,py}。模型读说明书、
用 Bash 跑脚本,脚本本身永不进上下文——这是它与 <extensions> 清单的分工:清单常驻一句话,
skill 按需展开一整页。
## 三来源汇合,渲染只有一个出口
| 来源 | 谁扫 | 读法 |
|---|---|---|
| 用户级 ~/.helios/skills/*/SKILL.md | kernel(skills/scan.ts) | node fs 直读(目录在 workDir 外,过 WorkDirGuard 必被拒;与 hooks.json、全局 AGENTS.md 同一条先例) |
| 项目级 <workDir>/.helios/skills/*/SKILL.md | @helios/cap-skills 的 getSkills() | FileSystemPort |
| extension 自带 | 各 capability 的 getSkills() | 生产者自己说了算 |
AgentSkill 因此带一个 load() 闭包而不是一个路径:**谁能读什么由生产者决定**。
渲染成工具只有 kernel/src/skills/tools.ts 的 toSkillTools 一个出口——曾经各来源各拼各的,
base directory 有的告诉有的不告诉,模型行为随来源而飘。
## 修掉三个硬伤
原 capability-fs 有三处让 skill 事实上不可用:
1. 工具 description 是模板拼的(`加载 skill「x」的完整指引内容`),等于没说,
模型无从判断该调哪个 → 改用 frontmatter 里作者写的那句,缺失即跳过并 warn
(不用目录名兜一个:凑出来的描述等于挂了个永远不会被正确选中的工具)
2. 加载时不告诉 base directory,SKILL.md 里 `运行 scripts/analyze.py` 无处可解
→ 输出前置一行 `Base directory for this skill: <abs>`
3. 扫的是 .helios/policies/*/SKILL.md —— 这是上一轮全局改名(3a8b90d)误伤的
用户可见路径,与 kernel/src/policies/(内置挂载策略)毫无关系 → 改回 .helios/skills/
## 其它
- 同名冲突**后来者不覆盖 + warn**,不再静默覆盖(valos nameMap.set 的教训)
- skill 工具名豁免命名空间前缀(名字是作者起的)。推论:base prompt 讲 skill 那条
不能靠命名规律——原来写的是「名为 caps__* 的工具」,现在改成只靠 description 描述
- SKILL.md 与 EXTENSION.md 共用 ports 的 parseFrontmatter,消掉两份必然漂移的正则
- 合成的 skills capability description 为空,故不进 <extensions> 清单:
它不是装出来的包,是 kernel 内部的汇合点
- capability-fs → cap-skills(包名 @helios/cap-skills),它不再自己渲染工具
## 验证
- 939 passed(新增 test/agent-skills.test.ts 18 例 + p1-impls 的 cap-skills 4 例)
- 变异验证 13/13 命中(不告诉 base dir / 模板描述 / 同名覆盖 / 目录名兜底 /
递归两层 / 不扫用户级 / getSkills 抛错不兜 / 加前缀 / 进清单 / 退回靠前缀讲 skill /
扫回 policies / baseDir 用相对路径 / readExtensionDoc 忽略 frontmatter)
- 真机验证 test/live/skills.verify.live.test.ts:用**真实 helios.config.json** 起 Kernel,
模型第一步就主动调了 release-notes(没自己发明步骤),拿到 base directory,
据此跑通了 scripts/collect.mjs。真机变异同样命中。
⚠️ 第一版断言查的是全部 tool_result,被模型顺手跑的 Glob 满足了,变异逃逸过一次;
改成只看 skill 工具自己的返回值(tool_use.id ↔ tool_result.toolUseId 配对)才抓住。
附带发现:base directory 不是能力强的模型的硬需求(它会 Glob 找回来),省的是轮次。
- eval set 38 → 41,新增第九类「按需资源加载」(missing-base-dir /
silent-source-override / fabricated-description),配套新场景 loader-context-review
命中 3/41;readonly-review 39/41
CLI 真机(本轮该跑没跑,补跑后逮到)暴露:只前置一行 `Base directory for this skill: <abs>` **不足以**让模型把正文里的相对路径挂到那个目录上。 它把 `scripts/collect.mjs` 当成 cwd 相对(Bash 的 cwd 是 workDir),第一个 Bash 直接失败, 然后靠 Glob 满仓找路。**3/3 次稳定复现**,而单测与 live 测试全绿。 模型需要的不是一个事实,是一条**解析规则**,并且要点明 shell 命令得先 cd。 A/B(同一提示词,各 3 次,deepseek-v4-pro-0813-ali): | | BASE(只报路径) | FIX(+解析规则) | |---|---|---| | 首个 Bash | 3/3 失败 | 3/3 成功 | | turn 数 | 9 / 5 / 5 | 3 / 3 / 3 | | 输入 token | 39.0k / 20.4k / 20.3k | 11.5k ×3 | | 成本 | $.0024 / $.0013 / $.0013 | $.0008 / $.0007 / $.0007 | ## 为什么原来的测试全绿 两层护栏各差一点,合起来正好放过这个缺陷: - 单测只断言输出里**含有那个路径**——含,绿。 - live 只断言脚本**最终跑通了**——模型靠 Glob 兜回来了,绿。 两条都补上:单测断言解析规则那两句在(`resolves against that directory` / `not your current working directory` / `cd <baseDir>`);live 新增 `failedToolNames(out)` 应为空,直接编码「**一次就解对**,而不只是最终做成」。 变异:M14「退回只报一个路径」、M15「说了基准但不提 cd」单测均命中; M14 真机也命中(`expected [ 'Bash' ] to deeply equal []`)。 940 passed,typecheck / vitest / live 退出码均为 0。
两个装配根都靠最后一行 `for (const bundle of opts.pluginMounts ?? []) register(bundle)` 保证这条,有实现、有注释、**零测试**。`pluginMounts` 此前只在 run-assembly.test.ts 里 被用来验作用域校验(注册到错误 scope 抛错),没有任何断言管顺序。 ## 坏了会零症状,但后果分两档 | 挂载点语义 | 顺序错了会怎样 | |---|---| | `concat`(context:prepare / session:start) | 拼接顺序**模型可见** → 「装了哪个插件」决定提示词长什么样 | | `firstWin`(context:compact / tool:before / turn:end) | 插件排前面**整个顶掉内置** → 压缩该发生时不发生,上下文涨到 413 | `test/unit/policies/pluginMountOrder.test.ts`(8 例):结构层(describe() 的相对位置) + 行为层(concat 真拼出来的顺序、firstWin 下插件根本没被调用)。 四处变异全命中:run 级挪到最前 / 插到内置中间 / 会话级挪到 compaction 前 / splitPluginMounts 反转。 ## 写这类护栏踩的三个坑(都写进注释了) 1.⚠️ **顺序断言最容易写成空断言。** 插件与内置**没挂在同一个挂载点上**时, 「插件排在最后」恒成立,顺序怎么改都绿。第一版会话级让插件挂 session:start、 内置只挂 context:compact,两者无交集 → **变异 M3 实测逃逸**。改用 context:compact 做对照才有内容。run 级同理:不传 consumeNotifications 时 context:prepare 上 一个内置都没有(那道 `length > 1` 防空断言当场抓住了第一版)。 2.⚠️ **`dispatch` 把 handler 异常 warn 后跳过**,所以「内置赢了」与「内置炸了、插件顶上」 在返回值上分不出来。第一版 payload 写错,内置在 `p.path.length` 抛 TypeError 被吞, 于是赢的是插件——而 `expect(out).toBeDefined()` 照样通过。断言 firstWin 必须同时收 warn。 3.⚠️ **fixture 上 `as unknown as MountBundle` 就是自己关掉类型检查。** 第一版加了, 于是把 `run` 写成 `handler` 时 TS 一声不响,跑起来 mount.run 是 undefined → TypeError → 被静默吞掉 → 表现成「返回值是 undefined」,排查一轮才定位。去掉 cast 后 TS 立刻又抓出 `memFs().fs`(实际叫 `port`)。 ## 刻意不做:`enforce: pre/post` 现在没有任何插件需要排到内置之前。为假想需求新增 API 表面的代价是永久的。 文档写清:将来若真要,抄 Vite 两档桶,不要抄 VSCode 数字优先级(必然退化成 `order: 999999` 军备竞赛)。 ## 验证 - typecheck / vitest 退出码均 0,**948 passed** - 变异 4/4 命中 - eval set 41 → 44,新增第十类「注册顺序即语义」(extension-before-builtin / firstwin-hijacked / handler-error-swallowed,落在新文件 `src/hooks/registry.ts`)。 这一类的判据不在被审代码上,在**消费端怎么用这个数组**:同一个顺序错误对 join 型 消费者是「外部看到的文本变了」,对 firstWin 型是「内置根本不执行」,差一个数量级。 新场景 `registration-order-review` 命中 3/44。 - CLI 真机:3 turn、零失败调用,与上一轮 FIX 基线一致。 (本轮无 src 改动,按规则可省;仍跑了一遍。) 顺带观察:`apps/cli/test/configDiscovery.e2e.test.ts` 在全量并发下撞 5s 超时, 单跑 975ms 通过。它起 pnpm 子进程,5s 预算对机器负载太敏感——非本轮引入,记一笔。
补跑 live **全套**(此前只跑了新增的那一个场景)时逮到: `derived-agent.live.test.ts` 把 `deepseek-v4-flash-0731-ali` 放在候选首位, 而 `code-review.eval.live` 与 `skills.verify.live` 都是 pro 优先——**全仓只有这一个文件 跑在最不稳的模型上**。 它已经三次以不同形态假红:一次「路径越界」、两次模型把 DSML 工具调用标记当纯文本吐出来 (`<|DSML|invoke name="file_read">` 直接进了 output)。此前两次我都当成随机噪声重跑过去了, 这次才去看候选列表,发现是稳定的配置差异。 判据:本文件断言的是**代码属性**(数据沿 workflow DAG 流动、不产生悬空 tool_use), 不是模型能力。让它跑在最不可靠的模型上,等于让它因为与被测内容无关的原因变红—— 一个只在 30% 的时候变红的护栏,实际效果是训练人忽略它。 flash 的耗时方差本身也大到不像在省时间:同一用例实测 23.6s vs 142.8s。 换 pro 后整个文件 4/4 通过,workflow 那例 66s。 ## 验证(三层全绿,退出码均为 0) - typecheck 0 - 单测 948 passed - live **全套** 19 passed / 4 files(此前是 18 passed + 1 failed) ## 顺带记一笔 我这轮往 `evalProject.ts` 的 FILES 里加了 `src/hooks/registry.ts`,那是**所有 eval 场景 共享的被审工程**,等于动了每个场景的输入。当时只跑了新场景(10 skipped)就汇报"跑了回归", 是不完整的——共享 fixture 的改动必须跑全套。
test(kernel): 补「插件挂载恒定排在内置之后」护栏(欠了四轮)
feat(ports,kernel): AgentSkill 契约 + 三来源汇合,capability-fs 重写为 cap-skills
refactor(ports): CapabilityProvider → Capability,钉清 extension 与 capability 的分工
feat(kernel): 已装 extension 清单进 system 前缀,支持 EXTENSION.md 声明式描述
fujiwarazz
merged commit Aug 31, 2026
e5533f0
into
refactor/manifest-ports-extensions
1 check passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
refactor(kernel): 删 CapabilityProvider.getHookHandlers,hook 的否决权只属于用户
为什么删
hook 的
deny一票否决之所以成立,是因为 hooks.json 由用户自己写。getHookHandlers()让插件也能行使这份否决权——「装一个数据分析插件」就默认授权它拦截所有工具调用。这在信任模型上是错的,不是「多一个可选能力」。
插件想在工具前后插手,走 PluginModule.mounts() 的 tool:before:它能短路,但结果
显示为一个工具返回值,不伪装成用户的权限拒绝。
替代注入路径:KernelOptions.hooks
hook 现在有且只有两条来路,都代表用户:
漏了 test/。生产代码确实零使用,但三个 fixture 靠它做进程内注入,所以删它必须先
有替代路径。hookCaptureCapability 一个工具都不供,它当初被塞进 manifest 纯粹是
为了借道注册 hook,现在退回成一组纯绑定 hookCaptureBindings.ts,不再是插件。
护栏:这条边界坏掉零症状
多注册一个 hook 不会让任何现有断言变红,所以 test/hook-authority.test.ts 两层都做:
两条合法来路都在 start() 里
想 deny,工具照常执行
只有结构护栏挡不住「换个方法名重新接上」,所以行为那条是必需的。
三处变异全部命中:kernel 重新去调 / 契约加回声明 / 宿主那条路被摘掉。
not.toContain("getHookHandlers")撞上了解释「为什么没有它」的那句注释。改成先 stripComments 再断言。
eval set 30 → 33:新增授权来源混淆类(src/auth/permit.ts:isAdmin 读请求体自称的
payload.role 而非会话已验证的 session.role、插件用 descriptor.skipAudit 自行豁免
审计、多来源决策用「最后一个胜出」合并导致 allow 覆盖 deny)。代码全都能跑、权限
检查也真的在跑,错在这份权力是谁给的——正是本轮改的那件事。
typecheck 退出码 0;904 passed(+5)。
真机 eval:readonly 28/33,七个专项各 3/3,写路径全绿。
一处上轮的猜测要收回
上轮报告里我怀疑 derived-agent workflow 用例的「路径越界」是 macOS /var →
/private/var 符号链接导致 WorkDirGuard 误判。本轮同一用例又失败一次,但失败形态
完全不同:模型把工具调用的 DSML 标记当纯文本吐了出来。两次都重跑即过。
所以那是 deepseek-v4-flash-0731-ali 在这个多 agent workflow 上的退化,
不是 pathGuard 的 bug——之前的符号链接推测无证据,撤回。