Academy → Developer GuideOfficial documentation · Arabic guidance

Secret Source Plugins

إضافات مصادر الأسرار

Advanced7 min readLesson 174 questions✓ 2026-08-18
Before you read

What this page is, and what it holds.

This page covers Secret Source Plugins. About 7 minutes to read. Never put a key in a chat or in a config file you share. Use environment variables or a secret manager.

9sections
5code examples
3tables
0commands
1,167source words
The official one-line description

How to build a secret-manager backend plugin for Hermes Agent

What you will be able to do

Outcomes taken from this page, not a template.

  • Understand what الأسرار والمفاتيح is and when you need it.
  • Read the table and take only the row that applies to you.
  • Set MYVAULT_TOKEN in the right place.
Identifiers you will meet

Exactly as they appear in Hermes.

Environment variables
  • MYVAULT_TOKEN
  • NOT_CONFIGURED
  • BINARY_MISSING
  • AUTH_FAILED
  • ENV_VAR
  • SECRET_SOURCE_API_VERSION
Page map

Jump to the part you need.

  1. 01First-process bootstrap timing
  2. 02What the framework owns vs. what you own
  3. 03Directory structure
  4. 04The SecretSource ABC
  5. 05Subprocess safety: use `runsecretcli()`
  6. 06Registering
  7. 07Users configure it like any other source
  8. 08Validate with the conformance kit
  9. 09ErrorKind reference
The full official page

Nothing summarised away.

The documentation body below is reproduced from the official source so commands and identifiers stay exact. Each section carries a short note describing what it contains.

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.

First-process bootstrap timing

Explains the idea itself. Read it slowly; the later sections build on it.

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

A lookup table. Do not read it all; find the row that applies to you.

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

Framework ownsYou own
Source ordering, mapped-vs-bulk precedenceFetching values from your backend
First-claim-wins conflict handling + warningsValidating your reference format
override_existing semantics (never crosses sources)Talking to your CLI/SDK/API
Protected bootstrap tokensDeclaring which env var IS your bootstrap token
Per-source wall-clock timeoutKeeping fetch() reasonably fast
Per-var provenance + (from X) labelsA human-readable label
os.environ writesNothing — you never touch the environment

Directory structure

Explains the idea itself. Read it slowly; the later sections build on it.

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

The SecretSource ABC

A lookup table. Do not read it all; find the row that applies to you.

Implement agent.secret_sources.base.SecretSource. One method is required:

Python47 lines
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

MethodDefaultOverride 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)emptyYou have a bootstrap token (you almost certainly do)
fetch_timeout_seconds(cfg)120sYour backend needs a different budget
config_schema(){}Declare config keys for setup surfaces
remediation(kind, cfg)generic per-ErrorKind hintsYou 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 `runsecretcli()`

Explains the idea itself. Read it slowly; the later sections build on it.

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

Explains the idea itself. Read it slowly; the later sections build on it.

Python3 lines
# __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.

Users configure it like any other source

Settings you configure once. Change one at a time so you can see what each does.

YAML5 lines
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 ↗ for the precedence ladder.

Validate with the conformance kit

Explains the idea itself. Read it slowly; the later sections build on it.

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

Python7 lines

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

A troubleshooting section. Find the symptom that matches yours rather than reading it end to end.

KindMeaning
NOT_CONFIGUREDEnabled but missing token / project / map
BINARY_MISSINGHelper CLI not found or not executable
AUTH_FAILED / AUTH_EXPIREDBad / expired credentials
REF_INVALIDA secret reference failed validation
NETWORKTransport-level failure
EMPTY_VALUEBackend returned nothing for a ref — never apply "" over a good credential
TIMEOUTFetch exceeded its budget
INTERNALAnything else (bug, unexpected shape)
Knowledge check

4 questions answered by this page alone.

Every option is a real identifier from the Hermes documentation. The wrong ones are real too, just from other pages.

1. In this lesson's table, what is the “You own” for “Per-source wall-clock timeout”?
2. Which of these environment variables actually appears in this lesson?
3. Which of these headings does not appear in this lesson?
4. Which configuration key appears in this lesson's examples?