File size: 10,524 Bytes
6aa76ed | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | ---
sidebar_position: 9
title: "Secret Source Plugins"
description: "How to build a secret-manager backend plugin for Hermes Agent"
---
# 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/.env` loads, before Hermes reads credentials. Bitwarden, 1Password, and a generic command-helper source ship in-tree; **every other backend is a plugin**. This guide covers building one.
:::tip
The bundled set is deliberately closed, same policy as [memory providers](/developer-guide/memory-provider-plugin): PRs adding new vault backends under `agent/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`).
:::
## First-process bootstrap timing
`load_hermes_dotenv()` often runs at import time **before** plugins register.
Hermes then re-pulls secrets after plugin discovery when any **enabled**
plugin secret source is configured. Enablement uses the source's
`is_enabled(cfg)` contract; the standard form is
`secrets.<name>.enabled: true`, while custom activation remains supported.
That closes the "replace Bitwarden with my vault" first-process gap (#64177).
- Re-pull is idempotent and fail-open (never blocks startup).
- Sources only supply env vars through the orchestrator; there is **no**
plugin API to dump other plugins' or the user's entire secret store beyond
what your source's own config allows.
- Reading `os.environ` after load is possible for any in-process code — the
trust boundary remains "enabled plugins run with agent privilege".
## 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:
| 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_existing` semantics (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 | Keeping `fetch()` reasonably fast |
| Per-var provenance + `(from X)` labels | A human-readable `label` |
| `os.environ` writes | Nothing — you never touch the environment |
## Directory structure
```
~/.hermes/plugins/my-vault/
├── plugin.yaml # name, description
└── __init__.py # SecretSource subclass + register(ctx)
```
## The SecretSource ABC
Implement `agent.secret_sources.base.SecretSource`. One method is required:
```python
from pathlib import Path
from 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()` never raises.** Errors go in `result.error` + `result.error_kind`. A raising fetch is contained by the orchestrator and reported as `INTERNAL` — a contract violation, not a feature.
- **`fetch()` never prompts.** Startup runs in non-TTY contexts (gateway, cron, Docker). `run_secret_cli()` closes stdin so a prompting helper fails fast. Interactive auth belongs in your CLI setup flow, never on the startup path.
- **Sync, within budget.** The orchestrator enforces a wall-clock timeout (default 120s, user-tunable via `secrets.<name>.timeout_seconds`). Exceeding it reports `TIMEOUT` and your result is discarded.
- **You fetch; the orchestrator applies.** Return the mapping you *would* contribute. Never write `os.environ` yourself — you'd bypass precedence, conflict detection, and provenance.
- **API versioning.** `SecretSource.api_version` defaults to the current `SECRET_SOURCE_API_VERSION`. The registry skips (with a warning) sources built against a different version instead of crashing startup.
### Choosing your `shape`
- `mapped` — the user explicitly binds env-var names to references in config (like 1Password's `env:` map). Strongest intent: mapped claims beat bulk claims on contested vars.
- `bulk` — you inject a whole project/folder of secrets implicitly (like Bitwarden BSM). Yields to mapped sources.
### 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 default `True` for 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 |
| `remediation(kind, cfg)` | generic per-`ErrorKind` hints | You want failure warnings to point at your own fix-it command (e.g. the bundled sources return `Run hermes secrets <name> token…` for `AUTH_FAILED`). Must be a pure kind→string mapping: no I/O, never raises. Return `""` to suppress the hint. |
## Subprocess safety: use `run_secret_cli()`
If your backend shells out to a CLI, use the shared helper instead of `subprocess.run` directly. It gives you the audited posture for free: argv-only (no `shell=True`), a **minimal allowlisted child environment** (by the time sources run, `os.environ` holds every credential Hermes knows — never hand that to a child process), `NO_COLOR` + ANSI-scrubbed stderr, stdin closed, timeout → clean `RuntimeError`. Pass user-supplied reference strings after a `--` terminator in your argv so they can never parse as flags.
## Registering
```python
# __init__.py
def register(ctx):
ctx.register_secret_source(MyVaultSource())
```
Registration is rejected (with a log warning, never a crash) for: non-`SecretSource` instances, invalid/duplicate names, a `scheme` another source owns, wrong `api_version`, or a `shape` outside `mapped`/`bulk`.
:::note Timing
Plugin discovery runs later in startup than the first `load_hermes_dotenv()` call. Immediately after discovery, Hermes re-pulls enabled plugin secret sources (`reset_secret_source_cache()` + `load_hermes_dotenv()`), so the discovering process *does* pick them up — see [First-process bootstrap timing](#first-process-bootstrap-timing) above (#64177). The re-pull is fail-open and skipped when no plugin source is enabled. Any code that reads `os.environ` during the plugin module's import or `register(ctx)` still runs before the re-pull and cannot depend on credentials supplied by that same source; keep credentialed work inside `fetch()`. Gateway, cron, and subagent processes perform the same discovery/re-pull sequence. The re-pull (and the per-fire cron re-pull) resets only the resolving home's cache, so under a multiplex gateway sibling profiles keep their hydrated snapshots; and a re-pull whose keys already sit in the process environment (`skipped_existing`, e.g. the previous apply's own write-back) still records the home's effective values, so `override_existing` is never required just to survive a re-pull.
:::
## Users configure it like any other source
```yaml
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 the [user-facing secrets docs](/user-guide/secrets/) for the precedence ladder.
## Validate with the conformance kit
Subclass the kit from the Hermes repo (`tests/secret_sources/conformance.py`) in your plugin's tests:
```python
import pytest
from tests.secret_sources.conformance import SecretSourceConformance
class 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 full `apply_all()` round trip. Green conformance is the review bar for calling a backend contract-compliant.
## 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) |
|