mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Introduces a first-class secret-source contract so password managers (Bitwarden today, 1Password next, third-party vaults as plugins) plug into one orchestrated startup path instead of each hardcoding into env_loader. - agent/secret_sources/base.py: SecretSource ABC (fetch-only contract: never raises, never prompts, sync with orchestrator-enforced timeout), shared ErrorKind taxonomy, FetchResult, run_secret_cli() minimal-env subprocess helper, API versioning for plugin compatibility. - agent/secret_sources/registry.py: registration gating (name/scheme uniqueness, api_version, shape), apply_all() orchestrator owning precedence (mapped-beats-bulk, first-claim-wins, override_existing never crosses sources, protected bootstrap tokens), conflict warnings, per-var provenance, per-source wall-clock timeout. - Bitwarden converted to a registered BitwardenSource (bulk shape); behavior unchanged, apply_bitwarden_secrets kept as legacy shim. - env_loader._apply_external_secret_sources now drives the orchestrator; provenance labels resolve through registry (e.g. '(from 1Password)'). - PluginContext.register_secret_source() for external backends. - secrets.sources optional ordering key in DEFAULT_CONFIG + example. - tests/secret_sources/: 47 new tests incl. reusable conformance kit (SecretSourceConformance) that plugin authors run against their source.
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""Secret-source contract: the ABC every secret backend implements.
|
|
|
|
A *secret source* resolves credentials from an external secret manager
|
|
(Bitwarden Secrets Manager, 1Password, an OS keystore, a user script, ...)
|
|
into environment-variable-shaped values at process startup, AFTER
|
|
``~/.hermes/.env`` has loaded and BEFORE the rest of Hermes reads
|
|
``os.environ``.
|
|
|
|
Scope of the contract (deliberate, please do not widen):
|
|
|
|
* **Read-only.** Sources resolve refs → values. There is no write-back
|
|
("save this key to your vault"), no arbitrary secret objects, and no
|
|
mid-session secret API. If a future need for rotation/refresh appears
|
|
it will arrive as a versioned optional hook — do not bolt it on.
|
|
* **Startup-time, synchronous.** ``fetch()`` is called once per process
|
|
(per HERMES_HOME) by the orchestrator in
|
|
:mod:`agent.secret_sources.registry`, which enforces a wall-clock
|
|
timeout around it. Sources must not spawn background refreshers.
|
|
* **Never raises, never prompts.** ``fetch()`` returns a
|
|
:class:`FetchResult` — errors go in ``result.error`` with a
|
|
machine-readable :class:`ErrorKind`. Interactive auth belongs in the
|
|
source's CLI ``setup`` flow, never on the startup path (non-TTY
|
|
gateway/cron startup must never block on stdin).
|
|
* **Sources fetch; the orchestrator applies.** A source returns the
|
|
name→value mapping it *would* contribute. Precedence (mapped-beats-bulk,
|
|
first-wins, ``override_existing``, protected vars), conflict warnings,
|
|
provenance tracking, and the actual ``os.environ`` writes are owned by
|
|
the orchestrator so no backend can get them wrong.
|
|
|
|
Versioning: ``SECRET_SOURCE_API_VERSION`` gates plugin compatibility.
|
|
New *optional* hooks with default implementations do not bump it;
|
|
required-signature changes do, and the registry skips (with a warning)
|
|
sources built against a different major version instead of crashing
|
|
startup.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from pathlib import Path
|
|
from typing import Dict, FrozenSet, List, Optional, Sequence
|
|
|
|
# Bump ONLY for breaking changes to the required contract surface
|
|
# (abstract-method signatures, FetchResult required fields). Additive
|
|
# optional hooks must ship with defaults and must NOT bump this.
|
|
SECRET_SOURCE_API_VERSION = 1
|
|
|
|
# Timeout the orchestrator enforces around fetch() when the source's
|
|
# config section doesn't override it. Generous because a first run may
|
|
# include a one-time CLI binary auto-install (e.g. bws download+verify).
|
|
DEFAULT_FETCH_TIMEOUT_SECONDS = 120.0
|
|
|
|
# Default timeout for run_secret_cli() subprocess invocations.
|
|
DEFAULT_CLI_TIMEOUT_SECONDS = 30.0
|
|
|
|
|
|
class ErrorKind(str, Enum):
|
|
"""Machine-readable failure taxonomy for :class:`FetchResult.error`.
|
|
|
|
A fixed vocabulary keeps startup warnings and ``hermes secrets status``
|
|
uniform across backends, and lets the orchestrator implement
|
|
kind-dependent policy (e.g. a future stale-cache fallback on
|
|
``NETWORK``/``TIMEOUT`` but not on ``AUTH_FAILED``) exactly once.
|
|
"""
|
|
|
|
NOT_CONFIGURED = "not_configured" # enabled but missing token/project/map
|
|
BINARY_MISSING = "binary_missing" # helper CLI not found / not installed
|
|
AUTH_FAILED = "auth_failed" # bad credentials
|
|
AUTH_EXPIRED = "auth_expired" # credentials were valid, aren't now
|
|
REF_INVALID = "ref_invalid" # a secret reference failed validation
|
|
NETWORK = "network" # transport-level failure
|
|
EMPTY_VALUE = "empty_value" # backend returned nothing for a ref
|
|
TIMEOUT = "timeout" # fetch exceeded its wall-clock budget
|
|
INTERNAL = "internal" # anything else (bug, unexpected shape)
|
|
|
|
|
|
@dataclass
|
|
class FetchResult:
|
|
"""Outcome of one source's fetch.
|
|
|
|
``secrets`` holds what the source *would* contribute; whether each
|
|
var is actually applied is the orchestrator's decision. ``applied``
|
|
and ``skipped`` exist for backward compatibility with the original
|
|
Bitwarden fetch-and-apply entry point and are left empty by
|
|
conforming ``fetch()`` implementations.
|
|
"""
|
|
|
|
secrets: Dict[str, str] = field(default_factory=dict)
|
|
applied: List[str] = field(default_factory=list)
|
|
skipped: List[str] = field(default_factory=list)
|
|
warnings: List[str] = field(default_factory=list)
|
|
error: Optional[str] = None
|
|
error_kind: Optional[ErrorKind] = None
|
|
# Path of the helper binary used, when the source is CLI-driven.
|
|
# Surfaced by status commands; None for SDK/API-driven sources.
|
|
binary_path: Optional[Path] = None
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.error is None
|
|
|
|
|
|
class SecretSource(ABC):
|
|
"""One external secret backend.
|
|
|
|
Subclasses set the class attributes and implement :meth:`fetch`.
|
|
Everything else has a sensible default.
|
|
|
|
Attributes:
|
|
name: Config-section key under ``secrets:`` in config.yaml.
|
|
Lowercase ``[a-z0-9_]+``. Also the provenance label stored
|
|
for every var this source supplies.
|
|
label: Human-readable name used in startup messages and
|
|
``hermes secrets status`` (e.g. ``"Bitwarden Secrets Manager"``).
|
|
shape: ``"mapped"`` when the user explicitly binds env-var names
|
|
to refs (1Password ``env:`` map, command source) or
|
|
``"bulk"`` when the backend injects whole projects/folders
|
|
of secrets implicitly (Bitwarden BSM). The orchestrator
|
|
gives mapped sources precedence over bulk sources: an
|
|
explicit binding is stronger intent than a project dump.
|
|
scheme: Optional URI scheme this source owns for secret
|
|
references (``"op"`` for ``op://...``). Must be unique
|
|
across registered sources — refs may eventually appear
|
|
outside the ``secrets:`` block (e.g. credential-pool
|
|
``api_key`` fields), so scheme collisions are rejected at
|
|
registration time to keep that future possible.
|
|
api_version: Contract version this source was built against.
|
|
"""
|
|
|
|
api_version: int = SECRET_SOURCE_API_VERSION
|
|
name: str = ""
|
|
label: str = ""
|
|
shape: str = "mapped" # "mapped" | "bulk"
|
|
scheme: Optional[str] = None
|
|
|
|
# -- required ----------------------------------------------------------
|
|
|
|
@abstractmethod
|
|
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
|
|
"""Resolve this source's secrets. MUST NOT raise or prompt.
|
|
|
|
``cfg`` is the source's raw config section (``secrets.<name>``)
|
|
from config.yaml — treat every field defensively, the section
|
|
may be malformed. ``home_path`` is the resolved HERMES_HOME.
|
|
"""
|
|
|
|
# -- optional hooks (defaults are correct for most sources) ------------
|
|
|
|
def is_enabled(self, cfg: dict) -> bool:
|
|
"""Whether the user turned this source on."""
|
|
return bool(isinstance(cfg, dict) and cfg.get("enabled"))
|
|
|
|
def override_existing(self, cfg: dict) -> bool:
|
|
"""May this source overwrite vars that .env / the shell already set?
|
|
|
|
This NEVER extends to vars claimed by another secret source in the
|
|
same startup pass — cross-source overrides are a config error the
|
|
orchestrator warns about, not a knob.
|
|
"""
|
|
return bool(isinstance(cfg, dict) and cfg.get("override_existing", False))
|
|
|
|
def protected_env_vars(self, cfg: dict) -> FrozenSet[str]:
|
|
"""Env vars the orchestrator must never let ANY source overwrite.
|
|
|
|
Typically the source's own bootstrap-auth var (e.g.
|
|
``BWS_ACCESS_TOKEN``) so a vault that contains its own access
|
|
token can't clobber the credential used to reach it.
|
|
"""
|
|
return frozenset()
|
|
|
|
def fetch_timeout_seconds(self, cfg: dict) -> float:
|
|
"""Wall-clock budget the orchestrator enforces around fetch()."""
|
|
try:
|
|
val = float((cfg or {}).get("timeout_seconds", DEFAULT_FETCH_TIMEOUT_SECONDS))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_FETCH_TIMEOUT_SECONDS
|
|
return val if val > 0 else DEFAULT_FETCH_TIMEOUT_SECONDS
|
|
|
|
def config_schema(self) -> dict:
|
|
"""Optional description of this source's config keys.
|
|
|
|
Shape: ``{key: {"description": str, "default": Any}}``. Used by
|
|
setup surfaces to render config without hardcoding per-source
|
|
knowledge. Purely informational.
|
|
"""
|
|
return {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared helpers — use these instead of hand-rolling per backend
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
|
|
# ANSI CSI/OSC escape sequences — helper-CLI stderr often carries color
|
|
# codes that must not reach Hermes' own startup output.
|
|
_ANSI_RE = re.compile(r"\x1b(?:\[[0-9;?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?)")
|
|
|
|
|
|
def is_valid_env_name(name: str) -> bool:
|
|
"""True when ``name`` is a legal environment-variable name."""
|
|
return bool(name) and bool(_ENV_NAME_RE.match(name))
|
|
|
|
|
|
def scrub_ansi(text: str) -> str:
|
|
"""Strip ANSI escape sequences (whole CSI/OSC sequences, not just ESC)."""
|
|
return _ANSI_RE.sub("", text or "")
|
|
|
|
|
|
def run_secret_cli(
|
|
argv: Sequence[str],
|
|
*,
|
|
allow_env: Sequence[str] = (),
|
|
extra_env: Optional[Dict[str, str]] = None,
|
|
timeout: float = DEFAULT_CLI_TIMEOUT_SECONDS,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a secret-manager helper CLI with a minimal, allowlisted env.
|
|
|
|
Security posture shared by every subprocess-driven backend:
|
|
|
|
* argv list only — never ``shell=True``. Callers pass user-supplied
|
|
reference strings AFTER a ``--`` option terminator in their argv.
|
|
* The child gets ``PATH``/``HOME``/locale basics plus only the env
|
|
vars named in ``allow_env`` (auth/session vars) and ``extra_env``
|
|
— never a copy of the full post-dotenv ``os.environ``, which by
|
|
this point holds every credential Hermes knows about.
|
|
* ``NO_COLOR=1`` is set and stderr/stdout are ANSI-scrubbed so
|
|
helper diagnostics can't smuggle escape sequences into Hermes
|
|
output.
|
|
* stdin is ``/dev/null`` so a helper that decides to prompt fails
|
|
fast instead of hanging startup.
|
|
|
|
Raises ``RuntimeError`` on spawn failure or timeout (message safe to
|
|
surface); returns the completed process otherwise — callers own
|
|
returncode interpretation.
|
|
"""
|
|
base_keep = ("PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TMPDIR", "TEMP",
|
|
"LANG", "LC_ALL", "XDG_CONFIG_HOME", "XDG_DATA_HOME")
|
|
env: Dict[str, str] = {}
|
|
for key in (*base_keep, *allow_env):
|
|
val = os.environ.get(key)
|
|
if val is not None:
|
|
env[key] = val
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
env.setdefault("NO_COLOR", "1")
|
|
|
|
try:
|
|
proc = subprocess.run( # noqa: S603 — argv list, no shell
|
|
list(argv),
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
raise RuntimeError(
|
|
f"{Path(str(argv[0])).name} timed out after {timeout:.0f}s"
|
|
) from exc
|
|
except OSError as exc:
|
|
raise RuntimeError(
|
|
f"failed to invoke {Path(str(argv[0])).name}: {exc}"
|
|
) from exc
|
|
|
|
proc.stdout = proc.stdout or ""
|
|
proc.stderr = scrub_ansi(proc.stderr or "")
|
|
return proc
|