Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""External secret source integrations.
|
||||
|
||||
A secret source is anything that can supply environment-variable-shaped
|
||||
credentials at process startup, _after_ ~/.hermes/.env has loaded.
|
||||
|
||||
The contract every source implements is
|
||||
:class:`agent.secret_sources.base.SecretSource`; the orchestrator that
|
||||
runs the enabled sources (ordering, mapped-beats-bulk precedence,
|
||||
first-claim-wins conflicts, ``override_existing`` semantics, provenance)
|
||||
is :func:`agent.secret_sources.registry.apply_all`. Multiple sources
|
||||
can be enabled at once — see the registry module docstring for the
|
||||
precedence ladder. The atomic-write / 0600 / TTL disk-cache substrate
|
||||
is shared across backends in ``agent.secret_sources._cache`` so the
|
||||
security-sensitive bits live in exactly one place.
|
||||
|
||||
Currently bundled:
|
||||
|
||||
- ``bitwarden`` — Bitwarden Secrets Manager (`bws` CLI). See
|
||||
``agent.secret_sources.bitwarden`` for the integration and
|
||||
``hermes_cli.secrets_cli`` for the user-facing setup wizard.
|
||||
- ``onepassword`` — 1Password ``op://`` secret references (`op` CLI).
|
||||
See ``agent.secret_sources.onepassword`` for the integration and
|
||||
``hermes_cli.onepassword_secrets_cli`` for the user-facing commands.
|
||||
|
||||
The bundled set is deliberately closed (policy mirrors memory
|
||||
providers): new third-party secret managers ship as standalone plugin
|
||||
repos that subclass ``SecretSource`` and register through
|
||||
``PluginContext.register_secret_source()`` — they are NOT added to this
|
||||
package. A generic ``command`` source is a possible future exception;
|
||||
OS keystores (Keychain/DPAPI/libsecret) are under discussion.
|
||||
"""
|
||||
|
||||
from agent.secret_sources.base import ( # noqa: F401
|
||||
SECRET_SOURCE_API_VERSION,
|
||||
ErrorKind,
|
||||
FetchResult,
|
||||
SecretSource,
|
||||
is_valid_env_name,
|
||||
run_secret_cli,
|
||||
scrub_ansi,
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Shared substrate for external secret-source backends.
|
||||
|
||||
Every backend (Bitwarden, 1Password, …) needs the same handful of
|
||||
security-sensitive primitives:
|
||||
|
||||
* a uniform result object (:class:`FetchResult`),
|
||||
* environment-variable name validation (:func:`is_valid_env_name`),
|
||||
* a two-layer fetch cache whose disk half writes atomically with ``0600``
|
||||
permissions and honours a TTL (:class:`DiskCache`, :class:`CachedFetch`).
|
||||
|
||||
These used to live inline inside ``bitwarden.py``. Pulling them here means
|
||||
the atomic-write / ``0600`` / TTL logic is audited and fixed in exactly one
|
||||
place instead of drifting across copy-pasted per-backend modules — each
|
||||
backend supplies only its own cache-key shape and a serializer for it.
|
||||
|
||||
Nothing in this module ever raises out to the caller's hot path: the disk
|
||||
layer is strictly best-effort (a miss just triggers a refetch), because a
|
||||
cache problem must never block Hermes startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Generic, Optional, TypeVar
|
||||
|
||||
__all__ = [
|
||||
"FetchResult",
|
||||
"CachedFetch",
|
||||
"DiskCache",
|
||||
"is_valid_env_name",
|
||||
"resolve_cache_home",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result object + env-name validation — canonical definitions live in
|
||||
# ``agent.secret_sources.base`` (the SecretSource contract module); re-exported
|
||||
# here so backends that import from ``_cache`` keep working.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from agent.secret_sources.base import ( # noqa: E402
|
||||
FetchResult,
|
||||
is_valid_env_name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class CachedFetch:
|
||||
"""A set of fetched secret values plus when they were fetched."""
|
||||
|
||||
secrets: Dict[str, str]
|
||||
fetched_at: float
|
||||
|
||||
def is_fresh(self, ttl_seconds: float) -> bool:
|
||||
if ttl_seconds <= 0:
|
||||
return False
|
||||
return (time.time() - self.fetched_at) < ttl_seconds
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disk cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_cache_home(home_path: Optional[Path] = None) -> Path:
|
||||
"""Resolve the Hermes home used for cache paths.
|
||||
|
||||
``home_path`` is whatever ``load_hermes_dotenv()`` already resolved;
|
||||
falling back to ``$HERMES_HOME`` / ``~/.hermes`` keeps direct callers
|
||||
(and tests that don't thread a home through) working.
|
||||
"""
|
||||
if home_path is None:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
home_path = get_hermes_home()
|
||||
return home_path
|
||||
|
||||
|
||||
K = TypeVar("K")
|
||||
|
||||
|
||||
class DiskCache(Generic[K]):
|
||||
"""Best-effort, profile-aware on-disk cache for fetched secret values.
|
||||
|
||||
One JSON object per backend lives at ``<hermes_home>/cache/<basename>``::
|
||||
|
||||
{"key": "<serialized cache key>", "secrets": {...}, "fetched_at": 1.0}
|
||||
|
||||
The file holds only secret *values* keyed by the serialized cache key —
|
||||
never raw auth material. Backends are responsible for fingerprinting
|
||||
tokens/sessions *before* they reach ``key_serializer`` so the token can't
|
||||
land in the key.
|
||||
|
||||
Writes are atomic (``mkstemp`` → ``chmod 0600`` → ``os.replace``) and the
|
||||
containing ``cache/`` directory is forced to ``0700`` — ``mkdir``'s mode is
|
||||
umask-subject, so the chmod is the reliable form. Both ``read`` and
|
||||
``write`` short-circuit when ``ttl_seconds <= 0``, so setting the TTL to
|
||||
zero disables *both* cache layers symmetrically: a user opting out never
|
||||
gets secret values written to disk at all.
|
||||
"""
|
||||
|
||||
def __init__(self, basename: str, *, key_serializer: Callable[[K], str]) -> None:
|
||||
self._basename = basename
|
||||
self._key_serializer = key_serializer
|
||||
# Temp-file prefix derived from the basename so concurrent writers for
|
||||
# different backends in the same dir don't collide on the staging name.
|
||||
stem = basename.split(".", 1)[0]
|
||||
self._tmp_prefix = f".{stem}_"
|
||||
|
||||
def path(self, home_path: Optional[Path] = None) -> Path:
|
||||
return resolve_cache_home(home_path) / "cache" / self._basename
|
||||
|
||||
def read(
|
||||
self,
|
||||
key: K,
|
||||
ttl_seconds: float,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> Optional[CachedFetch]:
|
||||
"""Return a fresh cached entry for ``key``, or None.
|
||||
|
||||
Best-effort: any I/O or parse error, a key mismatch, or a stale entry
|
||||
all return None so the caller re-fetches.
|
||||
"""
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
path = self.path(home_path)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
payload = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("key") != self._key_serializer(key):
|
||||
return None
|
||||
secrets = payload.get("secrets")
|
||||
fetched_at = payload.get("fetched_at")
|
||||
if not isinstance(secrets, dict) or not isinstance(fetched_at, (int, float)):
|
||||
return None
|
||||
# JSON permits non-string values; env vars need strings, so coerce by
|
||||
# dropping anything that isn't a str→str pair.
|
||||
typed: Dict[str, str] = {
|
||||
k: v for k, v in secrets.items() if isinstance(k, str) and isinstance(v, str)
|
||||
}
|
||||
entry = CachedFetch(secrets=typed, fetched_at=float(fetched_at))
|
||||
if not entry.is_fresh(ttl_seconds):
|
||||
return None
|
||||
return entry
|
||||
|
||||
def write(
|
||||
self,
|
||||
key: K,
|
||||
entry: CachedFetch,
|
||||
ttl_seconds: float,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Persist ``entry`` for ``key`` atomically at mode ``0600``.
|
||||
|
||||
No-op when ``ttl_seconds <= 0`` (so caching is genuinely off) or on any
|
||||
I/O error — the next invocation just re-fetches.
|
||||
"""
|
||||
if ttl_seconds <= 0:
|
||||
return
|
||||
path = self.path(home_path)
|
||||
try:
|
||||
cache_dir = path.parent
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
# mkdir's mode is umask-subject; chmod the dir to 0700 so cache
|
||||
# metadata isn't exposed if HERMES_HOME is ever made traversable.
|
||||
try:
|
||||
os.chmod(cache_dir, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
payload = {
|
||||
"key": self._key_serializer(key),
|
||||
"secrets": entry.secrets,
|
||||
"fetched_at": entry.fetched_at,
|
||||
}
|
||||
# Write to a sibling temp file and atomic-rename. tempfile honours
|
||||
# os.umask, so we explicitly chmod 0600 before the rename.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
prefix=self._tmp_prefix, suffix=".tmp", dir=str(cache_dir)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f)
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError:
|
||||
pass # best-effort — a disk-cache miss next invocation is fine
|
||||
|
||||
def clear(self, home_path: Optional[Path] = None) -> None:
|
||||
"""Delete the on-disk cache file if present (idempotent)."""
|
||||
try:
|
||||
self.path(home_path).unlink()
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
@@ -0,0 +1,336 @@
|
||||
"""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 contextvars import ContextVar, Token
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Dict, FrozenSet, List, MutableMapping, 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
|
||||
|
||||
_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
|
||||
|
||||
# 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 {}
|
||||
|
||||
def remediation(self, kind: Optional["ErrorKind"], cfg: dict) -> str:
|
||||
"""One-line, actionable next step for a failed fetch.
|
||||
|
||||
Called by the startup status printer (and ``hermes secrets ...
|
||||
status``) right after a fetch error is surfaced, so the user sees
|
||||
*what to run* next to fix it — not just what broke. Sources
|
||||
should override this to point at their own CLI verbs (e.g.
|
||||
``hermes secrets bitwarden token`` for AUTH_FAILED). Return an
|
||||
empty string to suppress the hint.
|
||||
|
||||
Must never raise and must not perform I/O — it's a pure
|
||||
kind→string mapping on the startup path.
|
||||
"""
|
||||
generic = {
|
||||
ErrorKind.NOT_CONFIGURED: (
|
||||
f"Run `hermes secrets {self.name} setup` to finish configuration."
|
||||
),
|
||||
ErrorKind.BINARY_MISSING: (
|
||||
f"Run `hermes secrets {self.name} setup` to install the helper CLI."
|
||||
),
|
||||
ErrorKind.AUTH_FAILED: (
|
||||
f"Credentials rejected — run `hermes secrets {self.name} setup` "
|
||||
"to re-authenticate."
|
||||
),
|
||||
ErrorKind.AUTH_EXPIRED: (
|
||||
f"Credentials expired — run `hermes secrets {self.name} setup` "
|
||||
"to re-authenticate."
|
||||
),
|
||||
ErrorKind.NETWORK: (
|
||||
"Network problem reaching the secrets backend — check "
|
||||
"connectivity and retry."
|
||||
),
|
||||
ErrorKind.TIMEOUT: (
|
||||
f"Backend was slow — raise secrets.{self.name}.timeout_seconds "
|
||||
"if this recurs."
|
||||
),
|
||||
}
|
||||
return generic.get(kind, "") if kind is not None else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# NOTE: intentionally NOT migrated to tools.ansi_strip.strip_ansi — the
|
||||
# optional terminator here (``(?:\x07|\x1b\\)?``) also strips *unterminated*
|
||||
# OSC sequences (common when a CLI is killed mid-write), which strip_ansi
|
||||
# leaves untouched. strip_ansi is not a superset of this regex.
|
||||
_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, encoding="utf-8", errors="replace",
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
||||
"""``command`` secret source — resolve secrets via a user-configured helper.
|
||||
|
||||
Ports the security semantics of the desktop app's TypeScript
|
||||
``CommandSecretsProvider`` (hermes-desktop ``src/main/secrets/commandProvider.ts``)
|
||||
to the Python agent. The helper command (e.g. ``keepassxc-cli``,
|
||||
``secret-tool``, or a script that cats a tmpfs env file) comes from
|
||||
``secrets.command`` in ``config.yaml`` — NEVER from ``.env``, which holds
|
||||
only secret values.
|
||||
|
||||
Security model (mirrors the TS provider line-for-line where it matters):
|
||||
|
||||
* The command string is the USER'S OWN configuration (same trust level as
|
||||
the ``.env`` file they control), so it is run via ``/bin/sh -c <command>``.
|
||||
* The requested key is passed to the child ONLY via the ``HERMES_SECRET_KEY``
|
||||
environment variable — it is NEVER interpolated into the shell string, so
|
||||
a hostile key name (e.g. ``"; rm -rf ~``) is inert data, not code.
|
||||
* Hard timeout (default 3s) + output cap (default 1 MiB); any failure
|
||||
(non-zero exit, timeout, spawn failure, oversized output) degrades to
|
||||
"no value" rather than raising.
|
||||
* Failures log ONLY structured fields (exit code / signal / errno) to
|
||||
stderr — never the command string, the helper's stderr, or any secret
|
||||
value. The helper's stderr is captured via a pipe and DISCARDED so its
|
||||
diagnostics (which can carry secret material) never reach our stderr.
|
||||
* The startup/apply path runs the helper exactly ONCE (with an empty
|
||||
``HERMES_SECRET_KEY``) — it is never called per-key in a loop, so a
|
||||
helper that blocks (e.g. on a vault unlock prompt) can't be spawned
|
||||
dozens of times.
|
||||
* PLATFORM: the provider is POSIX-only (needs ``/bin/sh``). On Windows it
|
||||
degrades to an empty result with a warning; Windows users stay on the
|
||||
default ``env`` provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import signal as _signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
# Reuse the exact result shape the bitwarden source returns so
|
||||
# hermes_cli.env_loader can consume both providers identically.
|
||||
from agent.secret_sources.base import ErrorKind, SecretSource
|
||||
from agent.secret_sources.base import get_source_environment
|
||||
from agent.secret_sources.bitwarden import FetchResult
|
||||
|
||||
__all__ = [
|
||||
"FetchResult",
|
||||
"apply_command_secrets",
|
||||
"get_command_secret",
|
||||
"list_command_secrets",
|
||||
"parse_secret_output",
|
||||
"unquote_dotenv_value",
|
||||
]
|
||||
|
||||
# Hard cap so a hung helper can never wedge startup. Kept deliberately
|
||||
# TIGHT (3s) — a configured helper MUST be fast and NON-INTERACTIVE
|
||||
# (e.g. `keepassxc-cli` against an already-unlocked DB, `secret-tool
|
||||
# lookup`, or `cat`-ing a tmpfs env file), NOT something that prompts
|
||||
# for a touch/PIN.
|
||||
_COMMAND_TIMEOUT_SECONDS = 3.0
|
||||
# Defensive cap on helper output (1 MiB) — a misbehaving command can't OOM us.
|
||||
_MAX_OUTPUT_BYTES = 1024 * 1024
|
||||
|
||||
# A line is treated as a KEY=VALUE pair only when it matches an env-key
|
||||
# shape before the '='. Anchored; `.` does not cross newlines, so a
|
||||
# multi-line blob never matches as a single "env-shaped" value.
|
||||
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt" or platform.system() == "Windows"
|
||||
|
||||
|
||||
def unquote_dotenv_value(raw: str) -> str:
|
||||
"""Strip a single layer of matching surrounding quotes from a dotenv value.
|
||||
|
||||
Requires length >= 2 so a lone quote (``"``) is left intact rather than
|
||||
collapsing to empty, and ``""``/``''`` correctly yield an empty string.
|
||||
Shared by the single-key parser and the list path so both unquote
|
||||
identically.
|
||||
"""
|
||||
t = raw.strip()
|
||||
if len(t) >= 2 and (
|
||||
(t.startswith('"') and t.endswith('"'))
|
||||
or (t.startswith("'") and t.endswith("'"))
|
||||
):
|
||||
return t[1:-1]
|
||||
return t
|
||||
|
||||
|
||||
def parse_secret_output(stdout: str, wanted_key: str) -> Optional[str]:
|
||||
"""Parse a secret-fetch helper's stdout. Supports BOTH shapes:
|
||||
|
||||
* a bare value (single secret): the whole trimmed stdout is the value.
|
||||
* a dotenv blob (KEY=VALUE lines): parse them and return the entry for
|
||||
``wanted_key``.
|
||||
|
||||
Mirrors the TS ``parseSecretOutput`` exactly, including the cross-key
|
||||
misroute guard and the base64-padding disambiguation.
|
||||
"""
|
||||
text = stdout.replace("\r\n", "\n")
|
||||
lines = text.split("\n")
|
||||
|
||||
# 1. Exact dotenv match wins: scan for a `wanted_key=...` line. This
|
||||
# is deterministic and never returns another key's value.
|
||||
dotenv_lines = [
|
||||
line
|
||||
for line in (raw.strip() for raw in lines)
|
||||
if line and not line.startswith("#") and _ENV_LINE.match(line)
|
||||
]
|
||||
for line in dotenv_lines:
|
||||
m = _ENV_LINE.match(line)
|
||||
assert m is not None # filtered above
|
||||
if m.group(1) == wanted_key:
|
||||
value = unquote_dotenv_value(m.group(2))
|
||||
# Whitespace-only (e.g. a quoted `K=" "` placeholder) is "no
|
||||
# value": it would otherwise flow into an Authorization header
|
||||
# → guaranteed 401.
|
||||
return value if value.strip() != "" else None
|
||||
|
||||
# 2. The output is a multi-key dotenv dump that does NOT contain the
|
||||
# wanted key → None, rather than mis-returning an unrelated line as
|
||||
# a bare value. Only >=2 env-shaped lines count as a dump: a SINGLE
|
||||
# non-matching env-shaped line falls through to the bare-value
|
||||
# branch, because a bare secret can itself match the KEY=VALUE shape
|
||||
# (e.g. base64 with '=' padding, "dGVzdA==") and must not be
|
||||
# misclassified as a dump.
|
||||
if len(dotenv_lines) > 1:
|
||||
return None
|
||||
|
||||
# 3. Otherwise treat the whole output as a single bare value (a per-key
|
||||
# helper that printed just the secret). Trim first so whitespace-only
|
||||
# output (a ' '/'\t' placeholder entry) resolves to None, never a "key".
|
||||
value = text.strip()
|
||||
if value == "":
|
||||
return None
|
||||
|
||||
# SECURITY (S2): a single env-shaped line for a DIFFERENT key must not
|
||||
# be returned as the wanted secret. A sloppy helper (e.g. `head -1
|
||||
# env-file`, or a grep that matched the wrong line) emitting
|
||||
# `OTHER_KEY=realvalue` would otherwise flow — key name, '=' and the
|
||||
# OTHER key's value — into an Authorization header sent to the WANTED
|
||||
# key's endpoint: cross-provider credential leakage, not just a 401.
|
||||
# Disambiguation from a bare base64 secret: base64 padding only ever
|
||||
# produces an env-shaped line whose "value" part is empty or all '='
|
||||
# (`dGVzdA==` → key `dGVzdA`, value `=`), so a non-trivial value part
|
||||
# after a non-matching key means a misrouted dotenv entry → None.
|
||||
env_shaped = _ENV_LINE.match(value)
|
||||
if (
|
||||
env_shaped
|
||||
and env_shaped.group(1) != wanted_key
|
||||
and re.fullmatch(r"=*", env_shaped.group(2).strip()) is None
|
||||
):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _run_helper(
|
||||
command: str,
|
||||
secret_key: str,
|
||||
timeout_seconds: float,
|
||||
max_output_bytes: int,
|
||||
) -> Optional[str]:
|
||||
"""Run the helper via ``/bin/sh -c`` and return its stdout, or None.
|
||||
|
||||
The key is passed as DATA via ``HERMES_SECRET_KEY`` — never interpolated
|
||||
into the command string. Both stdout and stderr are captured via pipes
|
||||
(never inherited); stderr is discarded. Any failure logs structured
|
||||
fields only and returns None — never raises.
|
||||
"""
|
||||
if _is_windows():
|
||||
print(
|
||||
"[secrets:command] the 'command' provider is POSIX-only "
|
||||
"(needs /bin/sh); resolving no value on Windows",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
# User-configured secret-helper command: runs with the user's full shell
|
||||
# env by design (it may need any credential to resolve the secret).
|
||||
source_env = get_source_environment()
|
||||
if source_env is os.environ:
|
||||
# Legacy single-profile startup intentionally preserves the existing
|
||||
# helper contract, which may rely on the user's full environment.
|
||||
from tools.environments.local import build_subprocess_env
|
||||
env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False)
|
||||
else:
|
||||
# A multiplex profile must never inherit sibling secrets from the
|
||||
# process-global environment. hydrate_profile_secret_sources seeds
|
||||
# only global-safe values plus this profile's own .env.
|
||||
env = dict(source_env)
|
||||
env["HERMES_SECRET_KEY"] = secret_key
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen( # noqa: S602 — command is the user's own config
|
||||
["/bin/sh", "-c", command],
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, # captured and DISCARDED — never inherited
|
||||
start_new_session=True, # so the hard timeout can kill the whole group
|
||||
)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"[secrets:command] helper failed to spawn; resolving no value: "
|
||||
f"errno={exc.errno}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
stdout_bytes, _stderr_discarded = proc.communicate(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Hard timeout: kill the whole process group (a helper script may
|
||||
# have forked children that would otherwise keep the pipe open).
|
||||
# POSIX-only by construction: _run_helper early-returns on Windows
|
||||
# before ever spawning, so this line can't execute there.
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
proc.kill()
|
||||
try:
|
||||
proc.communicate(timeout=1.0)
|
||||
except (subprocess.TimeoutExpired, ValueError, OSError):
|
||||
pass
|
||||
print(
|
||||
f"[secrets:command] helper timed out after {timeout_seconds:g}s; "
|
||||
f"resolving no value",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if proc.returncode != 0:
|
||||
# Structured fields ONLY — never the command string or the helper's
|
||||
# stderr (either can carry secret material).
|
||||
if proc.returncode < 0:
|
||||
try:
|
||||
sig = _signal.Signals(-proc.returncode).name
|
||||
except ValueError:
|
||||
sig = str(-proc.returncode)
|
||||
code, signame = "?", sig
|
||||
else:
|
||||
code, signame = str(proc.returncode), "none"
|
||||
print(
|
||||
f"[secrets:command] helper failed; resolving no value: "
|
||||
f"code={code} signal={signame}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
if len(stdout_bytes) > max_output_bytes:
|
||||
print(
|
||||
f"[secrets:command] helper output exceeded the "
|
||||
f"{max_output_bytes}-byte cap; resolving no value",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
|
||||
return stdout_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _parse_dotenv_map(stdout: str) -> Dict[str, str]:
|
||||
"""Parse a KEY=VALUE blob into a map (the list/enumerate path).
|
||||
|
||||
Mirrors the TS ``list()``: only env-shaped lines contribute; comments
|
||||
and non-matching lines are skipped. A bare-value helper yields ``{}``
|
||||
— per-key resolution via :func:`get_command_secret` still works.
|
||||
"""
|
||||
out: Dict[str, str] = {}
|
||||
for raw in stdout.replace("\r\n", "\n").split("\n"):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
m = _ENV_LINE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
out[m.group(1)] = unquote_dotenv_value(m.group(2))
|
||||
return out
|
||||
|
||||
|
||||
def get_command_secret(
|
||||
*,
|
||||
command: str,
|
||||
key: str,
|
||||
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
|
||||
max_output_bytes: int = _MAX_OUTPUT_BYTES,
|
||||
) -> Optional[str]:
|
||||
"""Resolve a single secret by running the helper with the key in
|
||||
``HERMES_SECRET_KEY``. Returns None on any failure — never raises."""
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
return None
|
||||
stdout = _run_helper(command, key, timeout_seconds, max_output_bytes)
|
||||
if stdout is None:
|
||||
return None
|
||||
return parse_secret_output(stdout, key)
|
||||
|
||||
|
||||
def list_command_secrets(
|
||||
*,
|
||||
command: str,
|
||||
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
|
||||
max_output_bytes: int = _MAX_OUTPUT_BYTES,
|
||||
) -> Dict[str, str]:
|
||||
"""Enumerate secrets by running the helper ONCE with an empty key.
|
||||
|
||||
Returns the dotenv map ONLY when the helper emits a KEY=VALUE blob;
|
||||
a bare-value helper returns ``{}``. Never raises.
|
||||
"""
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
return {}
|
||||
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
|
||||
if stdout is None:
|
||||
return {}
|
||||
return _parse_dotenv_map(stdout)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — called from hermes_cli.env_loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_command_secrets(
|
||||
*,
|
||||
command: str,
|
||||
override_existing: bool = False,
|
||||
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
|
||||
max_output_bytes: int = _MAX_OUTPUT_BYTES,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> FetchResult:
|
||||
"""Run the helper once at startup and set its KEY=VALUE output on
|
||||
``os.environ``.
|
||||
|
||||
LEGACY shim retained for API symmetry with ``apply_bitwarden_secrets``;
|
||||
the startup path goes through :class:`CommandSource` + the registry
|
||||
orchestrator instead (which owns precedence and the environ writes).
|
||||
"""
|
||||
result = FetchResult()
|
||||
|
||||
command = (command or "").strip()
|
||||
if not command:
|
||||
result.error = (
|
||||
"secrets.command.enabled is true but secrets.command.command is "
|
||||
"empty. Set the helper command in config.yaml."
|
||||
)
|
||||
return result
|
||||
|
||||
if _is_windows():
|
||||
result.warnings.append(
|
||||
"the 'command' secret source is POSIX-only (needs /bin/sh); "
|
||||
"skipping on Windows"
|
||||
)
|
||||
return result
|
||||
|
||||
# The list/enumerate path: run the helper exactly ONCE with an empty
|
||||
# HERMES_SECRET_KEY and parse its stdout as a dotenv blob.
|
||||
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
|
||||
if stdout is None:
|
||||
# _run_helper already logged structured fields to stderr.
|
||||
result.warnings.append(
|
||||
"helper command failed at startup; no secrets applied "
|
||||
"(process env / .env values remain in effect)"
|
||||
)
|
||||
return result
|
||||
|
||||
secrets = _parse_dotenv_map(stdout)
|
||||
result.secrets = secrets
|
||||
if not secrets:
|
||||
result.warnings.append(
|
||||
"helper output was not a KEY=VALUE map; nothing applied at "
|
||||
"startup (a bare-value helper still resolves single keys on demand)"
|
||||
)
|
||||
return result
|
||||
|
||||
for key, value in secrets.items():
|
||||
if value.strip() == "":
|
||||
# Whitespace-only placeholder entries are "no value" — applying
|
||||
# them would flow into an Authorization header → guaranteed 401.
|
||||
result.skipped.append(key)
|
||||
continue
|
||||
if not override_existing and os.environ.get(key):
|
||||
# Process env / .env win — same precedence as bitwarden.
|
||||
result.skipped.append(key)
|
||||
continue
|
||||
os.environ[key] = value
|
||||
result.applied.append(key)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SecretSource adapter — the registry-facing wrapper around this module.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CommandSource(SecretSource):
|
||||
"""User-configured helper command as a registered secret source.
|
||||
|
||||
Composes with the other sources (Bitwarden, 1Password, plugins) through
|
||||
the ``apply_all()`` orchestrator — enable any combination simultaneously;
|
||||
there is deliberately NO single-provider selector. ``fetch()`` only
|
||||
fetches: precedence, ``override_existing`` semantics, conflict warnings,
|
||||
and the ``os.environ`` writes are the orchestrator's job.
|
||||
|
||||
Bulk shape: the helper enumerates a KEY=VALUE blob in one run. Config::
|
||||
|
||||
secrets:
|
||||
command:
|
||||
enabled: true
|
||||
command: "cat /run/user/1000/hermes-secrets.env"
|
||||
# or per-vault CLIs: keepassxc-cli / secret-tool / pass / gpg —
|
||||
# anything fast and NON-interactive.
|
||||
"""
|
||||
|
||||
name = "command"
|
||||
label = "Command helper"
|
||||
shape = "bulk"
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return {
|
||||
"enabled": {"description": "Master switch", "default": False},
|
||||
"command": {
|
||||
"description": "Helper run via /bin/sh -c; must print a "
|
||||
"KEY=VALUE blob on stdout",
|
||||
"default": "",
|
||||
},
|
||||
"helper_timeout_seconds": {
|
||||
"description": "Hard timeout for one helper run",
|
||||
"default": _COMMAND_TIMEOUT_SECONDS,
|
||||
},
|
||||
"override_existing": {
|
||||
"description": "Helper values overwrite .env/shell values",
|
||||
"default": False,
|
||||
},
|
||||
}
|
||||
|
||||
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
result = FetchResult()
|
||||
|
||||
command = str(cfg.get("command") or "").strip()
|
||||
if not command:
|
||||
result.error = (
|
||||
"secrets.command.enabled is true but secrets.command.command "
|
||||
"is empty. Set the helper command in config.yaml."
|
||||
)
|
||||
result.error_kind = ErrorKind.NOT_CONFIGURED
|
||||
return result
|
||||
|
||||
if _is_windows():
|
||||
result.error = (
|
||||
"the 'command' secret source is POSIX-only (needs /bin/sh); "
|
||||
"skipping on Windows"
|
||||
)
|
||||
result.error_kind = ErrorKind.NOT_CONFIGURED
|
||||
return result
|
||||
|
||||
try:
|
||||
timeout = float(cfg.get("helper_timeout_seconds",
|
||||
_COMMAND_TIMEOUT_SECONDS))
|
||||
except (TypeError, ValueError):
|
||||
timeout = _COMMAND_TIMEOUT_SECONDS
|
||||
|
||||
stdout = _run_helper(command, "", timeout, _MAX_OUTPUT_BYTES)
|
||||
if stdout is None:
|
||||
# _run_helper already logged structured fields to stderr.
|
||||
result.error = (
|
||||
"helper command failed (see structured fields above); "
|
||||
"no secrets applied"
|
||||
)
|
||||
result.error_kind = ErrorKind.INTERNAL
|
||||
return result
|
||||
|
||||
secrets = _parse_dotenv_map(stdout)
|
||||
if not secrets:
|
||||
result.warnings.append(
|
||||
"helper output was not a KEY=VALUE map; nothing to apply"
|
||||
)
|
||||
return result
|
||||
|
||||
result.secrets = secrets
|
||||
return result
|
||||
|
||||
def remediation(self, kind, cfg: dict) -> str:
|
||||
if kind == ErrorKind.NOT_CONFIGURED:
|
||||
return (
|
||||
"Set secrets.command.command in config.yaml to a fast, "
|
||||
"non-interactive helper that prints KEY=VALUE lines."
|
||||
)
|
||||
if kind == ErrorKind.INTERNAL:
|
||||
return (
|
||||
"Run the helper manually in a shell to see its real error — "
|
||||
"Hermes discards helper stderr so diagnostics can't leak "
|
||||
"secret material."
|
||||
)
|
||||
return super().remediation(kind, cfg)
|
||||
@@ -0,0 +1,682 @@
|
||||
"""1Password (`op` CLI) secret source.
|
||||
|
||||
Resolve provider credentials from 1Password ``op://vault/item/field``
|
||||
references at process startup so they don't have to live in plaintext in
|
||||
``~/.hermes/.env``.
|
||||
|
||||
Design summary
|
||||
--------------
|
||||
|
||||
* Users map environment-variable names to official 1Password secret
|
||||
references in ``secrets.onepassword.env``::
|
||||
|
||||
secrets:
|
||||
onepassword:
|
||||
enabled: true
|
||||
env:
|
||||
OPENAI_API_KEY: "op://Private/OpenAI/api key"
|
||||
ANTHROPIC_API_KEY: "op://Private/Anthropic/credential"
|
||||
|
||||
* After ``.env`` loads, each reference is resolved with a single
|
||||
``op read -- <reference>`` call and injected into ``os.environ`` (the
|
||||
same point in startup as the Bitwarden source).
|
||||
* Authentication is whatever the user's ``op`` CLI already uses — a
|
||||
service-account token (``OP_SERVICE_ACCOUNT_TOKEN``) for headless boxes,
|
||||
or a desktop/interactive session (``OP_SESSION_*``). Hermes never
|
||||
authenticates on the user's behalf; it shells out to an already-trusted,
|
||||
already-authenticated CLI.
|
||||
* Failures NEVER block startup. A missing ``op`` binary, expired auth, a
|
||||
bad reference, or a permission error each surface a one-line warning and
|
||||
Hermes continues with whatever credentials ``.env`` already had.
|
||||
|
||||
The atomic-write / ``0600`` / TTL cache mechanics are shared with the other
|
||||
backends via :mod:`agent.secret_sources._cache` — successful, complete pulls
|
||||
are cached in-process and on disk under ``<hermes_home>/cache/op_cache.json``
|
||||
so back-to-back short-lived ``hermes`` invocations don't re-shell ``op`` for
|
||||
every reference. The disk file holds only resolved secret *values*; auth
|
||||
material is fingerprinted, never stored.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from agent.secret_sources._cache import (
|
||||
CachedFetch,
|
||||
DiskCache,
|
||||
FetchResult,
|
||||
is_valid_env_name,
|
||||
)
|
||||
from agent.secret_sources.base import ErrorKind, SecretSource
|
||||
from agent.secret_sources.base import get_source_environment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# How long to wait for a single `op read`, in seconds.
|
||||
_OP_RUN_TIMEOUT = 30
|
||||
|
||||
# Default env var the official `op` CLI reads for service-account auth. Users
|
||||
# can point `service_account_token_env` at a different name; we always export
|
||||
# the value to the child as OP_SERVICE_ACCOUNT_TOKEN, which is what `op` itself
|
||||
# looks for.
|
||||
_DEFAULT_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN"
|
||||
|
||||
# ANSI stripping for `op` diagnostics we surface uses the shared
|
||||
# tools.ansi_strip.strip_ansi (full ECMA-48: CSI, OSC, DCS/SOS/PM/APC,
|
||||
# C1) so a control sequence can't reposition the cursor or hide text
|
||||
# after a redaction marker.
|
||||
|
||||
# Env vars the `op` child actually needs. We build a minimal allowlisted env
|
||||
# rather than copying all of os.environ (which, post-dotenv, holds every
|
||||
# provider credential) into the child — tighter blast radius if `op` or
|
||||
# anything it execs ever misbehaves. OP_SESSION_* and the token are added
|
||||
# dynamically in _op_child_env().
|
||||
_OP_ENV_ALLOWLIST = (
|
||||
"PATH",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"SystemRoot",
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"OP_ACCOUNT",
|
||||
"OP_CONNECT_HOST",
|
||||
"OP_CONNECT_TOKEN",
|
||||
# Lets a user skip op's desktop-app integration probe (which can hang with
|
||||
# no timeout on a wedged desktop container) and go straight to token auth.
|
||||
"OP_LOAD_DESKTOP_APP_SETTINGS",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# In-process cache. The key folds in str(home_path) so a HERMES_HOME switch
|
||||
# inside one long-lived process (e.g. the gateway) can't return another
|
||||
# profile's secrets from L1. The disk layer omits home from its serialized
|
||||
# key because the file already lives under the home dir (see _disk_key_str).
|
||||
_CacheKey = Tuple[str, str, str, str] # (auth_fp, account, home, refs_fp)
|
||||
_CACHE: Dict[_CacheKey, CachedFetch] = {}
|
||||
|
||||
_DISK_CACHE_BASENAME = "op_cache.json"
|
||||
|
||||
|
||||
def _disk_key_str(cache_key: _CacheKey) -> str:
|
||||
"""Serialize a cache key for on-disk storage, omitting home_path.
|
||||
|
||||
The disk file is already partitioned by home (it lives under
|
||||
``<home>/cache/``), so the path provides the home dimension; folding it
|
||||
into the key string too would be redundant.
|
||||
"""
|
||||
auth_fp, account, _home, refs_fp = cache_key
|
||||
return f"{auth_fp}|{account}|{refs_fp}"
|
||||
|
||||
|
||||
_DISK_CACHE: DiskCache = DiskCache(
|
||||
_DISK_CACHE_BASENAME, key_serializer=_disk_key_str
|
||||
)
|
||||
|
||||
|
||||
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
|
||||
"""Path to the on-disk cache (exposed for tests and direct callers)."""
|
||||
return _DISK_CACHE.path(home_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reference validation + fingerprinting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_references(
|
||||
references: Optional[Dict[str, str]],
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
"""Return ``(valid_refs, warnings)`` from an ``env`` mapping.
|
||||
|
||||
A reference is kept only if its target env-var name is a valid POSIX
|
||||
name and the value is a stripped ``op://…`` reference string. Everything
|
||||
else produces a warning and is dropped (never fatal).
|
||||
"""
|
||||
valid: Dict[str, str] = {}
|
||||
warnings: List[str] = []
|
||||
for name, ref in (references or {}).items():
|
||||
if not is_valid_env_name(name):
|
||||
warnings.append(f"Skipping {name!r}: not a valid env-var name")
|
||||
continue
|
||||
if not isinstance(ref, str):
|
||||
warnings.append(f"Skipping {name!r}: reference is not a string")
|
||||
continue
|
||||
cleaned = ref.strip()
|
||||
if not cleaned.startswith("op://"):
|
||||
warnings.append(
|
||||
f"Skipping {name!r}: {ref!r} is not an op:// secret reference"
|
||||
)
|
||||
continue
|
||||
valid[name] = cleaned
|
||||
return valid, warnings
|
||||
|
||||
|
||||
def _auth_fingerprint(token_env: str) -> str:
|
||||
"""SHA-256 prefix over the auth material `op` would use.
|
||||
|
||||
Folds in the service-account token, ``OP_ACCOUNT``, the 1Password Connect
|
||||
``OP_CONNECT_HOST``/``OP_CONNECT_TOKEN``, and *all* ``OP_SESSION_*`` vars
|
||||
(the names `op` actually exports for interactive sessions —
|
||||
``OP_SESSION_<account_shorthand>``). Signing out and into a different
|
||||
identity therefore changes the cache key, so a value cached under a
|
||||
previous identity is never served under a new one. Never logged or
|
||||
displayed; the raw token never leaves this hash.
|
||||
"""
|
||||
source_env = get_source_environment()
|
||||
parts: List[str] = [
|
||||
f"token={source_env.get(token_env, '')}",
|
||||
f"account={source_env.get('OP_ACCOUNT', '')}",
|
||||
f"connect_host={source_env.get('OP_CONNECT_HOST', '')}",
|
||||
f"connect_token={source_env.get('OP_CONNECT_TOKEN', '')}",
|
||||
]
|
||||
for key in sorted(source_env):
|
||||
if key.startswith("OP_SESSION_"):
|
||||
parts.append(f"{key}={source_env[key]}")
|
||||
material = "\n".join(parts)
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _refs_fingerprint(references: Dict[str, str]) -> str:
|
||||
"""SHA-256 prefix over the configured name→reference mapping."""
|
||||
material = "\n".join(f"{name}={references[name]}" for name in sorted(references))
|
||||
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Binary discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def find_op(binary_path: str = "") -> Optional[Path]:
|
||||
"""Resolve a usable ``op`` binary, or None.
|
||||
|
||||
When ``binary_path`` is set it is used verbatim and PATH is NOT consulted
|
||||
— pinning an absolute path is a way to avoid trusting whatever ``op`` shows
|
||||
up first on ``PATH``. A pinned-but-missing path returns None (the caller
|
||||
surfaces a clear error) rather than silently falling back.
|
||||
"""
|
||||
if binary_path:
|
||||
pinned = Path(binary_path)
|
||||
if pinned.exists() and os.access(pinned, os.X_OK):
|
||||
return pinned
|
||||
return None
|
||||
found = shutil.which("op")
|
||||
return Path(found) if found else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `op read` invocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _scrub(text: str) -> str:
|
||||
"""Remove ANSI control sequences and trim, for safe message surfacing."""
|
||||
from tools.ansi_strip import strip_ansi
|
||||
|
||||
# strip_ansi removes well-formed sequences; drop any stray lone ESC too.
|
||||
return strip_ansi(text).replace("\x1b", "").strip()
|
||||
|
||||
|
||||
def _op_child_env(token_value: str) -> Dict[str, str]:
|
||||
"""Build a minimal allowlisted environment for the ``op`` child process."""
|
||||
source_env = get_source_environment()
|
||||
env: Dict[str, str] = {}
|
||||
for key in _OP_ENV_ALLOWLIST:
|
||||
val = source_env.get(key)
|
||||
if val is not None:
|
||||
env[key] = val
|
||||
# Desktop / interactive session credentials.
|
||||
for key, val in source_env.items():
|
||||
if key.startswith("OP_SESSION_"):
|
||||
env[key] = val
|
||||
# `op` reads OP_SERVICE_ACCOUNT_TOKEN regardless of which env var the user
|
||||
# configured Hermes to source it from, so normalize to that name here.
|
||||
if token_value:
|
||||
env["OP_SERVICE_ACCOUNT_TOKEN"] = token_value
|
||||
env["NO_COLOR"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def _run_op_read(
|
||||
op: Path,
|
||||
reference: str,
|
||||
*,
|
||||
account: str = "",
|
||||
token_value: str = "",
|
||||
) -> str:
|
||||
"""Resolve a single ``op://`` reference to its value.
|
||||
|
||||
Raises :class:`RuntimeError` on any failure — including a ``returncode 0``
|
||||
with empty output, which would otherwise silently clobber a good
|
||||
``.env``/shell credential with ``""``.
|
||||
"""
|
||||
cmd: List[str] = [str(op), "read"]
|
||||
if account:
|
||||
cmd += ["--account", account]
|
||||
# `--` terminates option parsing so a reference can never be mis-parsed as
|
||||
# an `op` flag even if validation is ever loosened.
|
||||
cmd += ["--", reference]
|
||||
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603 — op path is user-trusted, argv list
|
||||
cmd,
|
||||
env=_op_child_env(token_value),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_OP_RUN_TIMEOUT,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
f"op read timed out after {_OP_RUN_TIMEOUT}s for {reference!r}"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to invoke op: {exc}") from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = _scrub(proc.stderr or "")[:200]
|
||||
if err:
|
||||
raise RuntimeError(f"op read failed for {reference!r}: {err}")
|
||||
raise RuntimeError(
|
||||
f"op read exited {proc.returncode} for {reference!r}"
|
||||
)
|
||||
|
||||
# `op` appends a trailing newline; strip only that so a value with
|
||||
# intentional internal/edge spaces survives. But a value that is empty or
|
||||
# whitespace-only is treated as empty: applying it would silently clobber a
|
||||
# good .env/shell credential with effectively nothing.
|
||||
value = (proc.stdout or "").rstrip("\r\n")
|
||||
if not value.strip():
|
||||
raise RuntimeError(f"op read returned an empty value for {reference!r}")
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def fetch_onepassword_secrets(
|
||||
*,
|
||||
references: Dict[str, str],
|
||||
account: str = "",
|
||||
token_env: str = _DEFAULT_TOKEN_ENV,
|
||||
binary: Optional[Path] = None,
|
||||
binary_path: str = "",
|
||||
use_cache: bool = True,
|
||||
cache_ttl_seconds: float = 300,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> Tuple[Dict[str, str], List[str]]:
|
||||
"""Resolve ``references`` (name → ``op://…``) to ``(secrets, warnings)``.
|
||||
|
||||
Raises :class:`RuntimeError` only when no ``op`` binary is available — a
|
||||
fatal "can't fetch anything" condition. Per-reference failures (expired
|
||||
auth, bad reference, empty value) are collected as warnings and the
|
||||
reference is dropped, so one bad entry never sinks the rest.
|
||||
|
||||
Only a complete, error-free pull is cached, so a transient auth failure
|
||||
isn't frozen in for the whole TTL window.
|
||||
"""
|
||||
valid, warnings = _validate_references(references)
|
||||
if not valid:
|
||||
return {}, warnings
|
||||
|
||||
token_value = get_source_environment().get(token_env, "").strip()
|
||||
cache_key: _CacheKey = (
|
||||
_auth_fingerprint(token_env),
|
||||
account or "",
|
||||
str(home_path) if home_path is not None else "",
|
||||
_refs_fingerprint(valid),
|
||||
)
|
||||
|
||||
if use_cache:
|
||||
cached = _CACHE.get(cache_key)
|
||||
if cached and cached.is_fresh(cache_ttl_seconds):
|
||||
return dict(cached.secrets), warnings
|
||||
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
|
||||
if disk_cached is not None:
|
||||
# Promote into L1 so later fetches in this process skip the disk read.
|
||||
_CACHE[cache_key] = disk_cached
|
||||
return dict(disk_cached.secrets), warnings
|
||||
|
||||
op = binary or find_op(binary_path)
|
||||
if op is None:
|
||||
raise RuntimeError(
|
||||
"op CLI not found. Install the 1Password CLI "
|
||||
"(https://developer.1password.com/docs/cli/get-started/) or set "
|
||||
"secrets.onepassword.binary_path to its absolute location."
|
||||
)
|
||||
|
||||
secrets: Dict[str, str] = {}
|
||||
read_errors = 0
|
||||
for name in sorted(valid):
|
||||
try:
|
||||
secrets[name] = _run_op_read(
|
||||
op, valid[name], account=account, token_value=token_value
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
warnings.append(str(exc))
|
||||
read_errors += 1
|
||||
|
||||
if use_cache and not read_errors and secrets:
|
||||
entry = CachedFetch(secrets=dict(secrets), fetched_at=time.time())
|
||||
_CACHE[cache_key] = entry
|
||||
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
|
||||
|
||||
return secrets, warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point — called from hermes_cli.env_loader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_onepassword_secrets(
|
||||
*,
|
||||
enabled: bool,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
account: str = "",
|
||||
service_account_token_env: str = _DEFAULT_TOKEN_ENV,
|
||||
binary_path: str = "",
|
||||
override_existing: bool = True,
|
||||
cache_ttl_seconds: float = 300,
|
||||
home_path: Optional[Path] = None,
|
||||
) -> FetchResult:
|
||||
"""Resolve configured ``op://`` references and set them on ``os.environ``.
|
||||
|
||||
Called by ``load_hermes_dotenv()`` after the .env files have loaded.
|
||||
Intentionally defensive — any failure returns a :class:`FetchResult` with
|
||||
``error`` set (or surfaces warnings); it never raises.
|
||||
|
||||
Parameters mirror the ``secrets.onepassword.*`` config keys so the caller
|
||||
can splat the dict in. References that are already satisfied by the
|
||||
current environment (when ``override_existing`` is false) are skipped
|
||||
*before* fetching, so ``op`` is never invoked for a value that would be
|
||||
discarded.
|
||||
"""
|
||||
result = FetchResult()
|
||||
|
||||
if not enabled:
|
||||
return result
|
||||
|
||||
valid, warnings = _validate_references(env)
|
||||
result.warnings.extend(warnings)
|
||||
|
||||
# Skip-before-fetch: never resolve a reference we'd only throw away.
|
||||
refs_to_fetch: Dict[str, str] = {}
|
||||
for name, ref in valid.items():
|
||||
if name == service_account_token_env:
|
||||
# Never let a resolved secret clobber the very token used to auth.
|
||||
result.skipped.append(name)
|
||||
continue
|
||||
if not override_existing and os.environ.get(name):
|
||||
result.skipped.append(name)
|
||||
continue
|
||||
refs_to_fetch[name] = ref
|
||||
|
||||
if not refs_to_fetch:
|
||||
return result
|
||||
|
||||
binary = find_op(binary_path)
|
||||
result.binary_path = binary
|
||||
if binary is None:
|
||||
if binary_path:
|
||||
result.error = (
|
||||
f"secrets.onepassword.binary_path ({binary_path!r}) is not an "
|
||||
"executable op binary."
|
||||
)
|
||||
else:
|
||||
result.error = (
|
||||
"secrets.onepassword.enabled is true but the op CLI was not "
|
||||
"found on PATH. Install it "
|
||||
"(https://developer.1password.com/docs/cli/get-started/) or set "
|
||||
"secrets.onepassword.binary_path."
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
secrets, fetch_warnings = fetch_onepassword_secrets(
|
||||
references=refs_to_fetch,
|
||||
account=account,
|
||||
token_env=service_account_token_env,
|
||||
binary=binary,
|
||||
cache_ttl_seconds=cache_ttl_seconds,
|
||||
home_path=home_path,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
result.error = str(exc)
|
||||
return result
|
||||
|
||||
result.secrets = secrets
|
||||
result.warnings.extend(fetch_warnings)
|
||||
|
||||
for name, value in secrets.items():
|
||||
# The token-var and override guards already filtered refs_to_fetch, but
|
||||
# re-check defensively in case the fetch layer ever returns extras.
|
||||
if name == service_account_token_env:
|
||||
if name not in result.skipped:
|
||||
result.skipped.append(name)
|
||||
continue
|
||||
if not override_existing and os.environ.get(name):
|
||||
if name not in result.skipped:
|
||||
result.skipped.append(name)
|
||||
continue
|
||||
os.environ[name] = value
|
||||
result.applied.append(name)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SecretSource adapter — the registry-facing wrapper around this module.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OnePasswordSource(SecretSource):
|
||||
"""1Password as a registered secret source.
|
||||
|
||||
Thin adapter over the module's fetch machinery. ``fetch()`` only
|
||||
*fetches* — precedence, override semantics, conflict warnings, and
|
||||
the ``os.environ`` writes are the orchestrator's job
|
||||
(see ``agent.secret_sources.registry.apply_all``).
|
||||
|
||||
1Password is a **mapped** source: the user explicitly binds each env
|
||||
var to an ``op://`` reference under ``secrets.onepassword.env``, so
|
||||
its claims outrank bulk sources (e.g. a Bitwarden project dump) on
|
||||
contested vars.
|
||||
"""
|
||||
|
||||
name = "onepassword"
|
||||
label = "1Password"
|
||||
shape = "mapped"
|
||||
scheme = "op"
|
||||
|
||||
def override_existing(self, cfg: dict) -> bool:
|
||||
# Default True: an explicit VAR→op:// binding is the strongest
|
||||
# user intent there is — leaving a stale .env line in place
|
||||
# should not silently defeat it (same rotation rationale as
|
||||
# Bitwarden).
|
||||
return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
|
||||
|
||||
def protected_env_vars(self, cfg: dict):
|
||||
token_env = _DEFAULT_TOKEN_ENV
|
||||
if isinstance(cfg, dict):
|
||||
token_env = str(cfg.get("service_account_token_env") or token_env)
|
||||
return frozenset({token_env})
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return {
|
||||
"enabled": {"description": "Master switch", "default": False},
|
||||
"env": {
|
||||
"description": "Map of ENV_VAR -> op://vault/item/field reference",
|
||||
"default": {},
|
||||
},
|
||||
"account": {
|
||||
"description": "op --account shorthand (empty = default account)",
|
||||
"default": "",
|
||||
},
|
||||
"service_account_token_env": {
|
||||
"description": "Env var holding the service-account token "
|
||||
"(unset = desktop/interactive session)",
|
||||
"default": _DEFAULT_TOKEN_ENV,
|
||||
},
|
||||
"binary_path": {
|
||||
"description": "Pin the op binary (empty = resolve via PATH)",
|
||||
"default": "",
|
||||
},
|
||||
"cache_ttl_seconds": {
|
||||
"description": "Disk+memory cache TTL; 0 disables",
|
||||
"default": 300,
|
||||
},
|
||||
"override_existing": {
|
||||
"description": "Resolved values overwrite .env/shell values",
|
||||
"default": True,
|
||||
},
|
||||
}
|
||||
|
||||
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
result = FetchResult()
|
||||
|
||||
env_map = cfg.get("env")
|
||||
valid, warnings = _validate_references(
|
||||
env_map if isinstance(env_map, dict) else None
|
||||
)
|
||||
result.warnings.extend(warnings)
|
||||
if not valid:
|
||||
if not warnings:
|
||||
result.error = (
|
||||
"secrets.onepassword.enabled is true but the env: map is "
|
||||
"empty. Add ENV_VAR: op://vault/item/field entries."
|
||||
)
|
||||
result.error_kind = ErrorKind.NOT_CONFIGURED
|
||||
return result
|
||||
|
||||
binary_path = str(cfg.get("binary_path") or "")
|
||||
binary = find_op(binary_path)
|
||||
result.binary_path = binary
|
||||
if binary is None:
|
||||
if binary_path:
|
||||
result.error = (
|
||||
f"secrets.onepassword.binary_path ({binary_path!r}) is "
|
||||
"not an executable op binary."
|
||||
)
|
||||
else:
|
||||
result.error = (
|
||||
"secrets.onepassword.enabled is true but the op CLI was "
|
||||
"not found on PATH. Install it "
|
||||
"(https://developer.1password.com/docs/cli/get-started/) "
|
||||
"or set secrets.onepassword.binary_path."
|
||||
)
|
||||
result.error_kind = ErrorKind.BINARY_MISSING
|
||||
return result
|
||||
|
||||
try:
|
||||
ttl = float(cfg.get("cache_ttl_seconds", 300))
|
||||
except (TypeError, ValueError):
|
||||
ttl = 300.0
|
||||
|
||||
try:
|
||||
secrets, fetch_warnings = fetch_onepassword_secrets(
|
||||
references=valid,
|
||||
account=str(cfg.get("account") or ""),
|
||||
token_env=str(
|
||||
cfg.get("service_account_token_env") or _DEFAULT_TOKEN_ENV
|
||||
),
|
||||
binary=binary,
|
||||
cache_ttl_seconds=ttl,
|
||||
home_path=home_path,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
result.error = str(exc)
|
||||
result.error_kind = _classify_op_error(str(exc))
|
||||
return result
|
||||
|
||||
result.secrets = secrets
|
||||
result.warnings.extend(fetch_warnings)
|
||||
return result
|
||||
|
||||
def remediation(self, kind, cfg: dict) -> str:
|
||||
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
|
||||
token_env = _DEFAULT_TOKEN_ENV
|
||||
if isinstance(cfg, dict):
|
||||
token_env = str(cfg.get("service_account_token_env") or token_env)
|
||||
return (
|
||||
"Run `hermes secrets onepassword token` to paste a fresh "
|
||||
f"service-account token ({token_env}), or `op signin` for an "
|
||||
"interactive session."
|
||||
)
|
||||
if kind == ErrorKind.BINARY_MISSING:
|
||||
return (
|
||||
"Install the 1Password CLI "
|
||||
"(https://developer.1password.com/docs/cli/get-started/) or "
|
||||
"set secrets.onepassword.binary_path."
|
||||
)
|
||||
return super().remediation(kind, cfg)
|
||||
|
||||
|
||||
def _classify_op_error(message: str) -> ErrorKind:
|
||||
"""Best-effort mapping of op failure text onto the shared taxonomy."""
|
||||
lowered = message.lower()
|
||||
if "timed out" in lowered:
|
||||
return ErrorKind.TIMEOUT
|
||||
if "not found on path" in lowered or "not an executable" in lowered \
|
||||
or "failed to invoke" in lowered:
|
||||
return ErrorKind.BINARY_MISSING
|
||||
if any(tok in lowered for tok in ("unauthorized", "not signed in",
|
||||
"session expired", "authentication",
|
||||
"401", "403")):
|
||||
return ErrorKind.AUTH_FAILED
|
||||
if "empty value" in lowered:
|
||||
return ErrorKind.EMPTY_VALUE
|
||||
if any(tok in lowered for tok in ("network", "connection", "resolve host",
|
||||
"dns")):
|
||||
return ErrorKind.NETWORK
|
||||
return ErrorKind.INTERNAL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test hook — used by hermetic tests to flush the cache between cases.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def clear_caches(home_path: Optional[Path] = None) -> None:
|
||||
"""Drop in-process AND disk caches.
|
||||
|
||||
Used after a token rotation (`hermes secrets onepassword token`) so
|
||||
the next startup resolves fresh with the new credential instead of
|
||||
serving values cached under the old token's fingerprint.
|
||||
"""
|
||||
_CACHE.clear()
|
||||
_DISK_CACHE.clear(home_path)
|
||||
|
||||
|
||||
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
|
||||
"""Clear in-process AND disk caches.
|
||||
|
||||
Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.
|
||||
Without it we fall back to the same default resolution as the writer.
|
||||
"""
|
||||
clear_caches(home_path)
|
||||
@@ -0,0 +1,564 @@
|
||||
"""Secret-source registry + apply orchestrator.
|
||||
|
||||
This module owns everything that must be uniform across secret backends
|
||||
so no individual source can get it wrong:
|
||||
|
||||
* registration (name/scheme uniqueness, API-version gating)
|
||||
* per-source wall-clock timeout enforcement around ``fetch()``
|
||||
* precedence: mapped sources beat bulk sources; within a shape,
|
||||
``secrets.sources`` order (or registration order) decides; first
|
||||
claim wins — later sources never silently clobber an earlier one
|
||||
* ``override_existing`` semantics (may beat .env/shell, never another
|
||||
secret source, never a protected var)
|
||||
* cross-source conflict warnings (shadowed claims are always surfaced)
|
||||
* provenance: which source supplied every applied var
|
||||
|
||||
The single entry point for startup is :func:`apply_all`, called from
|
||||
``hermes_cli.env_loader._apply_external_secret_sources()``.
|
||||
|
||||
Plugins register additional sources via
|
||||
``PluginContext.register_secret_source()`` which lands in
|
||||
:func:`register_source`. In-tree sources are registered lazily by
|
||||
:func:`_ensure_builtin_sources` — the set of bundled sources is
|
||||
deliberately closed (Bitwarden, and 1Password once it lands); new
|
||||
third-party backends ship as standalone plugin repos implementing
|
||||
:class:`agent.secret_sources.base.SecretSource`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, MutableMapping, Optional
|
||||
|
||||
from agent.secret_sources.base import (
|
||||
SECRET_SOURCE_API_VERSION,
|
||||
ErrorKind,
|
||||
FetchResult,
|
||||
SecretSource,
|
||||
is_valid_env_name,
|
||||
reset_source_environment,
|
||||
set_source_environment,
|
||||
)
|
||||
from hermes_constants import hermes_home_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ordered registry: name → source instance. Python dicts preserve
|
||||
# insertion order, which doubles as the default apply order. Origin is
|
||||
# recorded beside each source so consumers never infer ownership from names.
|
||||
_SOURCES: Dict[str, SecretSource] = {}
|
||||
_SOURCE_ORIGINS: Dict[str, str] = {}
|
||||
_SCOPED_SOURCES: Dict[str, Dict[str, SecretSource]] = {}
|
||||
_BUILTINS_LOADED = False
|
||||
_REGISTRY_LOCK = threading.RLock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppliedVar:
|
||||
"""Provenance record for one env var the orchestrator set."""
|
||||
|
||||
name: str
|
||||
source: str # SecretSource.name
|
||||
shape: str # "mapped" | "bulk"
|
||||
overrode_env: bool # replaced a pre-existing .env/shell value
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceReport:
|
||||
"""One source's outcome within an :class:`ApplyReport`."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
result: FetchResult
|
||||
applied: List[str] = field(default_factory=list)
|
||||
skipped_existing: List[str] = field(default_factory=list) # .env/shell won
|
||||
skipped_claimed: List[str] = field(default_factory=list) # earlier source won
|
||||
skipped_protected: List[str] = field(default_factory=list) # bootstrap-auth guard
|
||||
skipped_invalid: List[str] = field(default_factory=list) # bad env-var name
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyReport:
|
||||
"""Merged outcome of one orchestrated apply pass."""
|
||||
|
||||
sources: List[SourceReport] = field(default_factory=list)
|
||||
provenance: Dict[str, AppliedVar] = field(default_factory=dict)
|
||||
conflicts: List[str] = field(default_factory=list) # human-readable warnings
|
||||
|
||||
@property
|
||||
def applied_any(self) -> bool:
|
||||
return bool(self.provenance)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def register_source(
|
||||
source: SecretSource,
|
||||
*,
|
||||
replace: bool = False,
|
||||
builtin: bool = False,
|
||||
scope: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Register a secret source. Returns True on success.
|
||||
|
||||
Rejections are logged, never raised — a bad plugin must not take
|
||||
down startup. ``replace`` allows tests / user plugins to override
|
||||
a bundled source of the same name (last-writer-wins like model
|
||||
providers), but scheme collisions across *different* names are
|
||||
always rejected.
|
||||
"""
|
||||
if not isinstance(source, SecretSource):
|
||||
logger.warning(
|
||||
"Ignoring secret source %r: does not inherit from SecretSource",
|
||||
source,
|
||||
)
|
||||
return False
|
||||
name = getattr(source, "name", "") or ""
|
||||
if not name or not name.replace("_", "").isalnum() or name != name.lower():
|
||||
logger.warning("Ignoring secret source with invalid name %r", name)
|
||||
return False
|
||||
if getattr(source, "api_version", None) != SECRET_SOURCE_API_VERSION:
|
||||
logger.warning(
|
||||
"Ignoring secret source '%s': built against secret-source API v%s, "
|
||||
"this Hermes speaks v%s",
|
||||
name, getattr(source, "api_version", "?"), SECRET_SOURCE_API_VERSION,
|
||||
)
|
||||
return False
|
||||
if getattr(source, "shape", None) not in ("mapped", "bulk"):
|
||||
logger.warning(
|
||||
"Ignoring secret source '%s': shape must be 'mapped' or 'bulk', got %r",
|
||||
name, getattr(source, "shape", None),
|
||||
)
|
||||
return False
|
||||
with _REGISTRY_LOCK:
|
||||
effective = dict(_SOURCES)
|
||||
if scope is not None:
|
||||
effective.update(_SCOPED_SOURCES.get(scope, {}))
|
||||
if name in effective and not replace:
|
||||
logger.warning(
|
||||
"Secret source '%s' already registered; ignoring duplicate", name
|
||||
)
|
||||
return False
|
||||
scheme = getattr(source, "scheme", None)
|
||||
if scheme:
|
||||
for other_name, other in effective.items():
|
||||
if other_name != name and getattr(other, "scheme", None) == scheme:
|
||||
logger.warning(
|
||||
"Ignoring secret source '%s': scheme '%s://' is already "
|
||||
"owned by source '%s'",
|
||||
name,
|
||||
scheme,
|
||||
other_name,
|
||||
)
|
||||
return False
|
||||
target = _SOURCES if scope is None else _SCOPED_SOURCES.setdefault(scope, {})
|
||||
target[name] = source
|
||||
if scope is None:
|
||||
_SOURCE_ORIGINS[name] = "builtin" if builtin else "plugin"
|
||||
return True
|
||||
|
||||
|
||||
def get_source(name: str, *, scope: Optional[str] = None) -> Optional[SecretSource]:
|
||||
_ensure_builtin_sources()
|
||||
with _REGISTRY_LOCK:
|
||||
return _SCOPED_SOURCES.get(scope or hermes_home_key(), {}).get(
|
||||
name
|
||||
) or _SOURCES.get(name)
|
||||
|
||||
|
||||
def snapshot_registration(
|
||||
name: str, *, scope: Optional[str] = None
|
||||
) -> Optional[SecretSource]:
|
||||
"""Return the registration owned by exactly one registry layer."""
|
||||
_ensure_builtin_sources()
|
||||
with _REGISTRY_LOCK:
|
||||
target = _SOURCES if scope is None else _SCOPED_SOURCES.get(scope, {})
|
||||
return target.get(name)
|
||||
|
||||
|
||||
def restore_registration(
|
||||
name: str,
|
||||
current: SecretSource,
|
||||
previous: Optional[SecretSource],
|
||||
*,
|
||||
scope: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Restore a host-owned source registration if it is still current."""
|
||||
_ensure_builtin_sources()
|
||||
with _REGISTRY_LOCK:
|
||||
target = _SOURCES if scope is None else _SCOPED_SOURCES.setdefault(scope, {})
|
||||
if target.get(name) is not current:
|
||||
return False
|
||||
if previous is None:
|
||||
target.pop(name, None)
|
||||
else:
|
||||
target[name] = previous
|
||||
if scope is not None and not target:
|
||||
_SCOPED_SOURCES.pop(scope, None)
|
||||
return True
|
||||
|
||||
|
||||
def list_sources(*, scope: Optional[str] = None) -> List[SecretSource]:
|
||||
_ensure_builtin_sources()
|
||||
with _REGISTRY_LOCK:
|
||||
merged = dict(_SOURCES)
|
||||
merged.update(_SCOPED_SOURCES.get(scope or hermes_home_key(), {}))
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def list_plugin_sources() -> List[SecretSource]:
|
||||
"""Return sources registered outside the bundled bootstrap set.
|
||||
|
||||
Includes both legacy global plugin registrations (``_SOURCE_ORIGINS ==
|
||||
"plugin"``) and the current scope's profile-keyed registrations — every
|
||||
scoped entry is plugin-registered by definition, since bundled sources
|
||||
register with ``scope=None`` (#64229 profile isolation).
|
||||
"""
|
||||
_ensure_builtin_sources()
|
||||
with _REGISTRY_LOCK:
|
||||
merged: Dict[str, SecretSource] = {
|
||||
name: source
|
||||
for name, source in _SOURCES.items()
|
||||
if _SOURCE_ORIGINS.get(name) == "plugin"
|
||||
}
|
||||
merged.update(_SCOPED_SOURCES.get(hermes_home_key(), {}))
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
def _ensure_builtin_sources() -> None:
|
||||
"""Idempotently register the bundled sources.
|
||||
|
||||
Lazy so importing this module stays cheap and so a broken bundled
|
||||
source can never break registration of the others.
|
||||
"""
|
||||
global _BUILTINS_LOADED
|
||||
with _REGISTRY_LOCK:
|
||||
if _BUILTINS_LOADED:
|
||||
return
|
||||
_BUILTINS_LOADED = True
|
||||
try:
|
||||
from agent.secret_sources.bitwarden import BitwardenSource
|
||||
|
||||
register_source(BitwardenSource(), builtin=True)
|
||||
except Exception: # noqa: BLE001 — never block startup
|
||||
logger.warning(
|
||||
"Failed to register bundled Bitwarden secret source",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from agent.secret_sources.onepassword import OnePasswordSource
|
||||
|
||||
register_source(OnePasswordSource(), builtin=True)
|
||||
except Exception: # noqa: BLE001 — never block startup
|
||||
logger.warning(
|
||||
"Failed to register bundled 1Password secret source",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from agent.secret_sources.command import CommandSource
|
||||
|
||||
register_source(CommandSource(), builtin=True)
|
||||
except Exception: # noqa: BLE001 — never block startup
|
||||
logger.warning(
|
||||
"Failed to register bundled command secret source",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _reset_registry_for_tests() -> None:
|
||||
global _BUILTINS_LOADED
|
||||
with _REGISTRY_LOCK:
|
||||
_SOURCES.clear()
|
||||
_SOURCE_ORIGINS.clear()
|
||||
_SCOPED_SOURCES.clear()
|
||||
_BUILTINS_LOADED = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrated apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fetch_with_timeout(
|
||||
source: SecretSource, cfg: dict, home_path: Path,
|
||||
environ: MutableMapping[str, str],
|
||||
) -> FetchResult:
|
||||
"""Run source.fetch() under a wall-clock budget; never raises.
|
||||
|
||||
The budget is enforced with a daemon worker thread: a source that
|
||||
blows its budget is reported as ``TIMEOUT`` and its (eventual)
|
||||
result is discarded. The thread itself may linger until process
|
||||
exit — acceptable for a startup-only path, and strictly better than
|
||||
an unbounded hang on every ``hermes`` invocation.
|
||||
"""
|
||||
timeout = source.fetch_timeout_seconds(cfg)
|
||||
executor = concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=1, thread_name_prefix=f"secret-src-{source.name}"
|
||||
)
|
||||
try:
|
||||
def _fetch() -> FetchResult:
|
||||
token = set_source_environment(environ)
|
||||
try:
|
||||
return source.fetch(cfg, home_path)
|
||||
finally:
|
||||
reset_source_environment(token)
|
||||
|
||||
future = executor.submit(_fetch)
|
||||
try:
|
||||
result = future.result(timeout=timeout)
|
||||
except concurrent.futures.TimeoutError:
|
||||
future.cancel()
|
||||
res = FetchResult()
|
||||
res.error = (
|
||||
f"fetch exceeded {timeout:.0f}s budget — startup continued "
|
||||
"without this source (raise secrets."
|
||||
f"{source.name}.timeout_seconds if the backend is just slow)"
|
||||
)
|
||||
res.error_kind = ErrorKind.TIMEOUT
|
||||
return res
|
||||
except Exception as exc: # noqa: BLE001 — contract violation, contain it
|
||||
res = FetchResult()
|
||||
res.error = f"fetch raised {type(exc).__name__}: {exc}"
|
||||
res.error_kind = ErrorKind.INTERNAL
|
||||
return res
|
||||
finally:
|
||||
executor.shutdown(wait=False)
|
||||
|
||||
if not isinstance(result, FetchResult):
|
||||
res = FetchResult()
|
||||
res.error = (
|
||||
f"fetch returned {type(result).__name__} instead of FetchResult"
|
||||
)
|
||||
res.error_kind = ErrorKind.INTERNAL
|
||||
return res
|
||||
return result
|
||||
|
||||
|
||||
def _ordered_enabled_sources(
|
||||
secrets_cfg: dict, *, scope: Optional[str] = None
|
||||
) -> List[SecretSource]:
|
||||
"""Resolve which sources run, in which order.
|
||||
|
||||
Order: the optional ``secrets.sources`` list wins; sources not named
|
||||
there follow in registration order. Enabled = the source's own
|
||||
``is_enabled`` says so for its config section. Mapped-vs-bulk
|
||||
precedence is applied on top of this order by :func:`apply_all`.
|
||||
"""
|
||||
sources = {source.name: source for source in list_sources(scope=scope)}
|
||||
|
||||
explicit = secrets_cfg.get("sources")
|
||||
order: List[str] = []
|
||||
if isinstance(explicit, list):
|
||||
for entry in explicit:
|
||||
if isinstance(entry, str) and entry in sources and entry not in order:
|
||||
order.append(entry)
|
||||
unknown = [e for e in explicit
|
||||
if isinstance(e, str) and e not in sources]
|
||||
if unknown:
|
||||
logger.warning(
|
||||
"secrets.sources names unknown source(s): %s (known: %s)",
|
||||
", ".join(unknown), ", ".join(sources) or "none",
|
||||
)
|
||||
for name in sources:
|
||||
if name not in order:
|
||||
order.append(name)
|
||||
|
||||
enabled: List[SecretSource] = []
|
||||
for name in order:
|
||||
source = sources[name]
|
||||
cfg = secrets_cfg.get(name)
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
try:
|
||||
if source.is_enabled(cfg):
|
||||
enabled.append(source)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("Secret source '%s' is_enabled() raised; skipping",
|
||||
name, exc_info=True)
|
||||
return enabled
|
||||
|
||||
|
||||
def _active_profile_name(home_path: Optional[Path]) -> str:
|
||||
"""Best-effort active profile name for profile-scoped secret aliases.
|
||||
|
||||
A named profile's HERMES_HOME is ``~/.hermes/profiles/<name>``; the
|
||||
default profile (``~/.hermes``) returns "".
|
||||
"""
|
||||
if home_path is not None:
|
||||
resolved = Path(home_path)
|
||||
if resolved.parent.name == "profiles" and resolved.name:
|
||||
return resolved.name
|
||||
for env_name in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"):
|
||||
value = os.environ.get(env_name, "").strip()
|
||||
if value and value != "default":
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
# Only credential-shaped names get auto-aliased — a random profile-suffixed
|
||||
# var should not silently hydrate an unsuffixed name.
|
||||
_ALIAS_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY", "_PASSWORD")
|
||||
|
||||
|
||||
def _profile_alias_target(var: str, profile: str) -> Optional[str]:
|
||||
"""Map ``FOO_<PROFILE>`` to ``FOO`` for the active profile when safe."""
|
||||
if not profile:
|
||||
return None
|
||||
suffix = "_" + profile.replace("-", "_").upper()
|
||||
if not var.endswith(suffix):
|
||||
return None
|
||||
alias = var[: -len(suffix)]
|
||||
if not alias or not is_valid_env_name(alias):
|
||||
return None
|
||||
if not any(alias.endswith(s) for s in _ALIAS_SUFFIXES):
|
||||
return None
|
||||
return alias
|
||||
|
||||
|
||||
def apply_all(secrets_cfg: dict, home_path: Path,
|
||||
environ: Optional[MutableMapping[str, str]] = None) -> ApplyReport:
|
||||
"""Fetch from every enabled source and apply the merged result to env.
|
||||
|
||||
``environ`` defaults to ``os.environ``; injectable for tests.
|
||||
|
||||
Precedence per env var (most-specific intent wins):
|
||||
|
||||
1. ``secrets.preserve_existing`` names — a pre-existing env value always
|
||||
wins for these, even against a source with ``override_existing: true``
|
||||
(escape hatch for profile-local platform secrets, #58073).
|
||||
2. Pre-existing env (.env / shell) — unless the winning source has
|
||||
``override_existing: true``.
|
||||
3. Mapped sources, in configured order.
|
||||
4. Bulk sources, in configured order.
|
||||
|
||||
First claim wins. A later source that also carries the var gets a
|
||||
``skipped_claimed`` entry and a conflict warning — never a silent
|
||||
clobber, and ``override_existing`` never applies across sources.
|
||||
|
||||
Profile aliasing (#51447): when running under a named profile, an applied
|
||||
var ``FOO_<PROFILE>`` (credential-shaped suffixes only) also hydrates the
|
||||
canonical ``FOO`` so platform adapters and plugins that read fixed env
|
||||
names see the profile's value. The alias obeys the same protected /
|
||||
preserve / claimed / override guards and is disabled with
|
||||
``secrets.profile_alias: false``.
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
env = environ if environ is not None else _os.environ
|
||||
report = ApplyReport()
|
||||
|
||||
secrets_cfg = secrets_cfg if isinstance(secrets_cfg, dict) else {}
|
||||
enabled = _ordered_enabled_sources(
|
||||
secrets_cfg, scope=hermes_home_key(home_path)
|
||||
)
|
||||
if not enabled:
|
||||
return report
|
||||
|
||||
preserve_raw = secrets_cfg.get("preserve_existing")
|
||||
preserve: frozenset = frozenset(
|
||||
n.strip() for n in preserve_raw if isinstance(n, str) and n.strip()
|
||||
) if isinstance(preserve_raw, list) else frozenset()
|
||||
|
||||
alias_enabled = bool(secrets_cfg.get("profile_alias", True))
|
||||
profile = _active_profile_name(home_path) if alias_enabled else ""
|
||||
|
||||
# Mapped sources outrank bulk sources regardless of list order:
|
||||
# an explicit VAR→ref binding is stronger intent than a project dump.
|
||||
ordered = ([s for s in enabled if s.shape == "mapped"]
|
||||
+ [s for s in enabled if s.shape == "bulk"])
|
||||
|
||||
# Fetch phase.
|
||||
fetches: List[tuple[SecretSource, dict, FetchResult]] = []
|
||||
protected: Dict[str, str] = {} # var → source that protects it
|
||||
for source in ordered:
|
||||
cfg = secrets_cfg.get(source.name)
|
||||
cfg = cfg if isinstance(cfg, dict) else {}
|
||||
result = _fetch_with_timeout(source, cfg, home_path, env)
|
||||
fetches.append((source, cfg, result))
|
||||
try:
|
||||
for var in source.protected_env_vars(cfg):
|
||||
protected.setdefault(var, source.name)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# Every var any source supplies directly — an alias never shadows a
|
||||
# var that some source will (or tried to) claim by its real name.
|
||||
supplied_directly: set = set()
|
||||
for _, _, result in fetches:
|
||||
if result.ok:
|
||||
supplied_directly.update(
|
||||
v for v in result.secrets if isinstance(v, str)
|
||||
)
|
||||
|
||||
# Apply phase — sequential, first-wins, fully attributed.
|
||||
claimed: Dict[str, str] = {} # var → source name that won it
|
||||
for source, cfg, result in fetches:
|
||||
sr = SourceReport(name=source.name,
|
||||
label=source.label or source.name,
|
||||
result=result)
|
||||
report.sources.append(sr)
|
||||
if not result.ok:
|
||||
continue
|
||||
|
||||
try:
|
||||
override = source.override_existing(cfg)
|
||||
except Exception: # noqa: BLE001
|
||||
override = False
|
||||
|
||||
def _try_apply(var: str, value: str, *, is_alias: bool = False) -> bool:
|
||||
"""Apply one var through the shared guard chain. True = applied."""
|
||||
if not is_valid_env_name(var):
|
||||
sr.skipped_invalid.append(var)
|
||||
return False
|
||||
if var in protected:
|
||||
sr.skipped_protected.append(var)
|
||||
return False
|
||||
if var in claimed:
|
||||
sr.skipped_claimed.append(var)
|
||||
report.conflicts.append(
|
||||
f"{var}: kept value from {claimed[var]}; "
|
||||
f"{source.name} also supplies it (first source wins — "
|
||||
"remove one binding or reorder secrets.sources)"
|
||||
)
|
||||
return False
|
||||
existed = bool(env.get(var))
|
||||
if existed and var in preserve:
|
||||
sr.skipped_existing.append(var)
|
||||
return False
|
||||
if existed and not override:
|
||||
sr.skipped_existing.append(var)
|
||||
return False
|
||||
env[var] = value
|
||||
claimed[var] = source.name
|
||||
sr.applied.append(var)
|
||||
report.provenance[var] = AppliedVar(
|
||||
name=var,
|
||||
source=source.name,
|
||||
shape=source.shape,
|
||||
overrode_env=existed,
|
||||
)
|
||||
return True
|
||||
|
||||
for var, value in result.secrets.items():
|
||||
if not isinstance(var, str) or not isinstance(value, str):
|
||||
continue
|
||||
applied = _try_apply(var, value)
|
||||
|
||||
if not applied or not profile:
|
||||
continue
|
||||
alias = _profile_alias_target(var, profile)
|
||||
if alias and alias not in supplied_directly and alias not in claimed:
|
||||
if _try_apply(alias, value, is_alias=True):
|
||||
result.warnings.append(
|
||||
f"applied profile-scoped {var} as {alias} "
|
||||
f"(active profile {profile!r})"
|
||||
)
|
||||
|
||||
return report
|
||||
Reference in New Issue
Block a user