构建机密源插件
机密源在进程启动时,把外部机密管理器(vault、密码管理器、操作系统密钥库、自定义脚本)中的提供商凭据解析为环境变量——时机在 ~/.hermes/.env 加载之后、Hermes 读取凭据之前。Bitwarden、1Password 和一个通用命令辅助源随仓库发布;其他一切后端都是插件。本指南介绍如何构建一个。
内置集合刻意保持封闭,政策与记忆提供商相同:在 agent/secret_sources/ 下新增 vault 后端的 PR 会被关闭,并指向本指南。请把你的后端发布为独立插件仓库,并在 Nous Research Discord(#plugins-skills-and-skins)分享。
首进程启动时机 {#first-process-bootstrap-timing}
load_hermes_dotenv() 常在 import 时运行,早于插件注册。随后,当配置了任何已启用的插件机密源时,Hermes 会在插件发现之后重新拉取机密。启用采用该源的 is_enabled(cfg) 契约;标准形式是 secrets.<name>.enabled: true,同时仍支持自定义激活方式。这补上了"用我的 vault 替换 Bitwarden"的首进程缺口(#64177)。
- 重新拉取是幂等且失败即开放的(绝不阻塞启动)。
hermes update从不解析外部源——无论是在更新器进程中,还是在它派生的 import 健康探针里。更新路径中没有任何东西需要凭据,否则一个慢辅助进程会被误报为 import 失败(#110823)。- 机密源只通过编排器提供环境变量;没有插件 API 能转储其他插件或用户整个机密存储,除非你自己源的配置允许。
- 加载后任何进程内代码都可读
os.environ——信任边界仍是"已启用插件以智能体特权运行"。
框架负责什么 vs 你负责什么 {#what-the-framework-owns-vs-what-you-own}
编排器(agent.secret_sources.registry.apply_all)负责一切涉及安全和优先级的事,因此后端不会搞错:
| 框架负责 | 你负责 |
|---|---|
| 源排序、mapped 与 bulk 的优先级 | 从你的后端取值 |
| 先到先得的冲突处理 + 警告 | 校验你的引用格式 |
override_existing 语义(绝不跨源) | 与你的 CLI/SDK/API 通信 |
| 受保护的启动 token | 声明哪个环境变量是你的启动 token |
| 按源的墙钟超时 | 让 fetch() 足够快 |
按变量的来源 + (from X) 标签 | 一个人类可读的 label |
写 os.environ | 无——你绝不碰环境 |
目录结构 {#directory-structure}
~/.hermes/plugins/my-vault/
├── plugin.yaml # 名称、描述
└── __init__.py # SecretSource 子类 + register(ctx)
SecretSource 抽象基类 {#the-secretsource-abc}
实现 agent.secret_sources.base.SecretSource。必须实现一个方法:
from pathlib import Path
from agent.secret_sources.base import (
ErrorKind,
FetchResult,
SecretSource,
run_secret_cli,
)
class MyVaultSource(SecretSource):
name = "myvault" # 配置段键:secrets.myvault
label = "My Vault" # 用于启动行 + 来源标签
shape = "mapped" # "mapped"(显式 VAR→ref 映射)或 "bulk"(项目整体导出)
scheme = "mv" # 可选:你拥有的唯一 URI scheme(mv://...)
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
"""解析机密。绝不能抛异常。绝不能提示输入。"""
result = FetchResult()
token = os.environ.get("MYVAULT_TOKEN", "").strip()
if not token:
result.error = "secrets.myvault.enabled is true but MYVAULT_TOKEN is not set."
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
try:
proc = run_secret_cli(
["myvault-cli", "export", "--json"],
allow_env=["MYVAULT_TOKEN"], # 只放你的鉴权变量——绝不要整个 os.environ
timeout=30,
)
except RuntimeError as exc: # 派生失败 / 超时
result.error = str(exc)
result.error_kind = ErrorKind.BINARY_MISSING
return result
if proc.returncode != 0:
result.error = f"myvault-cli exited {proc.returncode}: {proc.stderr[:200]}"
result.error_kind = ErrorKind.AUTH_FAILED
return result
result.secrets = parse_your_output(proc.stdout) # {ENV_VAR: value}
return result
def protected_env_vars(self, cfg: dict):
# 你的启动 token——任何源(包括你自己)都绝不能覆盖它。
return frozenset({"MYVAULT_TOKEN"})
契约规则(强制执行,不是建议){#contract-rules-enforced-not-suggestions}
fetch()绝不抛异常。 错误放进result.error+result.error_kind。一个抛异常的 fetch 会被编排器兜住并报告为INTERNAL——这是违反契约,不是功能。fetch()绝不提示输入。 启动运行在非 TTY 上下文(网关、cron、Docker)。run_secret_cli()关闭 stdin,因此一个会提示输入的辅助进程会快速失败。交互式鉴权属于你的 CLI 设置流程,绝不在启动路径上。- 同步,在预算内。 编排器强制执行墙钟超时(默认 120 秒,可经
secrets.<name>.timeout_seconds调)。超时则报告TIMEOUT,你的结果被丢弃。 - 你来取;编排器来应用。 返回你本想贡献的映射。绝不自己写
os.environ——否则你会绕过优先级、冲突检测和来源记录。 - API 版本。
SecretSource.api_version默认为当前SECRET_SOURCE_API_VERSION。注册表会跳过(带警告)针对不同版本构建的源,而非让启动崩溃。
选择你的 shape {#choosing-your-shape}
mapped——用户在配置中显式把环境变量名绑定到引用(如 1Password 的env:映射)。意图最强:在有争议的变量上,mapped 声明胜过 bulk 声明。bulk——你隐式注入整个项目/文件夹的机密(如 Bitwarden BSM)。让位给 mapped 源。
可选钩子 {#optional-hooks}
| 方法 | 默认 | 何时覆盖 |
|---|---|---|
is_enabled(cfg) | cfg.get("enabled") | 自定义激活逻辑 |
override_existing(cfg) | cfg.get("override_existing", False) | 你想要不同默认值(两个内置源为轮换默认为 True) |
protected_env_vars(cfg) | 空 | 你有一个启动 token(你几乎肯定有) |
fetch_timeout_seconds(cfg) | 120 秒 | 你的后端需要不同预算 |
config_schema() | {} | 为设置界面声明配置键 |
remediation(kind, cfg) | 通用的按 ErrorKind 提示 | 你想让失败警告指向你自己的修复命令(如内置源对 AUTH_FAILED 返回 Run hermes secrets <name> token…)。必须是纯 kind→字符串映射:无 I/O、绝不抛异常。返回 "" 可抑制提示。 |
子进程安全:用 run_secret_cli() {#subprocess-safety-use-run_secret_cli}
如果你的后端要 shell out 调用 CLI,请用共享辅助函数,而非直接 subprocess.run。它免费给你经过审计的姿态:仅 argv(无 shell=True)、一个最小的白名单子进程环境(等源运行时,os.environ 已持有 Hermes 知道的每个凭据——绝不要把它交给子进程)、NO_COLOR + 去除 ANSI 的 stderr、stdin 关闭、超时 → 干净的 RuntimeError。把用户提供的引用字符串放在 argv 中一个 -- 分隔符之后,使它们绝不可能被解析成标志。
注册 {#registering}
# __init__.py
def register(ctx):
ctx.register_secret_source(MyVaultSource())
以下情况注册会被拒绝(记日志警告,绝不崩溃):非 SecretSource 实例、无效/重复名称、被另一个源占用的 scheme、错误的 api_version、或 mapped/bulk 之外的 shape。
插件发现在启动中晚于第一次 load_hermes_dotenv() 调用。发现之后,Hermes 立即重新拉取已启用的插件机密源(reset_secret_source_cache() + load_hermes_dotenv()),因此发起发现的那个进程确实会拿到它们——见上文首进程启动时机(#64177)。重新拉取失败即开放,且在没有插件源启用时跳过。任何在插件模块 import 或 register(ctx) 期间读 os.environ 的代码仍跑在重新拉取之前,不能依赖同一次源提供的凭据;把需要凭据的工作留在 fetch() 里。网关、cron 和子智能体进程执行同样的发现/重新拉取序列。重新拉取(以及每次触发的 cron 重新拉取)只重置正在解析的那个 home 的缓存,因此多路复用网关下兄弟 profile 保留它们已加载的快照;而一次其键已在进程环境中的重新拉取(skipped_existing,例如上一次 apply 自己的回写)仍记录该 home 的有效值,因此绝不需要仅为扛过一次重新拉取而设 override_existing。
用户像配置其他源一样配置它 {#users-configure-it-like-any-other-source}
secrets:
sources: [myvault, bitwarden] # 可选排序
myvault:
enabled: true
# ... 你的 config_schema 键
多源优先级、冲突警告和 (from My Vault) 来源标签全部自动生效——优先级阶梯见面向用户的机密文档。
用一致性套件验证 {#validate-with-the-conformance-kit}
在你的插件测试中,从 Hermes 仓库(tests/secret_sources/conformance.py)子类化该套件:
import pytest
from tests.secret_sources.conformance import SecretSourceConformance
class TestMyVaultConformance(SecretSourceConformance):
@pytest.fixture
def source(self):
return MyVaultSource()
它检查那些一旦违反就会影响他人的规则:畸形配置下绝不抛异常、机器可读的错误类别、默认禁用、正数超时、受保护变量名合法,以及一次完整的 apply_all() 往返。一致性测试全绿是判定一个后端符合契约的评审门槛。
ErrorKind 参考 {#errorkind-reference}
| 类别 | 含义 |
|---|---|
NOT_CONFIGURED | 已启用但缺少 token / 项目 / 映射 |
BINARY_MISSING | 辅助 CLI 未找到或不可执行 |
AUTH_FAILED / AUTH_EXPIRED | 凭据错误 / 过期 |
REF_INVALID | 某个机密引用未通过校验 |
NETWORK | 传输层故障 |
EMPTY_VALUE | 后端对某引用返回空——绝不要用 "" 覆盖一个好凭据 |
TIMEOUT | 取数超出预算 |
INTERNAL | 其他一切(bug、意外形态) |