第八部:证明与守卫——怎样知道系统真的成立
写完了所有运行时模块,最后要回答一个工程师终极问题:你怎么知道它真的成立?从credentials不进日志到telemetry侧车定位,从trajectory replay到mock snapshot测试,从per-package invariant守卫到分层安全模型,最后这本书本身的更新方法论——这一部讲"证明"这件事。
展开阅读路线与实验入口
你读完了前七部,知道CLI怎么启动、Cordis怎么组装、Turn怎么跑、工具怎么调度、事件怎么落盘。你甚至能画出整个系统的调用图。但有个问题你没法回答:
你怎么知道它真的成立?
密钥会不会泄漏到日志里?Telemetry采集会不会反过来污染会话?崩溃后能不能重放验证?测试是不是真的覆盖了真实路径?每个包自己的守卫是什么?安全是不是一层纸糊的墙?还有——这本书本身,当代码commit前进时,怎么保证你读到的不是过期的谎言?
这一部不引入新功能。这一部讲”证明”。
这一部解决什么
读完这七章,你能回答七个证明相关的问题:
- 为什么credentials用引用而非值传递,空字符串为什么永远不被当作已配置密钥
- 为什么telemetry/feedback只是观测侧车,它永远不能创建或恢复会话
- SessionQueryEngine怎样做重放验证,traceEvent怎样追踪事件替换链
- ACP Snapshot Harness怎样启动真实子进程做record/replay,22种LLM mock行为覆盖哪些故障
- CI gate怎样强制每个包必须有自己的invariant.ts companion
- 安全为什么是Trust→Permission→Sandbox→Network四层,每层都不假设其他层完美
- 这本书本身的更新方法论:为什么锁commit不是为了停在旧版,而是让每个”当前”有可复核对象
你可能以为”证明”就是写测试。Harness不这么认为。测试是证明的一部分,但不是全部。证明还包括:密钥不泄漏的设计约束、观测不写入的边界、重放验证的能力、每个包自带守卫的CI强制、分层防御不把鸡蛋放一个篮子里、以及文档本身的可验证性。
所有证据来自固定commit的源码。实验命令只读不写,用$DSH_SOURCE_DIR指向官方checkout。
本节源码依据(1 处)
从最敏感的credentials开始。你会发现,防止密钥泄漏不是靠”小心打印日志”,而是从数据结构设计上就让密钥值根本不可能出现在错误消息里。
密钥只在内存里转一圈,日志里永远只看到它的"名字"
凭据系统有一条铁律:存的是值、传的是引用,日志和错误消息里永远只出现名字不出现值。LLM 调用前用 assertUsableApiKey 拦住空/非法 key;密钥文件落成 0600,权限不对直接拒;redactSecrets 在日志层把 role('secret') 的字段整片抹掉。凭据不出日志,靠的是三层独立闸门。
本章以中文撰写
你在设置里填了 API key,然后翻开日志想排错——里面应该只能看到”模型在等哪个凭据名”,而不是那串真的 key。这一章讲凭据系统怎么做到这一点:存的时候只存引用不存值、调模型前先拦空 key、密钥文件锁到 0600、日志层再把标记成 secret 的字段整片抹掉。四道闸门,任何一道漏了,剩下的兜住。
第一道闸门:配置里写的是引用,不是值
配置文件里不直接写 key 字面量,而是写 ${VAR_NAME} 这样的引用。credentialRef 先校验这个引用名的形状——REF_PATTERN 要求它是 [A-Za-z_][A-Za-z0-9_]*(字母或下划线开头,后接字母数字下划线)——然后才把它登记成一个凭据引用。
本节源码依据(6 处)
为什么”引用而非值”是第一道防线。 如果 key 字面量直接躺在配置文件里,那这个文件被 cat、被 git add、被日志打印一次,就全漏了。写成 ${VAR_NAME} 之后,配置里出现的永远是一个无害的名字;真的值只在运行时由 CredentialProvider 按引用取进内存,用完不回写。这是把”敏感值”和”配置副本”在源头分开。
第二道闸门:调 LLM 前先拦住空 key
拿到 key 之后、真正发请求之前,assertUsableApiKey 做两件事:一是不许是空,二是不许含非法字符。它同时用 LEGAL_API_KEY 正则 ^[\x21-\x7E]+$(只允许可打印 ASCII)做合法形状检查。
本节源码依据(4 处)
为什么错误消息不能回显 key。 报错常常被复制进 issue、贴进群聊——如果错误里把那串 key 带上,等于借排错之机又泄漏一次。assertUsableApiKey 只说”空了”或”含非法字符”,并指向”网页 Models 页会帮你写”,绝不复述值。这是凭据系统的一条纪律:任何对人说话的地方,都把值换成名字。
第三道闸门:密钥文件锁到 0600
凭据落盘时,文件权限要锁死:writeFileAtomic 写文件用 0o600(仅 owner 可读写),目录用 0o700。启动加载时 assertOwnerOnly 再查一遍权限——如果文件被人 chmod 644 变成全局可读,直接拒绝服务,错误里提示 chmod 600 ...。
本节源码依据(4 处)
第四道闸门:日志层把 role(‘secret’) 整片抹掉
就算前面都对,万一某个日志不小心把整个对象打出来怎么办?redactSecrets 是最后一道网:它递归 walk 任何对象,遇到被标成 role('secret') 的字段就直接置成 RedactedSecret 标记对象,而不是它的真实值。它还覆盖了 union/intersect/transform 这些嵌套类型的分支,不漏。
本节源码依据(6 处)
为什么需要四道独立闸门。 任何单一防线都可能被绕过:配置改写手滑、日志忘了传、文件权限被外部改松。把”不写值 / 权限锁死 / 错误不回显 / 日志兜底抹除”做成四道互相独立的闸门,意味着攻击者要同时破四处才拿得到明文。这是纵深防御——不是相信某一处绝对正确,而是让任何一处出错都不至于全盘泄漏。
| 闸门 | 防的是什么 | 失败时的行为 |
|---|---|---|
| 配置写引用不写值 | 字面量进配置/进 git | credentialRef 拒非法形状 |
| assertUsableApiKey | 空/畸形 key 被发出去 | 报错但不回显值 |
| 文件 0600 + assertOwnerOnly | 同机其他用户读密钥文件 | 权限太开直接拒服务 |
| redactSecrets | 日志/边界里把值打出来 | role(secret)→RedactedSecret |
第五道闸门的内部:两套键空间各答各的问题
前面四道闸门讲的是”值别漏”。但凭据系统内部其实有两套不同的键空间,混在一起会直接写错地方。第一套 CredentialRef 回答”这个环境变量名背后现在是什么值”——它是分层的:进程环境 → provider 托管存储 → 各种 .env 文件,逐层往上叠。第二套 CredentialKey(scope/id)回答”这个插件为这条路线存了什么授权”——它没有分层,记录在就是在,不在就是不在。
本节源码依据(5 处)
为什么 resolve 要”每次现取”而不是缓存。 如果进程启动时把 key 取一次就缓存住,那用户在设置页换了 key、或者 export 了新环境变量,都要重启进程才生效——这和”凭据可热更新”的产品预期相悖。每次操作都 resolve,等于把”配置层”和”运行层”解耦:值随时可以变,但消费者的代码一个字不用改。代价是每次多一次读取,而凭据读取是本地文件/环境查询,相对一次 LLM 调用的延迟可以忽略。
| 维度 | CredentialRef(环境变量名) | CredentialKey(scope/id 授权) |
|---|---|---|
| 回答的问题 | 这个名字背后现在是什么值 | 这个插件为这条路线存了什么 |
| 是否分层 | 是:进程环境→托管存储→.env | 否:记录在就是在 |
| 枚举例 | 不枚举(schema 已知) | listRecords 全枚举报孤儿 |
| 写路径 | set/unset(受只读层阴影拒) | modifyRecord(跨进程锁) |
写路径:只读层阴影下拒绝写,refresh token 靠跨进程锁
读侧已经很克制,写侧更小心。set/unset 不是无条件成功——如果一个只读层(比如用户 shell 里 export 的环境变量)正压在这个引用上,写托管存储看起来”成功”了、resolve 却永远返回那个只读值,等于没写。所以这种情况下直接拒绝。token 刷新更麻烦:它是”读出现值 → 判断要不要续 → 写回”的三段,两个进程同时续同一个 refresh token 会互相覆盖,必须在一把锁里串行做。
本节源码依据(4 处)
为什么写成功后才广播更新事件。 credentials/reference-updated 是给下游”我该重取了”的信号。如果写还没落盘就广播,下游抢在落盘前去 resolve,读到的还是旧值——信号就失真了。所以契约是”写真正提交后再 fanOut”,且监听器抛错被 contain(顶多 warn),只有 INVARIANT 这种断言级错误才重抛。这和第 49 章 webhook 会话”先提交再通知”是同一条纪律。
把这些串起来看,凭据系统的”不进日志”其实是两层:一层是前面四道闸门做的”别让值出现”,另一层是这里讲的”键空间和写路径别把值绕出来”。哪怕四道闸门全失效,只要消费者一直走 describe/listRecords/modifyRecord 这组受控接口、而不是直接碰存储文件,UI 和日志就永远只能看到键名、类型、可写性这三个布尔或枚举。值是被”接口形状”逼在外面的,不是靠人记得别打印。这也是为什么这一章的结论不是”小心别 log”,而是”在结构上就 log 不出来”。
小结
凭据”不进日志”靠四道独立闸门:配置只写 ${NAME} 引用、调模型前拦空 key 且不回显值、密钥文件锁 0600 且启动校验权限、日志层把 role('secret') 整片抹成 RedactedSecret。下一章看这些”往外发的东西”——遥测与上报怎样既收集到运行数据、又不在上报里带上敏感字段——第 51 章。