Building a Secret Source Plugin

Secret sources resolve provider credentials from an external secret manager (a vault, a password manager, an OS keystore, a custom script) into environment variables at process startup — after~/.hermes/.envloads, before Hermes reads credentials. Bitwarden and 1Password ship in-tree;every other backend is a plugin. This guide covers building one.

~/.hermes/.env

The bundled set is deliberately closed, same policy asmemory providers: PRs adding new vault backends underagent/secret_sources/are closed with a pointer to this guide. Publish your backend as a standalone plugin repo and share it in the Nous Research Discord (#plugins-skills-and-skins).

agent/secret_sources/ #plugins-skills-and-skins

What the framework owns vs. what you own​

The orchestrator (agent.secret_sources.registry.apply_all) owns everything security- and precedence-sensitive, so a backend cannot get it wrong:

agent.secret_sources.registry.apply_all | Framework owns | You own | | — | — | | Source ordering, mapped-vs-bulk precedence | Fetching values from your backend | | First-claim-wins conflict handling + warnings | Validating your reference format | | override_existingsemantics (never crosses sources) | Talking to your CLI/SDK/API | | Protected bootstrap tokens | Declaring which env var IS your bootstrap token | | Per-source wall-clock timeout | Keepingfetch()reasonably fast | | Per-var provenance +(from X)labels | A human-readablelabel | | os.environwrites | Nothing — you never touch the environment |

override_existing fetch() (from X) label os.environ

Directory structure​

~/.hermes/plugins/my-vault/├── plugin.yaml      # name, description└── __init__.py      # SecretSource subclass + register(ctx)

The SecretSource ABC​

Implementagent.secret_sources.base.SecretSource. One method is required:

agent.secret_sources.base.SecretSource

from pathlib import Pathfrom agent.secret_sources.base import (    ErrorKind,    FetchResult,    SecretSource,    run_secret_cli,)class MyVaultSource(SecretSource):    name = "myvault"          # config section key: secrets.myvault    label = "My Vault"        # used in startup lines + provenance labels    shape = "mapped"          # "mapped" (explicit VAR→ref map) or "bulk" (project dump)    scheme = "mv"             # optional: unique URI scheme you own (mv://...)    def fetch(self, cfg: dict, home_path: Path) -> FetchResult:        """Resolve secrets. MUST NOT raise. MUST NOT prompt."""        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"],   # ONLY your auth vars — never full os.environ                timeout=30,            )        except RuntimeError as exc:           # spawn failure / timeout            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):        # Your bootstrap token — no source (including yours) may ever overwrite it.        return frozenset({"MYVAULT_TOKEN"})

Contract rules (enforced, not suggestions)​

fetch() result.error result.error_kind INTERNAL fetch() run_secret_cli() secrets.<name>.timeout_seconds TIMEOUT os.environ SecretSource.api_version SECRET_SOURCE_API_VERSION

Choosing yourshape​

shape

mapped env: bulk

Optional hooks​

Method Default Override when
is_enabled(cfg) cfg.get(“enabled”) Custom activation logic
override_existing(cfg) cfg.get(“override_existing”, False) You want a different default (both bundled sources defaultTruefor rotation)
protected_env_vars(cfg) empty You have a bootstrap token (you almost certainly do)
fetch_timeout_seconds(cfg) 120s Your backend needs a different budget
config_schema() {} Declare config keys for setup surfaces

is_enabled(cfg) cfg.get("enabled") override_existing(cfg) cfg.get("override_existing", False) True protected_env_vars(cfg) fetch_timeout_seconds(cfg) config_schema() {}

Subprocess safety: userun_secret_cli()​

run_secret_cli()

If your backend shells out to a CLI, use the shared helper instead ofsubprocess.rundirectly. It gives you the audited posture for free: argv-only (noshell=True), aminimal allowlisted child environment(by the time sources run,os.environholds every credential Hermes knows — never hand that to a child process),NO_COLOR+ ANSI-scrubbed stderr, stdin closed, timeout → cleanRuntimeError. Pass user-supplied reference strings after a–terminator in your argv so they can never parse as flags.

subprocess.run shell=True os.environ NO_COLOR RuntimeError --

Registering​

# __init__.pydef register(ctx):    ctx.register_secret_source(MyVaultSource())

Registration is rejected (with a log warning, never a crash) for: non-SecretSourceinstances, invalid/duplicate names, aschemeanother source owns, wrongapi_version, or ashapeoutsidemapped/bulk.

SecretSource scheme api_version shape mapped bulk

Plugin discovery runs later in startup than the firstload_hermes_dotenv()call, so a plugin source is not consulted by the very first env load of the process that discovers it. It IS consulted by every subsequently spawned Hermes process (gateway children, cron sessions, subagents). Bundled sources cover first-process bootstrap.

load_hermes_dotenv()

Users configure it like any other source​

secrets:  sources: [myvault, bitwarden]   # optional ordering  myvault:    enabled: true    # ... your config_schema keys

Multi-source precedence, conflict warnings, and(from My Vault)provenance labels all work automatically — see theuser-facing secrets docsfor the precedence ladder.

(from My Vault)

Validate with the conformance kit​

Subclass the kit from the Hermes repo (tests/secret_sources/conformance.py) in your plugin’s tests:

tests/secret_sources/conformance.py

import pytestfrom tests.secret_sources.conformance import SecretSourceConformanceclass TestMyVaultConformance(SecretSourceConformance):    @pytest.fixture    def source(self):        return MyVaultSource()

It checks the rules that break other people when violated: never-raises on malformed config, machine-readable error kinds, disabled-by-default, positive timeouts, valid protected-var names, and a fullapply_all()round trip. Green conformance is the review bar for calling a backend contract-compliant.

apply_all()

ErrorKind reference​

Kind Meaning
NOT_CONFIGURED Enabled but missing token / project / map
BINARY_MISSING Helper CLI not found or not executable
AUTH_FAILED/AUTH_EXPIRED Bad / expired credentials
REF_INVALID A secret reference failed validation
NETWORK Transport-level failure
EMPTY_VALUE Backend returned nothing for a ref — never apply”“over a good credential
TIMEOUT Fetch exceeded its budget
INTERNAL Anything else (bug, unexpected shape)

NOT_CONFIGURED BINARY_MISSING AUTH_FAILED AUTH_EXPIRED REF_INVALID NETWORK EMPTY_VALUE "" TIMEOUT INTERNAL