Skip to main content

知识按需进入上下文,代码先过信任门

同一个工作区里四种会影响运行的对象

假设用户在项目里发出一句话:
用 review 方法检查 src/parser.ts,并把结论记到 review note。
这个任务看起来只有一句话,运行时却会用到四种不同对象。前三种由 roots 发现, ExtensionSource 则由组成层明确传入。先固定本章一直使用的目录,不再为每个概念换例子:
调用者把 roots 明确写成 [projectRoot, userRoot]。因此同名资源由 project 版本胜出。一次 正常运行依次发生这些事:
这条 trace 是本章的地图。后面五个 Lab 只是逐段把它变成代码。 四种对象不能装进一个含糊的“插件”概念里: 前三种是数据。它们改变模型能看到什么,但不会仅因“被发现”就执行 Node 代码。 Extension 是代码;模块顶层在 import 时就可能运行,所以信任判断必须发生在 import 之前。 课程里的 discoverResources(roots) 只扫描前三种文本资源。组成层把 { id: "review-extension", path: ... } 单独交给 loadExtension();真实 Pi 才由更完整的 ResourceLoader 同时汇总资源路径与 Extension 路径。分开这两个入口,是为了让“发现数据” 和“准许代码执行”的先后关系可以独立观察。 本章最终得到六个公开函数:
先看对象怎样流动,再记术语:catalog 是“已经发现的目录”;activation 是“本轮确定要 读取的知识”;template render 是“输入参数变成用户消息”;extension host 是“获准代码 能注册什么、何时介入工具执行”的边界。

它接在第 11 章的哪个位置

第 11 章已经确立唯一的上下文构造器 buildContext()。本章不会再发明一套 resource messages 或另一套 token 裁剪器,只负责准备两个输入:
模板生成的消息走既有消息与 session 协议;项目规则和 Skill 知识走同一个 systemPrompt 字段。于是第 11 章仍能看到一次模型请求的完整预算。 Extension 接在另一边。它复用第 06 章的 ToolRegistryToolExecutor,而不是直接 侵入 Agent loop:
第 13 章会把 catalog 的静态资源上下文、扩展后的执行器、session 和 loop 组装成一个 Runtime。本章只把“发现”和“执行前后”各自做成可独立验证的部件。

建立练习起点

在教学历史仓库中生成隔离练习。后续修改只发生在新 practice 目录,教材目录和原始 pi 仓库保持不变:
练习保留 Chapter 11 的实现,并用无答案 starter 覆盖唯一教学文件:
Checkpoint 12 · 沿一条发现链重建资源与扩展模式: 重建。从第 11 章 target 开始,只增加 resources.ts起终点: parent 5fb517c2012d8e6227c0e17ee65e530e18ac13e6 是起点;target 03541892bcd533444af599d1813401f79807de3c 是终点。教学文件: packages/pi-course/src/resources.ts学习脚手架: starters/12-resources.ts 已固定 Resource、Extension、hook 的公共类型和 六个导出函数;函数体只有按 Lab 命名的施工位,没有本章答案。动手前只需知道: catalog 先回答“工作区里有什么”,activation 再回答“本轮读取哪份 Skill”;Extension 只有通过 trust gate 后才有机会执行并注册 tool/hook。第一步: 只实现 discoverResources(),让 [projectRoot, userRoot] 中 project 的 同名资源胜出,并让输出只按逻辑身份稳定排序。第一次红灯: starter 可以 build;只运行 Lab 12.1 时应得到 0/2,错误都是 Lab 12.1 resource catalog 尚未实现聚焦测试: packages/pi-course/test/12-resources-extensions.test.ts定位命令: npm run checkpoint -w @pi/course -- 12练习目录: npm run practice -w @pi/course -- 12聚焦运行: npm run build -w @pi/course,然后运行 node --test packages/pi-course/dist/test/12-*.test.js施工顺序: catalog precedence 2/2 → Skill activation 3/3 → template 与 context 2/2 → trust 与 staging 2/2 → hook 执行语义 3/3通过证据: fresh build 为绿,首红准确,五个 Lab 分别通过,失败注入能被现有测试 杀死并恢复,最后本章 12/12第一次尝试先不看 target diff。禁止让陪练粘贴完整答案;只比较当前 Lab 的输入、输出和 第一处偏差。
先验证起点:
看到准确的 0/2 后再开始。Build 失败不是预期首红;先检查练习是否从正确 parent 生成。

Lab 12.1:roots 顺序决定 catalog winner

现在只跟踪一个对象:skill:review
projectRoot 和 userRoot 都声明了 skill:review。逻辑身份由 kind + name 构成,因此 skill:reviewtemplate:review 可以共存,两个 skill:review 才发生冲突。输入数组 已经表达优先级:先出现的 root 获胜。 发现与排序是两个不同动作:
不能先按绝对路径排序再选 winner。那会让临时目录名或机器路径替调用者决定优先级。 对固定示例,catalog 的核心结果应类似:
注意 skill:review 没有 body。Discovery 会读取 SKILL.md 的文件前缀来解析 frontmatter;短文件的正文可能进入临时 buffer,但公开 catalog 只保留 namedescription,不会让正文进入本轮 context。目录可以告诉运行时“有 review 能力”, 却没有替本轮请求公开全部方法文本。 同一 root 内若出现两个相同 kind:name,roots 顺序无法裁决,直接报错。缺少 AGENTS.mdskills/templates/ 只表示该类资源不存在;其他 I/O 或解析错误不能 悄悄吞成空目录。
实践 12.1 · 实现 discoverResources只实现 discoverResources() 及它直接需要的目录、frontmatter、逻辑身份和排序 helper。 这一段的施工范围到此为止,后四个 Lab 仍保持 starter 状态。先写下三个预测:调换 roots 后 winner 是否调换;物理目录名与 frontmatter name 不同时 按哪个排序;JSON.stringify(catalog) 是否包含 inactive Skill 正文。可按下面的控制流施工:
运行独立聚焦测试:
通过证据是 2/2:调换 roots 会调换 winner;重复运行结果一致;排序看 logical name; inactive Skill 的 marker 不出现在序列化 catalog 中。常见的第一处偏差是把 roots 自己排序、只用 name 作为 key,或把完整 Skill 解析结果直接 塞进 catalog。

Lab 12.2:activation 才把 Skill 正文交给本轮

Catalog 现在已经选定 project 的 skill:review,但对象里仍只有 metadata。本轮明确需要 review 时才激活:
成功后,当前对象才扩展为:
request 保留调用者写下的逻辑路径,source 记录文件系统解析后的 canonical 路径, content 才是要进入后续上下文的文字。相同 request 出现两次时按 request 去重。 路径边界需要回答两个问题。第一,字符串解析后是否仍在 Skill root 内;第二,跟随 symlink 后的真实文件是否仍在 root 内:
只检查 .. 会漏过 symlink;只检查字符串前缀也会把 /work/review-old 错认成 /work/review 的子路径。用 path.relative(root, candidate) 判断 containment:结果不能 是绝对路径,也不能以 .. 开始。 激活还会重新读取完整 SKILL.md。若 frontmatter name 已经不等于 catalog 中的 name, 就拒绝这次激活。成功结果必须是副本;修改 review.body 不能反向改变 catalog。
实践 12.2 · 实现 activateSkill只实现 activateSkill()resolveInsideSkill() 和 containment helper,保留 Lab 12.1 的 结果。核心顺序是:按 name 找 catalog Skill → realpath() 并确认 SKILL.md 位于 canonical root 内 → 解析完整正文并复核 name → 对去重后的 resource requests 做 lexical 与 canonical 两次 containment → 返回深副本。运行:
3/3 证明三组事实:正文只在激活结果中出现且 catalog 不变;安全附加文件返回自己的 canonical source;traversal、绝对路径和指向 root 外的 symlink 都被拒绝。这层 containment 只约束 Resource loader 读取哪些 Skill 文件,不是操作系统沙箱,也 不能限制后面获准执行的 Extension 自己访问文件。

Lab 12.3:模板与 Skill 从两个入口汇入一次模型请求

固定模板正文是:
唯一占位协议是 {{name}}:name 以字母或下划线开头,后面可跟字母、数字或下划线。 同一变量出现两次就替换两次;缺少参数立即报错;多余参数不参与输出。只有符合这个 外形的片段参与替换;{{ name }}{{1x}} 等花括号文本按字面保留。
结果不是裸字符串,而是第 03 章定义的 canonical UserMessage
模板到这里就结束。它不直接调用模型,也不自行写 session。 另一条输入来自已经发现和激活的资源:
这份字符串包含 instructions 正文、所有 Skill 的 name/description、已激活 Skill 的正文 与显式附加文件。未激活 Skill 的 description 可见,正文不可见。函数还要拒绝重复激活 同名 Skill,以及不属于当前 catalog 的 ActivatedSkill。 现在把两条路径接回第 11 章:先把 message 追加为 session 事实,再把 systemPrompt 作为已有入口交给 buildContext()
formatResourceContext() 不裁剪 token,也不调用 buildContext()。它只返回一个字符串; 预算与历史投影仍只有一个负责人。
实践 12.3 · 实现 renderTemplate 与 formatResourceContext实现两个函数和必要的字符串 helper。先用固定模板手算完整输出,再写 replace 回调。资源上下文按这个顺序构造:加入 instructions → 加入全部 Skill metadata → 验证 activated 集合 → 按 name 稳定排列 active Skills → 加入 active body 和 resource files → lines.join("\n\n")运行:
2/2 要同时证明:重复占位符都被替换,缺少 owner 报错,结果 role 为 user; systemPrompt 有 instructions、active/inactive metadata 和 active body,没有 inactive body, 并原样进入 buildContext().systemPrompt若你在这里创建第二套 resource messages 或第二个 token budget,说明职责边界已经偏离。

Lab 12.4:Extension 先获准,再一次性注册

现在模型已经看见 review 方法,但还没有 review_note 工具。固定 Extension 的 factory 只做三件事:注册一个 tool、注册一个 before hook、注册一个 after hook。
loadExtension() 的正常顺序必须能从外部观察:
为什么不是 factory 一调用 registerTool() 就写真实 Registry?因为下一行仍可能抛错。 如果 tool 已写入而 hook 没写入,宿主会留下一个不存在于任何完整 Extension 状态中的半成品。 Staging context 的 registerTool()on() 只写局部数组。Factory 正常返回后, commit() 先检查 extension id、暂存区内部 tool 重名、与 Registry 现有 tool 重名;所有 检查通过后才写入真实 Registry、hook 列表和 committed extension ids。 公开 ExtensionHost 只有:
stage()loadExtension() 与 host 实现之间的私有通道,不能为了少写一个 helper 就 暴露给 Extension 作者。课程 target 用私有 ExtensionHostImpl 和模块内类型收窄连接:
私有 WeakMap 也可以。黑盒测试不要求 helper 同名,只要求 Extension 拿不到 staging 权限,而 loader 能在 factory 成功后 commit。
实践 12.4 · 实现 trust gate 与 staging registration实现 createExtensionHost() 的暂存/提交部分、loadExtension() 和 default factory 的运行 时检查。Lab 12.5 尚未实现时,wrapExecutor(core) 至少要在无已提交 hook 时调用 core 一次并透传结果;第二项测试会用它确认失败 factory 没有泄漏 hook。先保持这四条不变量:
运行:
2/2 的正常证据是 trust → import → factory → commitreview_note 可从 Registry 取得。 边界证据是 untrusted 只留下 trust,以及 factory 抛错或工具重名时 Registry 不变、暂存 hook 调用数为零。“原子”只指本章的注册可见性:全部 tool/hook 一起出现,或一个也不出现;它不是跨进程 事务,也没有实现 Extension 卸载协议。

Lab 12.5:一次工具调用怎样穿过 hooks

先只看正常结果,不讨论故障。review-extension 已经 commit,core 收到一条 review_note 调用:
wrapExecutor() 的主干因此很短:
Hook 收到冻结的深副本。它不能修改 call.argumentsresult.details,调用者修改返回 对象也不能反向改写 core 保存的原结果。 理解正常主干后,再给三个边界规定语义。 第一,before 是策略门。显式 deny 会阻止 core,并返回一条与原 call 的 id/name 配对的 isError: true 工具结果。策略抛错或超时表示 host 无法确认动作可继续,也 fail-closed:
Deny 是正常策略决定,reason 已写入 result details,不需要 diagnostic。Throw/timeout 是 Extension 故障,diagnostic 记录 extensionId/hook/kind/message 多个 before 按注册顺序运行,并在第一条阻断结果处短路:
Blocked result 是 host 产生的策略结果,不是 core result,因此不会触发 after。只有所有 before 都 allow,core 才执行一次;core 返回以后,after 再按注册顺序逐个观察。 第二,after 观察的是已经发生的 core 事实。一个 after 抛错或超时,只写 diagnostic, 然后继续后面的 after;它不能让 core 重跑,也不能替换结果:
第三,timeout 只让 host 停止等待。Promise.race() 不能强制终止一个已经开始且不响应 取消的 Promise;这层 timeout 是 host 的等待边界,不是代码沙箱。
实践 12.5 · 实现 wrapExecutor实现 timeout helper、paired blocked result、host 的 before()after()wrapExecutor()。Agent loop 和 session 保持现有实现,hook 只通过 wrapper 观察调用。逐行守住这些计数:
运行:
3/3 要看到:deny 保留 call id/name 且 core 为零;before throw/timeout 都 fail-closed 并 产生对应 diagnostic;after success/error/timeout 的组合中 core 仍只执行一次,成功 observer 执行一次,返回值与 core fact 深度相等但不是同一引用。常见错误是把 before throw 当成 allow、返回普通 Error 而丢失 call/result 配对、第一个 after 失败后停止后续 observer,或把 core 原对象直接交给 Extension。

把正常链与边界链放在一起

现在可以完整解释开头那次请求:
只有在这条正常链已经清楚后,边界才容易定位: 这里的信任策略来自调用者注入的 isTrusted()loadExtension() 能固定的链条是:判定 为 false,随后就不会 import。source path 是否已经 canonicalize,以及信任判断与随后 import 看到的是否仍是同一份文件,属于调用者和文件系统层的契约。 这张表把“安全”拆成可观察的控制流:每项测试固定一条边界上的动作顺序和结果。这些局部 契约合起来,也不等于对整个运行环境作出“绝对安全”的承诺。

故意破坏 trust 与 import 的顺序

失败注入 · 让未信任模块发生 import五个 Lab 全绿后,在 loadExtension() 的 untrusted 分支临时加入:
返回值仍写着 skipped_untrusted,但 import spy 已经观察到副作用。运行:
第一项应失败,调用顺序从 ['trust'] 变成包含 import。删掉错误行,再运行 Lab 12.4 与本章全量:
恢复到 2/212/12 后,这个实验结束;最终 diff 中不包含演示用错误分支。

测试证据与验收

本章 12 项测试按五个 Lab 分组: 这 12 项证据停在三个边界:
  • Resource containment 在发现和激活时检查路径;它不是操作系统沙箱,检查完成后的文件 变化仍由调用环境管理。
  • isTrusted() 由调用者提供。Extension 测试固定的是 trust → import 顺序,以及 hook timeout 后 host 停止等待;它不判断策略质量,也不终止已经运行的模块代码。
  • Staging 保证单次 factory 失败时零残留。卸载、reload 和多个 factory 并发提交需要另 一套生命周期协议。
本章全量命令是:
再验证没有破坏前十二章:
验收记录至少包含:

课程模型怎样迁移到真实 Pi

Pi 对照 · 课程压缩的是顺序,不是生产接口课程用 discoverResources()activateSkill()formatResourceContext() 和一个小型 Extension host,把最重要的输入输出压成 12 项黑盒测试。真实 Pi 的对象更丰富,不能按 函数名逐行映射。在固定上游提交 8479bd8 中,DefaultResourceLoader 统一暴露 getExtensions()getSkills()getPrompts()getAgentsFiles() 与 system prompt 相关读取,并在 reload() 中解析启用的资源路径。项目 trust 采用预加载流程:先把项目 设置视为未信任;此时排除 project-local extension,只加载 user/global 与临时 CLI 来源; 随后解析项目是否 trusted,并按最终 trust 状态重载设置与资源。这个实现比课程的单个 isTrusted(source) 更完整。真实 Extension API 也远不止两个 hook:factory 可以 registerTool(),并用 on() 订阅 tool_calltool_resultresources_discover 等事件。课程的 beforeToolCall/afterToolResult 只保留了最适合练习“策略先于动作、观察晚于事实”的一小 段执行语义。迁移时保留三条不变量,不复制课程内部 helper:数据资源与可执行代码分开;项目代码在 信任决策前不能被意外加载;扩展故障的处理取决于它发生在动作之前还是事实之后。

用固定示例做一次组合迁移

迁移练习 · 让 review 请求走完整条链在 Chapter 12 的练习目录新增 packages/pi-course/test/12-transfer.test.ts,复用公开测试里的临时目录 helper,不修改 resources.ts API。仍使用开头的 [projectRoot, userRoot]、同名 skill:reviewtemplate:review、 checklist 和 review-extension
  1. 断言 project 的 template 与 Skill metadata 胜出;
  2. 激活 review,渲染 src/parser.ts/runtime,再断言 systemPrompt 只有 project 的 active body;
  3. 加载一个注册 review_note 的 trusted Extension,before 只拒绝 text 中含 SECRET 的调用;
  4. 执行普通 note 与 secret note,记录 coreCalls、结果 id/name 和 isError
先写预期 trace:
验收条件是普通 note 让 coreCalls === 1,secret note 不再增加 coreCalls,并仍与自己的 call id/name 配对。失败时只找 trace 中第一处偏差,不给核心实现添加 transfer 专用分支。
Checkpoint 12 · 同一个工作区有一条可解释的发现链完成状态: Roots 输入顺序决定 kind+name winner;inactive Skill 只公开 metadata; 显式 activation 才返回正文与 root 内附加文件;template 生成 canonical UserMessage;资源 文字从唯一 systemPrompt 接口进入 Chapter 11 的预算。执行状态: Trusted Extension 按 trust → import → factory → commit 生效;staging 失败零残留;before 决定 core 能否运行;after 故障只产生 diagnostic,不改写 core 事实。公开证据: 2/2 → 3/3 → 2/2 → 2/2 → 3/3,本章共 12/12准确边界: Realpath containment 不是 OS sandbox;hook timeout 不会杀死后台 Promise; 课程 API 不是生产 Pi 的逐行缩写。恢复: 重新生成 Chapter 12 练习即可回到第 11 章 parent 加无答案 starter;只恢复 packages/pi-course/src/resources.ts,session、context、tool 和 loop 保持当前版本。

小结

  • Catalog 先回答“有什么”,activation 再回答“本轮读什么”。
  • Roots 顺序选择 winner;最终排序只稳定输出,不能反过来决定权限。
  • Template 生成 UserMessage,资源文字生成 systemPrompt,两者都回到既有上下文链。
  • Extension 是代码:先 trust,再 import;factory 注册先 staging,检查完成后一次 commit。
  • Before hook 位于动作之前,拒绝或故障时 fail-closed;after hook 位于事实之后,故障不能 重写结果。
  • 第 13 章会接入 catalog 的静态 metadata 与 ExtensionHost;Skill activation 和 template rendering 仍由调用者显式完成。