Files
hermes-agent/agent/secret_sources/base.py
T
Teknium d4f2933262 refactor(agent/creds): unify secret-source CLI/cache/error plumbing in base and _cache
- base: run_cli (shared subprocess wrapper), classify_cli_error (rule tables),
  coerce_float, source_child_env, FetchResult.fail, SecretSource.token_env /
  token_env_key / default_token_env / override_existing_default so per-backend
  override_existing/protected_env_vars overrides collapse into the ABC;
  generic remediation is a kind->template table.
- _cache: atomic_write_json (mkstemp->0600->replace) and entry_from_payload
  shared by DiskCache and the bws encrypted cache; SecretCache = L1 dict + L2
  DiskCache with lookup/store/clear.
2026-09-02 13:29:46 -07:00

312 lines
13 KiB
Python

"""Secret-source contract: the ABC every secret backend implements.
A *secret source* resolves credentials from an external secret manager
(Bitwarden, 1Password, a user script, ...) into env-var-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; no write-back, no arbitrary
secret objects, no mid-session secret API.
* **Startup-time, synchronous.** ``fetch()`` runs once per process (per
HERMES_HOME) under a wall-clock timeout enforced by the registry. Sources
must not spawn background refreshers.
* **Never raises, never prompts.** Errors go in ``FetchResult.error`` with a
machine-readable :class:`ErrorKind`; interactive auth belongs in the CLI
``setup`` flow (non-TTY gateway/cron startup must never block on stdin).
* **Sources fetch; the orchestrator applies.** Precedence, conflict warnings,
provenance and the ``os.environ`` writes live in ``registry.apply_all`` so
no backend can get them wrong.
``SECRET_SOURCE_API_VERSION`` gates plugin compatibility: additive optional
hooks with defaults do NOT bump it; required-signature changes do, and the
registry skips (with a warning) sources built against another version.
"""
from __future__ import annotations
import os
import re
import subprocess
from abc import ABC, abstractmethod
from contextvars import ContextVar, Token
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Dict, FrozenSet, List, MutableMapping, Optional, Sequence, Tuple
SECRET_SOURCE_API_VERSION = 1
# Generous: a first run may include a one-time CLI auto-install (bws download).
DEFAULT_FETCH_TIMEOUT_SECONDS = 120.0
DEFAULT_CLI_TIMEOUT_SECONDS = 30.0
_SOURCE_ENVIRONMENT: ContextVar[Optional[MutableMapping[str, str]]]
_SOURCE_ENVIRONMENT = ContextVar("hermes_secret_source_environment", default=None)
def set_source_environment(environ: MutableMapping[str, str]) -> Token:
"""Install a per-fetch environment view without changing ``os.environ``."""
return _SOURCE_ENVIRONMENT.set(environ)
def reset_source_environment(token: Token) -> None:
_SOURCE_ENVIRONMENT.reset(token)
def get_source_environment() -> MutableMapping[str, str]:
"""Return the active per-fetch environment, or the process environment."""
environ = _SOURCE_ENVIRONMENT.get()
return environ if environ is not None else os.environ
def source_child_env() -> Dict[str, str]:
"""Environment for a helper child that legitimately needs the caller's env.
Single-profile startup keeps the legacy contract (full process env, minus
the terminal blocklist). A profile-local fetch (multiplex) gets ONLY the
per-fetch view so a child can never inherit sibling profiles' secrets.
"""
source_env = get_source_environment()
if source_env is os.environ:
from tools.environments.local import build_subprocess_env
return build_subprocess_env(scrub_secrets=False, inherit_profile_home=False)
return dict(source_env)
class ErrorKind(str, Enum):
"""Machine-readable failure taxonomy for :class:`FetchResult.error`.
A fixed vocabulary keeps startup warnings and ``hermes secrets status``
uniform, and lets the orchestrator apply kind-dependent policy (e.g.
stale-cache fallback on NETWORK/TIMEOUT but never AUTH_FAILED) 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)
# Ordered (kind, substrings) rules for mapping CLI failure text onto ErrorKind;
# first rule whose substring appears (case-insensitive) wins.
ErrorRules = Sequence[Tuple[ErrorKind, Sequence[str]]]
def classify_cli_error(message: str, rules: ErrorRules) -> ErrorKind:
"""Best-effort mapping of helper-CLI failure text onto the taxonomy."""
lowered = message.lower()
for kind, tokens in rules:
if any(tok in lowered for tok in tokens):
return kind
return ErrorKind.INTERNAL
def coerce_float(value: Any, default: float) -> float:
"""``float(value)`` with ``default`` for malformed config values."""
try:
return float(value)
except (TypeError, ValueError):
return default
@dataclass
class FetchResult:
"""Outcome of one source's fetch.
``secrets`` is what the source *would* contribute; whether each var is
applied is the orchestrator's decision. ``applied``/``skipped`` exist for
the legacy fetch-and-apply entry points and stay empty in ``fetch()``.
"""
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
# Helper binary used (CLI-driven sources); surfaced by status commands.
binary_path: Optional[Path] = None
@property
def ok(self) -> bool:
return self.error is None
def fail(self, error: str, kind: ErrorKind) -> "FetchResult":
self.error, self.error_kind = error, kind
return self
class SecretSource(ABC):
"""One external secret backend. Subclasses set attributes + ``fetch``.
Attributes:
name: Config-section key under ``secrets:`` (``[a-z0-9_]+``); also the
provenance label for every var this source supplies.
label: Human-readable name for startup messages / ``secrets status``.
shape: ``"mapped"`` (user binds env-var names to refs) or ``"bulk"``
(backend injects whole projects). Mapped beats bulk: an explicit
binding is stronger intent than a project dump.
scheme: URI scheme this source owns for refs (``"op"``). Unique across
sources so refs can later appear outside the ``secrets:`` block.
token_env_key / default_token_env: config key naming the bootstrap-auth
env var, and its default. Drives :meth:`protected_env_vars` so a
vault holding its own access token can't clobber the credential
used to reach it.
override_existing_default: value of ``override_existing`` when unset.
"""
api_version: int = SECRET_SOURCE_API_VERSION
name: str = ""
label: str = ""
shape: str = "mapped" # "mapped" | "bulk"
scheme: Optional[str] = None
token_env_key: Optional[str] = None
default_token_env: str = ""
override_existing_default: bool = False
@abstractmethod
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
"""Resolve this source's secrets. MUST NOT raise or prompt.
``cfg`` is the raw ``secrets.<name>`` section — may be malformed.
"""
def is_enabled(self, cfg: dict) -> bool:
return bool(isinstance(cfg, dict) and cfg.get("enabled"))
def override_existing(self, cfg: dict) -> bool:
"""May this source overwrite vars .env / the shell already set?
Never extends to vars claimed by another source in the same pass —
cross-source overrides are a config error the orchestrator warns about.
"""
return bool(isinstance(cfg, dict)
and cfg.get("override_existing", self.override_existing_default))
def token_env(self, cfg: dict) -> str:
"""Name of the env var holding this source's bootstrap credential."""
if isinstance(cfg, dict) and self.token_env_key:
return str(cfg.get(self.token_env_key) or self.default_token_env)
return self.default_token_env
def protected_env_vars(self, cfg: dict) -> FrozenSet[str]:
"""Env vars the orchestrator must never let ANY source overwrite."""
return frozenset({self.token_env(cfg)}) if self.token_env_key else frozenset()
def fetch_timeout_seconds(self, cfg: dict) -> float:
"""Wall-clock budget the orchestrator enforces around fetch()."""
val = coerce_float((cfg or {}).get("timeout_seconds", DEFAULT_FETCH_TIMEOUT_SECONDS),
DEFAULT_FETCH_TIMEOUT_SECONDS)
return val if val > 0 else DEFAULT_FETCH_TIMEOUT_SECONDS
def config_schema(self) -> dict:
"""Informational ``{key: {"description": str, "default": Any}}`` for setup UIs."""
return {}
def remediation(self, kind: Optional["ErrorKind"], cfg: dict) -> str:
"""One-line actionable next step for a failed fetch (pure, no I/O).
Shown right after the fetch error by the startup status printer and
``hermes secrets ... status``. Empty string suppresses the hint.
"""
return _GENERIC_REMEDIATION.get(kind, "").format(name=self.name) if kind is not None else ""
_GENERIC_REMEDIATION = {
ErrorKind.NOT_CONFIGURED: "Run `hermes secrets {name} setup` to finish configuration.",
ErrorKind.BINARY_MISSING: "Run `hermes secrets {name} setup` to install the helper CLI.",
ErrorKind.AUTH_FAILED: "Credentials rejected — run `hermes secrets {name} setup` to re-authenticate.",
ErrorKind.AUTH_EXPIRED: "Credentials expired — run `hermes secrets {name} setup` to re-authenticate.",
ErrorKind.NETWORK: "Network problem reaching the secrets backend — check connectivity and retry.",
ErrorKind.TIMEOUT: "Backend was slow — raise secrets.{name}.timeout_seconds if this recurs.",
}
# ---------------------------------------------------------------------------
# Shared helpers — use these instead of hand-rolling per backend
# ---------------------------------------------------------------------------
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# Deliberately NOT tools.ansi_strip.strip_ansi: the optional terminator here
# also strips *unterminated* OSC sequences (a CLI killed mid-write), which
# strip_ansi leaves untouched.
_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_cli(
argv: Sequence[str],
*,
env: Dict[str, str],
timeout: float,
label: str,
timeout_message: str,
stdin: Any = subprocess.DEVNULL,
) -> subprocess.CompletedProcess:
"""``subprocess.run`` an argv list (never a shell), capturing utf-8 text.
Timeout and spawn failure become ``RuntimeError`` with messages safe to
surface; callers own returncode interpretation.
"""
try:
return subprocess.run( # noqa: S603 — argv list, no shell
list(argv),
env=env,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
timeout=timeout,
stdin=stdin,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(timeout_message) from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke {label}: {exc}") from exc
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.
The child gets PATH/HOME/locale basics plus only ``allow_env`` (auth/session
vars) and ``extra_env`` — never the full post-dotenv ``os.environ``, which
holds every credential Hermes knows. ``NO_COLOR=1`` plus ANSI-scrubbed
stderr keep helper diagnostics out of Hermes output; stdin is /dev/null so
a prompting helper fails fast. Pass user refs AFTER a ``--`` terminator.
"""
base_keep = ("PATH", "HOME", "USERPROFILE", "SYSTEMROOT", "TMPDIR", "TEMP",
"LANG", "LC_ALL", "XDG_CONFIG_HOME", "XDG_DATA_HOME")
env = {k: os.environ[k] for k in (*base_keep, *allow_env) if k in os.environ}
if extra_env:
env.update(extra_env)
env.setdefault("NO_COLOR", "1")
name = Path(str(argv[0])).name
proc = run_cli(argv, env=env, timeout=timeout, label=name,
timeout_message=f"{name} timed out after {timeout:.0f}s")
proc.stdout = proc.stdout or ""
proc.stderr = scrub_ansi(proc.stderr or "")
return proc