开源自托管 Agent 项目 Hermes Agent 如何把密钥来源做成可插拔并划定作用域

2026-07-30

本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。

一个常驻在你机器上的 Agent,密钥的难点从来不是用什么算法加密,而是「谁、在什么时候、能读到哪一份」;Hermes Agent 的处理方式是把这件事拆成两个彼此不相干的问题——密钥从哪里来、密钥归谁用——各给一套独立机制,中间不打通。 先做命名消歧:本文说的是 NousResearch/hermes-agent 这个 MIT 许可的开源自托管 Agent 仓库,不是 Nous Research 的 Hermes 开源模型系列,也不是任何同名商标或第三方库。

站内已有的 Agent 最小权限设计 谈的是通用方法论,pi 的安全边界MCP 授权加固 各自落在另一个项目和另一个协议层。这篇的分工很窄:只把这个仓库里密钥来源与凭证作用域的两块代码读透,告诉你哪几处设计可以照搬到自己的自托管 Agent 上,以及照搬的代价是什么。

一、它要解决的问题:明文 .env 会一路扩散

这个项目的默认凭证载体是 ~/.hermes/.env。单机自己用没什么毛病,问题出在它是个常驻进程:会开终端执行命令、会往磁盘写文件、会连你的聊天软件账号、会访问外部服务。一旦某个模型服务商的密钥写进了 .env,它就进了 os.environ;而 os.environ 会被继承进它派生的每一个子进程。你以为泄漏面是一个文件,实际泄漏面是这个进程整棵子进程树。

仓库里的注释把这层顾虑写得很直白:base.py 中共享的子进程启动函数明确说明,子进程不会拿到「后 dotenv 阶段的完整 os.environ」,因为那时候环境变量里装着 Hermes 知道的每一份凭证。这句话本身就是对明文 .env 的风险定性——不是「不安全」,是「作用面太大」。

于是有了两条独立的补救路线。第一条:让密钥不必长期驻留在明文文件里,改为启动时从外部密钥管理器取,这是可插拔来源。第二条:即便值已经进了进程,也要让读取端按调用上下文拿到属于自己的那一份,这是密钥作用域。两条路线在仓库里是两个互不引用的模块,这个解耦本身就值得记一笔。

二、来源契约:一个刻意做窄的接口

agent/secret_sources/base.py 定义了抽象基类 SecretSource,每个密钥后端实现它。这个契约最值得学的地方不是它支持什么,而是它明确拒绝支持什么,模块开头的注释逐条列了边界:

只读。来源只做「引用 → 值」的解析,没有写回(不提供「把这把密钥存进保险库」)、没有任意密钥对象、没有会话中途的密钥 API。注释里甚至留了一句给未来维护者的话——如果以后真需要轮转/刷新,那会以带版本号的可选钩子形式加进来,不许硬塞。

启动时、同步。fetch() 每个进程调用一次,编排器在外面套一个墙钟超时;来源不许自己起后台刷新线程。默认超时给得相当宽松,注释也说明了原因:首次运行可能包含一次性的辅助 CLI 自动安装,预算太紧会把第一次启动直接判死。每个来源可以在自己的配置节里改写这个预算。

不抛异常、不弹交互。fetch() 必须返回 FetchResult,错误塞进 error 字段并附一个机器可读的 ErrorKind。需要交互式登录的部分只能放在这个来源自己的 CLI setup 流程里,绝不能出现在启动路径上——因为网关或定时任务启动时没有终端,卡在 stdin 上就是整个进程起不来。

来源只取,编排器才写。fetch() 返回的是「我打算贡献的名值映射」,真正写 os.environ、判优先级、发冲突告警、记来源出处,统统由编排器负责。这条是整套设计的支点:任何一个后端都不可能把优先级逻辑写错,因为它压根碰不到。

ErrorKind 是个固定词表,包含未配置、缺二进制、认证失败、认证过期、引用非法、网络、空值、超时、内部错误。词表固定的收益在两处:启动告警和状态命令的措辞在所有后端之间一致;编排器可以按错误类型做统一策略——比如只在网络类和超时类失败时考虑降级到陈旧缓存,而认证失败绝不降级。这个区分很关键,认证失败时喂旧值等于把一个真实的凭证问题藏起来。

契约还给了三个共享工具函数:环境变量名合法性校验、ANSI 转义序列清洗、以及带白名单环境的辅助 CLI 调用。后者的安全姿态写在文档字符串里:只传 argv 数组不开 shell;子进程只拿到 PATHHOME 一类基础变量加上显式声明的认证变量;关掉颜色输出并清洗 ANSI,防止辅助程序的诊断信息把控制字符塞进 Hermes 自己的输出;stdin 指向空设备,让打算弹提示的辅助程序快速失败而不是挂住启动。

三、三个内置来源,两种形状

契约里有个字段叫 shape,取值只有 "mapped""bulk"。前者是用户逐个把环境变量名绑到一个引用上,后者是后端把整个项目/目录的密钥一次性倒进来。这个字段直接决定优先级:显式绑定的意图强于批量倾倒。

1Password 走 mapped。用户在配置里把环境变量名映射到官方的 op:// 引用,每个引用用一次 op read 解析。仓库里的配置示例是这样的:

secrets:
  onepassword:
    enabled: true
    env:
      OPENAI_API_KEY: "op://Private/OpenAI/api key"
      ANTHROPIC_API_KEY: "op://Private/Anthropic/credential"

它的认证完全沿用你本机 op CLI 已有的登录状态——无头机器用服务账号令牌,桌面环境用交互式会话。文档字符串里那句「Hermes 从不代替用户认证,它只是调用一个已经被信任、已经登录好的 CLI」是个很聪明的责任划分:把认证这件麻烦事整个推给上游工具。

Bitwarden Secrets Manager 走 bulk,因为它把配置项目里的全部密钥一次拉过来。它需要一个辅助二进制,仓库的做法是首次使用时下载到 Hermes 家目录下的 bin/ 里,并且把版本钉死在代码里——注释明确写了绝不自动追 latest,理由是上游发布物的形状(资产文件名、CLI 参数)在大版本之间是允许变的,升级必须是一次有意识的动作。下载后会用发布页公布的校验和文件比对 SHA-256,解压时还有一层路径穿越防护:先把成员名解析成绝对路径,确认它没跑出临时目录才落盘。这两道防护针对的是同一类风险——你在启动路径上执行一个刚从网上拿下来的可执行文件。

外部命令来源也走 bulk。它把一条用户自己写的 shell 命令当成密钥来源,keepassxc-clisecret-tool、或者干脆 cat 一个 tmpfs 上的环境文件都行。仓库里的示例配置:

secrets:
  command:
    enabled: true
    command: "cat /run/user/1000/hermes-secrets.env"

这个来源的安全设计最值得逐行看,因为它是三个里唯一真的会执行任意命令的。命令字符串来自 config.yaml 而不是 .env,因为 .env 只该放值;请求的密钥名只通过一个专门的环境变量传给子进程,绝不拼进 shell 字符串,所以一个恶意的键名是惰性数据而不是代码;硬超时默认卡在个位数秒级、输出体积另有一道上限,两者都是防「辅助程序挂住启动」和「失控输出把内存吃光」;辅助程序的 stderr 被管道捕获后直接丢弃,因为它可能带着密钥材料,失败时只打印结构化字段(退出码、信号名);超时后杀的是整个进程组,因为辅助脚本可能 fork 了还攥着管道的子进程。它还是 POSIX-only,Windows 上直接降级为空结果并给告警。

解析辅助程序输出的那段代码里藏着一个很容易被忽略的攻击面。输出可能是裸值,也可能是多行的 KEY=VALUE 块,代码得区分。麻烦在于一个裸的 base64 密钥自己就长得像 KEY=VALUE(因为末尾有等号填充)。它的处理是:如果只有一行像键值对、键名又跟请求的不一致、而等号后面还有非等号的实质内容,就判定为「串了别人的条目」并返回空值。注释解释了为什么必须这么严——否则一个写得潦草的辅助脚本吐出的另一把密钥,会连着键名和等号一起流进请求方的授权头,那不是 401 的问题,是跨服务商凭证泄漏。

组成部分它负责什么仓库位置你什么时候会碰到它
来源契约 SecretSource定义 fetch()、可选钩子、错误词表与共享工具agent/secret_sources/base.py想自己写一个密钥后端时,先读这个文件的模块注释
编排器 apply_all注册、超时、优先级、冲突告警、出处记录、写环境变量agent/secret_sources/registry.py两个来源都提供同一个变量、想知道谁赢时
1Password 来源解析 op:// 引用,逐个绑定环境变量名agent/secret_sources/onepassword.py想按变量精确绑定、而不是整项目倾倒时
Bitwarden 来源拉取整个项目的密钥,含辅助二进制自装与校验agent/secret_sources/bitwarden.py一队机器共用同一份密钥项目时
外部命令来源跑一条用户自己配的 shell 辅助命令取值agent/secret_sources/command.py用系统密钥环或本地脚本、不想接云端保险库时
缓存底座 DiskCache原子写、0600 权限、TTL;两个保险库来源共用这一份agent/secret_sources/_cache.py想确认缓存文件权限和落盘位置时
凭证作用域 get_secret按上下文解析凭证,多档案下失败关闭agent/secret_scope.py一个进程服务多个档案、担心串号时
启动装载在 dotenv 之后、其他模块读环境变量之前跑一遍hermes_cli/env_loader.py排查「为什么我的值没生效」时

四、编排器:优先级只在一个地方裁决

registry.py 的模块注释把优先级阶梯写成了一份可执行的规格。按环境变量逐个判定,意图越具体越赢:配置里显式列进「保留既有值」名单的变量最优先,即使某个来源开了覆盖开关也压不过它;其次是已经存在的 .env / shell 值,除非胜出的来源开了覆盖;再往下是 mapped 来源,按配置顺序;最后是 bulk 来源,同样按配置顺序。

关键规则是「先声明者赢」。后面的来源如果也带着同一个变量,不会静默覆盖,而是被记进一条「已被别人声明」的跳过列表,并生成一条人类可读的冲突告警,提示你要么删掉一处绑定、要么调整来源顺序。覆盖开关的语义也被卡死了:它只能压过 .env / shell 的值,永远不能压过另一个密钥来源的声明——跨来源覆盖被定义为配置错误,是要告警的,不是给你调的旋钮。

还有一层「受保护变量」。每个来源可以声明一批任何来源都不许覆盖的变量名,典型就是它自己的引导认证变量。这条防的是一个具体的自伤场景:你把保险库的访问令牌本身也存进了那个保险库,于是拉取回来的值把正在用于拉取的凭证给覆盖了。

超时的实现细节值得一提。编排器用一个守护工作线程跑 fetch(),超预算就报超时并丢弃结果,线程本身可能一直挂到进程退出。注释坦白这是个折中:对只在启动阶段跑一次的路径可以接受,总比每次调用都可能无限期挂死要好。这种「明写取舍而不是假装没有问题」的注释风格,在这个仓库里出现得相当频繁。

编排器最后会产出一份出处记录:每个被写入的变量,记下是哪个来源提供的、形状是 mapped 还是 bulk、以及有没有覆盖掉一个原本存在的值。这份记录被启动状态行和几个界面读取。密钥管理里最烦的问题之一就是「这个值到底是谁塞进来的」,把出处做成一等公民而不是靠 grep 猜,是很实用的一笔投入。关于密钥本身的日常管理,站内另有 API 密钥安全管理密钥轮换 两篇,可以对照看。

五、作用域:一个进程服务多个档案时的失败关闭

agent/secret_scope.py 处理的是另一半问题。这个项目的网关支持在一个进程里服务多个档案,每个档案有自己的 .env、自己的模型服务商密钥、自己的平台令牌。这些值不能并进进程级的 os.environ——那等于把档案 A 的密钥泄给档案 B 的每一次对话,以及每一个用完整环境变量派生出去的子进程。

它的方案是一个基于上下文变量的作用域。装入当前档案的密钥映射后,这个作用域会随上下文拷贝一起传进 Agent 的工作线程。凭证读取统一走一个解析函数,顺序是这样的:先看变量名是不是真正的进程级设置(PATHHOME、家目录路径、看板路径、一批调优开关等,仓库里维护了一份精确名单加前缀名单),是就直接读 os.environ;否则看有没有装作用域,有就从作用域里取。

最漂亮的一处是没有装作用域时的分叉。多档案复用模式关闭时(默认状态),它透明地读 os.environ,行为和改造之前完全一致。多档案复用模式打开时,它直接抛一个专门的异常——一个没迁移到位或者新加的调用点会在那一行大声炸掉,而不是悄悄拿到另一个档案的值。这就是失败关闭。异常消息里连修法都写好了:把调用路径包进作用域,而不是去放宽白名单。

另一处细节展示了这类设计的真实成本。多档案模式关闭时,作用域内查不到的键会继续回落到 os.environ。注释解释了为什么必须留这条回落:单档案部署完全有理由通过进程环境提供凭证(systemd 的环境声明、密钥管理器的包装运行命令、普通 shell 导出),而定时任务调度器会无条件在每个任务外面套一层作用域——没有这条回落,只存在于进程环境里的凭证会在作用域块内凭空消失,结果就是定时任务带着占位密钥去请求然后 401,而交互式对话一切正常。这类 bug 极难复现,因为它只在一条路径上发作。

配套还有两个小函数:把 .env 解析成普通字典而完全不碰 os.environ(隔离本身就是它存在的理由),以及把某个档案家目录下的 .env 与外部来源取回的值合并成一份新映射,合并时跳过那些真正的进程级变量。

六、边界与代价:它明确不管的事

这套设计放弃了不少东西,而且是明写着放弃的。

不管轮转。契约里没有写回、没有会话中途的密钥 API、没有后台刷新。密钥换了,你得重启进程,或者用它提供的清缓存动作。缓存键里折进了认证材料的哈希指纹,所以换了令牌之后不会拿旧指纹下的缓存值——但这只解决「不拿错」,不解决「自动拿到新的」。

不管值落磁盘。两个保险库来源都会把解析出来的密钥值写到家目录的缓存目录下(外部命令来源反倒完全不缓存,每次启动都重跑一遍辅助命令)。仓库对缓存落盘的说法很实在:这在明文程度上等价于本来就接受的 .env 文件,但故意放在 .env 之外,免得你编辑 .env 时手滑把保险库来的密钥提交进版本库。缓存文件只存解析出来的值,不存访问令牌。Bitwarden 来源另有一个可选的加密缓存,用引导令牌派生密钥做 AES-GCM,用于网络故障时的离线兜底,而且一旦启用就不会再回头去读那份明文缓存;1Password 来源只有明文这一层。把 TTL 设成零可以让读写两层缓存同时关闭,代价是每次启动都要重新走一遍外部调用。

不管交互式解锁。硬超时和「非交互」是写进注释里的硬要求:外部命令来源那个紧到几秒的预算摆明了不接受一个会等你按硬件密钥或输 PIN 的辅助程序。想要那种体验,你得自己在 Agent 之外先把保险库解锁好。

不管跨平台对等。外部命令来源需要 /bin/sh,Windows 上直接降级。这不是待办,是声明过的取舍。

不改变常驻进程本身的暴露面。这点必须说清楚:把密钥搬进保险库,减少的是「明文文件被读到」和「进程环境变量整体外溢到子进程」的风险;它一点也没有减少「这个 Agent 正在你机器上执行命令、写文件、连你的账号」这件事的风险。值最终还是会到进程里,能读到它的代码路径依然是整个 Agent。这套机制降低的是泄漏半径,不是权限半径。

七、上手与避坑清单

先读契约的模块注释,再看任何实现。 会踩的原因是:三个内置来源的代码量差异很大,直接从最长的那个读起,你会以为它的做法就是规范,实际上里面很多是那个后端自己的历史包袱。避法是先把契约文件的模块注释读完,那三十几行把「什么是这套设计的一部分、什么不是」说得比任何实现都清楚。

别自己在来源里写优先级。 会踩的原因是:写后端时很自然会想「这个值已经有了就别覆盖吧」,于是在 fetch() 里判断当前环境变量。避法是记住来源只返回它打算贡献的映射,判定全交给编排器;FetchResult 里那两个「已应用 / 已跳过」字段是为兼容最早那个 Bitwarden「取了就应用」入口才留着的,注释写明了符合契约的 fetch() 实现应当把它们留空。

两个来源提供同一个变量时,先去看冲突告警而不是猜。 会踩的原因是:mapped 压 bulk 这条规则跟你在配置里的书写顺序无关,看配置文件是看不出谁赢的。避法是相信编排器产出的告警文本,它会直接告诉你保留了谁的值、还有谁也提供了它、以及两条修法(删一处绑定,或者调整来源顺序)。

把保险库访问令牌自己也存进保险库之前,先想清楚。 会踩的原因是:为了「统一管理」把引导凭证也塞进去,看起来很整洁。避法是知道两个云端来源都把自己的引导认证变量声明成了受保护变量,所以这么做不会炸——但也意味着那一份存进去的副本永远不会生效,你多半会在某次轮转后对着一个不更新的值发懵。

外部命令来源的辅助程序,务必让它只输出规整的键值行。 会踩的原因是:随手用 grephead 从文件里捞一行,很容易捞到隔壁那一行。避法是知道解析器有跨键防误投保护,遇到形状可疑的输出会返回空值而不是勉强用上——所以你看到的现象是「没取到值」而不是报错。排查时直接在 shell 里手动跑一遍那条命令,因为 Hermes 会丢弃辅助程序的 stderr,你在它的日志里看不到真实错误。

Windows 上不要指望外部命令来源。 会踩的原因是:配置写了、开关也开了,但只在启动日志里留一行告警。避法是在这类机器上改用两个云端来源之一,或者干脆保持 .env

多档案复用模式一开,就要预期看到失败关闭的异常。 会踩的原因是:那个异常长得像 bug,第一反应是找地方绕过它。避法是把它当成设计信号——它在告诉你某个凭证读取路径还没被作用域包起来,正确的修法是补上作用域,不是放宽名单。

收个尾

这套设计里真正能搬走的东西有三样:把「取值」和「决策」拆成来源与编排器两层,让后端不可能把优先级写错;给错误定一份固定词表,好让降级策略只写一次;以及在多租共进程的场合选择失败关闭而不是静默回落。三样都不依赖 Python,也不依赖这个项目的其他部分。

接下来该读哪个文件,取决于你要干什么。想自己写一个后端,从 agent/secret_sources/base.py 的模块注释开始,然后直接跳到 agent/secret_sources/registry.py 看注册时的那几条校验(名字形状、契约版本、shape 取值、引用协议头唯一性),它们决定了你的实现会不会被静默跳过。想搞清楚线上某个值的来龙去脉,从 hermes_cli/env_loader.py 的启动装载路径读起,它有个按家目录去重的一次性守卫,也是「为什么状态行只打印一次」的答案。想评估多档案部署,agent/secret_scope.py 全文不长,值得从头读到尾,尤其是那两处解释「为什么这里必须回落」的注释。

自检三问:你的 Agent 派生子进程时,是把完整环境变量抄过去,还是显式列了白名单?两个凭证来源撞同一个变量名时,你能不能在日志里看到谁赢了?如果一个进程要服务多个用户,读不到作用域的时候它是抛异常,还是随手读进程环境?三问里有一个答不上来,就说明这套机制里有一部分你还得自己补。

本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的可观测四块拼图开源自托管 Agent 项目 Hermes Agent 怎么把网络出口关小

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。