"""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.``) 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