构建机密源插件

机密源在进程启动时,把外部机密管理器(vault、密码管理器、操作系统密钥库、自定义脚本)中的提供商凭据解析为环境变量——时机在 ~/.hermes/.env 加载之后、Hermes 读取凭据之前。Bitwarden、1Password 和一个通用命令辅助源随仓库发布;其他一切后端都是插件。本指南介绍如何构建一个。

TIP

内置集合刻意保持封闭,政策与记忆提供商相同:在 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、意外形态)