8.2 KiB
Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册
Status: implemented
English | 中文
范围:对请求级 LLM(大语言模型)配置 seam的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 settings 写路径 note 配套:本轮把那篇 note 的提供方修复套用到
credentials-local,并把其中的写锁提升进dsh-atomic-write。
问题
评审发现,凭据路径正在越过它自己划下的边界泄漏。已交付的各个面在 Cordis 启动之前就把 $DSH_HOME/.env 提升进了 process.env,于是下一次运行时,credentials-local 会把它自己存下的每个键都判成来自环境的只读启动覆盖:describe() 报告 source: 'env' 且 writable: false,set/unset 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。
存储自身的写路径重演了同一轮评审在 settings-local 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 writable: true 而 set 总是抛错,credentials/updated 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
在读取一侧,文件的 0600 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
与之并排的还有两个请求路径缺陷。DeepSeek 分别解析连接事实与凭据事实,因此被 resolver 拒绝的那一代设置仍可能把自己的凭据选择与上一代的端点配在一起。配置了 apiKeyEnv 却解析不到值时,pi-ai 会把 undefined 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
决策
**凭据文档只归凭据提供方所有。**没有任何一个面会把它加载进 process.env。当时该文档是 $DSH_HOME/.env;凭据文档拆分后来把它移到 $DSH_HOME/.credentials.yaml,因此如今被加载的正是那条旧路径——作为用户的普通环境层,其中不含任何提供方管理的密钥。真正的启动环境,以及调用目录中由 bin 加载的 .env,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 describe() 的单元断言。
存下的凭据对模型没有边界,而 README 就是这么写的。0700 目录下的 0600 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 process.env,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据引用,resolveApiKey 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 MISSING_CREDENTIAL 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
路由替换是注册表的操作,不是调用方的一串步骤。registerAdapter 返回一个携带 replace(providers) 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
已提交的凭据写入采用收容式发布。Credentials.notifyUpdated 逐个监听器扇出 credentials/updated;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 INVARIANT 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 settings/updated 的形状相同。installSettingsSection 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
曾考虑的替代方案
- 用沙箱点名拒读
$DSH_HOME/.env——已按readDenyPaths策略字段实现过(末尾一条 SBPLdeny file-read* file-write*、一条/dev/null的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对/的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报partial。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖sessions/,而DSH_SESSION_JSONL是一项成文的、模型可见的能力。 - 把
DSH_HOME从模型的 bash 环境中移除——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。 - 本轮就交付 OS 钥匙串提供方——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。
- 做成
replaceRegistration(previous, next)服务方法——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把replace挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。
后果
update() 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,describe() 对它不会改写的多行条目报告 writable: false。LlmAdapter 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),DeepSeekConnectionOptions 则新增了凭据字段,因此以编程方式构造该适配器必须提供 apiKeyEnv。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。