Two had real security consequences: The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile applied the file with Node's own. Two independently maintained dialects meant the check and the thing it guards could disagree: a name Node accepts but the checker misses would reach process.env unchecked, and BASH_ENV there runs a file of the project's choosing on every `bash -c` the bash tool issues. Parse once with node:util's parseEnv — the same engine loadEnvFile uses — and assign the entries already checked, which also drops the dotenv dependency. llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it registers a settings namespace, so the defect removed from llm-deepseek survived intact in its design twin. The field is gone from the profile schema, the resolution path, and the tests. The rest are consistency and documentation defects the review named: - verify-config-source-ownership did not scan the Python runtime's bundled cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and the line-anchored INLINE_DENY documents that it is a tripwire, not a parser. - The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated rule about what a variable does. - Snapshot lookups folded case on Windows, where environment names are case-insensitive and an exact-match Map could miss a higher-ranked layer. - The credentials note claimed a read-time permission check was "not taken" while this PR implemented it; the credentials-local README still described two layers, live process.env reads, dotenv-era limitations, and a renamed anchor; the llm-deepseek README still advertised the removed literal apiKey; and web.ts and base.cordis.yml kept personal-overlay wording. - The ownership note's literal-apiKey claim now names its scope: the web-search providers keep a literal field but register no settings namespace, so nothing can shadow a stored credential through them.
6.4 KiB
Agent Note: 把凭据存储与用户环境层拆开
Status: implemented
English | 中文
Problem
$DSH_HOME/.env 同时承担了两件互不相容的工作。它是 credentials-local 的可写密钥存储,因此任何表层都不能把它提升进 process.env——一旦提升,每个已存密钥都会读作只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。但它的文件名和 dotenv 格式承诺的是一个环境文件,于是用户把非密钥值放进去,而那些值哪儿也到不了:同一个文件里,一个能用的 DEEPSEEK_API_KEY 旁边的 DEEPSEEK_BASE_URL 会被静默忽略,因为只有凭据 provider 读这份文档,而它只寻址凭据引用。
一个文件无法既是由 Harness 拥有并隔离的存储,又是按普通环境规则传播的层。请求级凭据决策当初选择 dotenv 是为了对齐同类产品的 home .env,而这种混同直到有非密钥值需要用同一个文件时才暴露出来。
Decision
两件工作在 Harness home 下拆成两个文件。
.credentials.yaml 是 provider 管理的存储。 一个从 CredentialRef 到非空字符串的严格 YAML mapping,没有 version 字段,也没有包装层:
DEEPSEEK_API_KEY: sk-…
OPENAI_API_KEY: sk-…
因为该文档只存放凭据、别无他物,任何偏离都是拒绝而不是跳过条目:非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败——启动时和写入时响亮失败,运行期热重载则告警并保留最后可用快照。被静默忽略的键读起来就是「我存进去的密钥没有生效」,而这正是本次变更要消除的失败。dotenv 物理行编辑器被替换为对已解析文档打补丁,因此注释与未触及条目的排版都会保留,任何字符串值都能往返(含多行),也不会再有条目因为缺少可用引号样式而不可写。写锁、read-modify-write、0700 目录下的 0600 原子写、精确路径 watcher、按内容相等抑制自写、以及 dispose 时的完全停稳,均保持不变。
$DSH_HOME/.env 是用户的普通环境层。 dsh-app-boot 中的 loadLayeredEnv 先加载调用目录的 .env,再加载 Harness home 的,得到 用户 < 项目 < 继承——process.loadEnvFile 从不替换已经设置的名字,加载顺序正是利用了这一点,app-boot 的测试也把三层一起钉住。Harness home 在两个文件加载之前就从继承的环境解析完毕,因此项目 .env 无法改变读取哪份用户文档。只有产品 CLI(命令行界面)叠加这两个文件;SDK 与示例 bin 仍通过 loadEnv 加载各自的目录,绝不继承开发者的 $DSH_HOME。
本轮不改凭据优先级:活跃进程环境仍然只读地优先于文件,set/unset 仍然拒绝会被环境遮蔽的写入。provider 管理的存储是否应当反过来压过环境,是另一个决策,此处刻意不作。
不做迁移。产品尚未发布,而已经放在 $DSH_HOME/.env 里的密钥会继续通过新的环境层解析——作为只读的 env 来源遮蔽已存储的那一份,诊断给出的也正是这个结论。
Consequences
- 放弃的:留在
$DSH_HOME/.env里的密钥现在会被提升进process.env,因而会按子进程凭据清洗的规则抵达子进程,而不再留在 provider 内部。这就是「普通环境层」的诚实含义;需要由 Harness 拥有并隔离的密钥属于.credentials.yaml,后者永不提升。 - 放弃的:同一个键会遮蔽
.credentials.yaml,并让 Web Models 页的写入被拒。seam 对这种状态本来就报告source: 'env', writable: false,而拒绝信息现在会把已加载的.env一并指为需要清除的位置。 - 换来的:用户
.env里的非密钥值终于生效,这正是最初的缺陷;文档格式可以拒绝它无法承担的内容;0600保护的是一个只存密钥的文件,而不是一个我们同时叫用户往里写普通配置的文件。 - provider 写入时用的
0600同样约束它读取的内容:在 POSIX 上,只要文档带有任何 group 或 other 权限位,就会在读取内容之前让启动失败——启动时与每次 reload 都检查,诊断里给出chmod 600的修复命令。Windows 没有可检查的 mode(其 ACL 无法在此表达),因此跳过该检查而不是伪造它。 0600这条边界仍然只挡其他 OS 用户、挡不住模型,本次拆分未改变这一点——该限制及 keychain provider 的延后项归 provider README 所有。
Alternatives considered
保留单一的 $DSH_HOME/.env,让 CLI 去提升它。 否决:提升存储本身正是让已存密钥无法轮换的原因,这也是 app-boot 当初记录该排除的理由。冲突来自这个文件的两份工作,而不是加载器。
$DSH_HOME/.credentials.env——第二个 dotenv 文件。 否决:dotenv 适合环境层,却无法表达「一份按凭据引用索引的受管文档」。它无法拒绝非字符串或无法寻址的键,而且它的行编辑器本来就会拒绝无法加引号的值,留下可读却不可写的条目。
给新文档加 version 字段。 否决:该格式只有一个受 schema 约束的字符串 mapping,没有需要判别的历史变体。在未发布阶段,直接修改结构并拒绝旧结构,好过提前承诺迁移协议。
首次运行时把形似凭据的键从 $DSH_HOME/.env 迁出。 否决:迁移代码会把短命格式变成长期维护面,而判断一个未知文件里哪些键是密钥,恰恰是本次拆分要消除的歧义。旧文件继续作为环境工作,这是诚实的结果,而不是静默的结果。
彻底取消用户 .env 层,只保留继承的环境。 在此处否决为超出范围:它本身是自洽的设计(层次更少、每个值只有一处来源),但会移除用户已有的工作流,而分层问题属于那个被延后的优先级决策,不属于本次拆分。